# Drag & Drop

> Cards dropped into slots and a slider with useDraggable, and a slingshot made with the drag events alone: a drag is never a click, and what it is let go over is the target.

Example `drag-and-drop`, running at https://nacatamalon.com/en/examples/drag-and-drop. The whole catalogue: https://nacatamalon.com/llms/examples.md.

The cards and the slider use `useDraggable`, which moves what it is given. Each slot is a `useDropZone` that accepts some tags: red and yellow are `warm`, green is `cool`, and the last slot takes both. While a card travels, the slots that take it show an outline (`canDrop()`), bright on the one under it (`isOver()`); anywhere else, `snapBack` sends it home. A full slot disables itself and opens again when its card leaves. Clicking a card without moving it still turns it over.

The slider is the same hook along `x`, inside the track, in steps of ten. The slingshot uses only the drag events through `listen`: the stone does not follow the pointer, and the pull aims the throw.

## Code

`index.ts`:

```ts
import { createGame } from 'nacatamalon';
import { Level } from './scenes/demo';
import { WIDTH, HEIGHT } from './settings';

const game = createGame('#app', {
    width: WIDTH,
    height: HEIGHT,
    background: '#10141d'
});

// Declare Scenes
game({
    Level
});
```

`components/cards.ts`:

```ts
import { createSprite, getColor, useDropZone, useUpdate, useTag, useDraggable, type TFont, type TDropZoneHandle, type TDropZoneListener } from 'nacatamalon';
import { COLORS, SLOTS } from '../settings';
import { label } from '../helpers/label';

export const Cards = ({ font }: { font: TFont }) => {
    label(font, 'DROP INTO A SLOT', 123, 18);

    const CAN = getColor('#3a4560');
    const OVER = getColor('#ffcc33');
    // Which slot each card is in, to open it again when the card leaves.
    const inSlot = new Map<Parameters<TDropZoneListener>[0], TDropZoneHandle>();

    SLOTS.forEach(({ accepts, name }, i) => {
        const x = 36 + i * 58;

        // The outline: a slightly bigger sprite behind the slot.
        const outline = createSprite({
            width: 58,
            height: 78,
            tint: CAN,
            visible: false,
            zIndex: -1,
            transform: {
                x: x,
                y: 140
            }
        });

        const slot = createSprite({
            width: 52,
            height: 72,
            tint: '#1e2533',
            transform: {
                x: x,
                y: 140
            }
        });

        label(font, name, x, 190);

        const zone = useDropZone(slot, {
            accepts,
            onDrop: (card) => {
                if (card.transform === null) {
                    return;
                }

                card.transform.x = slot.transform.x;
                card.transform.y = slot.transform.y;
                inSlot.set(card, zone);
                zone.disable();
            },
        });

        useUpdate(() => {
            outline.visible = zone.canDrop();
            outline.tint = zone.isOver() ? OVER : CAN;
        });
    });

    COLORS.forEach((color, i) => {
        const face = getColor(color);
        const back = getColor('#3a4560');
        let up = true;
        let previousZone: TDropZoneHandle | undefined;

        const card = createSprite({
            width: 44,
            height: 64,
            tint: face,
            transform: {
                x: 65 + i * 58,
                y: 66
            },
            onClick: () => {
                up = !up;
                card.tint = up ? face : back;
            },
        });

        useTag(i < 2 ? 'warm' : 'cool').add(card);

        useDraggable(card, {
            snapBack: true,
            // On top while it travels, and a little bigger, as if lifted. Out of its slot, if it was
            // in one: the slot opens again.
            onDragStart: () => {
                card.zIndex = 10;
                card.transform.scaleX = card.transform.scaleY = 1.1;
                previousZone = inSlot.get(card);
                previousZone?.enable();
                inSlot.delete(card);
            },
            onDragEnd: ({ dropped }) => {
                card.zIndex = 0;
                card.transform.scaleX = card.transform.scaleY = 1;

                // A rejected drop returns to its previous slot, which is occupied again.
                if (dropped === null && previousZone !== undefined) {
                    previousZone.disable();
                    inSlot.set(card, previousZone);
                }

                previousZone = undefined;
            },
        });
    });
};
```

`components/slider.ts`:

```ts
import { createSprite, createText, useDraggable, type TFont } from 'nacatamalon';
import { SLIDER } from '../settings';
import { label } from '../helpers/label';

export const Slider = ({ font }: { font: TFont }) => {
    label(font, `SLIDER (AXIS X, GRID ${SLIDER.grid})`, 120, 222);
    createSprite({
        width: SLIDER.width,
        height: 4,
        tint: '#3a4560',
        anchor: {
            x: 0,
            y: 0.5
        },
        transform: {
            x: SLIDER.left,
            y: SLIDER.y
        }
    });

    const value = createText({
        text: '0',
        font,
        style: {
            fontSize: 8
        },
        tint: '#ffffff',
        anchor: {
            x: 0.5,
            y: 0.5
        },
        transform: {
            x: 120,
            y: 290
        }
    });

    const knob = createSprite({
        width: 14,
        height: 22,
        tint: '#ffcc33',
        transform: {
            x: SLIDER.left,
            y: SLIDER.y
        }
    });

    useDraggable(knob, {
        axis: 'x',
        grid: SLIDER.grid,
        bounds: {
            x: SLIDER.left,
            y: SLIDER.y,
            width: SLIDER.width,
            height: 0
        },
        onDrag: () => {
            value.text = String(Math.round(((knob.transform.x - SLIDER.left) / SLIDER.width) * 100));
        },
    });
};
```

`components/slingshot.ts`:

```ts
import { createSprite, listen, useUpdate, type TFont } from 'nacatamalon';
import { WIDTH, HEIGHT, SLING } from '../settings';
import { label } from '../helpers/label';

export const Slingshot = ({ font }: { font: TFont }) => {
    label(font, 'PULL AND LET GO', 360, 22);
    createSprite({
        width: 6,
        height: 40,
        tint: '#7a5230',
        anchor: {
            x: 0.5,
            y: 0
        },
        transform: {
            x: SLING.x,
            y: SLING.y + 8
        }
    });

    const stone = createSprite({
        width: 16,
        height: 16,
        tint: '#c9d1e0',
        transform: {
            x: SLING.x,
            y: SLING.y
        }
    });

    // The aim: five dots along where it will fly, shown while pulling.
    const dots = [1, 2, 3, 4, 5].map(() => createSprite({
        width: 4,
        height: 4,
        tint: '#4fd6a0',
        visible: false,
        transform: {
            x: 0,
            y: 0
        }
    }));

    let flying: { vx: number; vy: number } | null = null;

    const pull = (dx: number, dy: number) => {
        const length = Math.hypot(dx, dy);
        const max = SLING.maxPull;

        return length > max ? {
            x: (dx / length) * max,
            y: (dy / length) * max
        } : {
            x: dx,
            y: dy
        };
    };

    listen(stone, {
        onDragStart: () => {
            flying = null;
        },
        onDrag: ({ deltaX, deltaY }) => {
            const p = pull(deltaX, deltaY);

            stone.transform.x = SLING.x + p.x;
            stone.transform.y = SLING.y + p.y;
            dots.forEach((dot, i) => {
                dot.visible = true;
                dot.transform.x = SLING.x - p.x * (i + 1) * 0.5;
                dot.transform.y = SLING.y - p.y * (i + 1) * 0.5;
            });
        },
        onDragEnd: ({ deltaX, deltaY }) => {
            const p = pull(deltaX, deltaY);

            dots.forEach((dot) => {
                dot.visible = false;
            });
            flying = {
                vx: -p.x * SLING.speed,
                vy: -p.y * SLING.speed
            };
        },
    });

    useUpdate((delta) => {
        if (flying === null) {
            return;
        }

        flying.vy += SLING.gravity * delta;
        stone.transform.x += flying.vx * delta;
        stone.transform.y += flying.vy * delta;

        // Off the screen: back on the sling.
        if (stone.transform.y > HEIGHT + 20 || stone.transform.x < -20 || stone.transform.x > WIDTH + 20) {
            flying = null;
            stone.transform.x = SLING.x;
            stone.transform.y = SLING.y;
        }
    });
};
```

`helpers/label.ts`:

```ts
import { createText, type TFont } from 'nacatamalon';

export const label = (font: TFont, text: string, x: number, y: number) =>
    createText({
        text,
        font,
        style: {
            fontSize: 8
        },
        tint: '#8a94a8',
        anchor: {
            x: 0.5,
            y: 0.5
        },
        transform: {
            x: x,
            y: y
        }
    });
```

`scenes/demo.ts`:

```ts
import { createScene, useLoadFont, useSpawn } from 'nacatamalon';
import { UI_FONT } from '../settings';
import { Cards } from '../components/cards';
import { Slider } from '../components/slider';
import { Slingshot } from '../components/slingshot';

export const Level = () => {
    const font = useLoadFont(UI_FONT);

    useSpawn(Cards)({
        font
    });
    useSpawn(Slider)({
        font
    });
    useSpawn(Slingshot)({
        font
    });

    return createScene();
};
```

`settings.ts`:

```ts
export const WIDTH = 480;
export const HEIGHT = 320;
export const UI_FONT = {
    key: 'ui',
    src: '/example-assets/fonts/vector/PressStart2P-Regular.ttf'
};
export const COLORS = ['#ff5a5a', '#ffcc33', '#4fd6a0'];
export const SLOTS = [
    {
        accepts: 'warm',
        name: 'WARM'
    },
    {
        accepts: 'warm',
        name: 'WARM'
    },
    {
        accepts: 'cool',
        name: 'COOL'
    },
    {
        accepts: ['warm', 'cool'],
        name: 'ANY'
    }
];
export const SLIDER = {
    left: 30,
    width: 180,
    grid: 10,
    y: 260
};
export const SLING = {
    x: 360,
    y: 200,
    maxPull: 60,
    speed: 8,
    gravity: 400
};
```
