Drag and drop
<Draggable data> makes what it wraps draggable, and <DropTarget onDrop> is where a drag lands. The data a Draggable carries is the game’s own, such as an item’s id, and the DropTarget that takes it gets that data in onDrop. A drag works with a mouse or a finger, from one Hud to another.
Their props are in DraggableProps and DropTargetProps.
Both need the dragAndDrop() plugin in the Game’s plugins, which holds the drags of the whole screen:
import { dragAndDrop, Game, type PluginList } from "@spawnite/engine";
const plugins: PluginList = [dragAndDrop()];
export function App() { return ( <Game name="hotbar" start="town" plugins={plugins}> {/* scenes */} </Game> );}The plugin loads dnd-kit, about 34 KB gzipped, as a file of its own in the browser, so a game that drags nothing never fetches it, and a room or a test in Node never loads it. The game’s first frame waits for that file, as it waits for the save. A Draggable in a game without the plugin throws an error that names it.
import { Button, Draggable, DropTarget, Hud, Panel, Slot,} from "@spawnite/engine";
type Potion = { kind: "potion"; name: string };
function isPotion(data: unknown): data is Potion { return ( typeof data === "object" && data !== null && "kind" in data && data.kind === "potion" );}
interface HotbarProps { bag: Potion[]; pads: (Potion | null)[]; setPad: (index: number, potion: Potion | null) => void; drink: (potion: Potion) => void;}
export function Hotbar({ bag, pads, setPad, drink }: HotbarProps) { return ( <Hud> <Panel slot={Slot.Center}> {bag.map((potion) => ( <Draggable key={potion.name} data={potion}> <Button onPress={() => drink(potion)}> {potion.name} </Button> </Draggable> ))} </Panel> <Panel slot={Slot.Bottom}> {pads.map((pad, index) => ( <DropTarget key={index} accepts={isPotion} onDrop={(potion) => setPad(index, potion)} className="rounded-xl data-[over]:ring-2 data-[over]:ring-white" > <Draggable data={pad} disabled={pad === null} onDragEnd={({ dropped, canceled }) => { if (!dropped && !canceled) setPad(index, null); }} > <Button label={`Pad ${index + 1}`} onPress={() => pad && drink(pad)} > {pad?.name ?? "Empty"} </Button> </Draggable> </DropTarget> ))} </Panel> </Hud> );}Dragging
Section titled “Dragging”A mouse press becomes a drag once it moves 8 pixels, and a finger once it holds still for a quarter of a second, so a swipe still scrolls a window. A press that does not become a drag reaches the Button inside as a press, and the release that ends a drag presses nothing.
While dragged, a copy of the children follows the pointer and the children stay where they are, as an item stays in its cell until it lands. preview shows something else under the pointer, such as the item’s icon for a whole row of the bag. disabled takes no drag and leaves every press to the children.
onDragEnd({ dropped, canceled }) runs when the drag ends: dropped is true when a DropTarget took it, and canceled is true when Escape called it off, which puts everything back as it was. While a drag runs, Escape cancels it before it closes a Window or opens the menu. A hotbar slot dropped on nothing, neither dropped nor canceled, clears itself.
useDragging() is true while any drag is under way, such as to fade a window that stands over the hotbar.
Dropping
Section titled “Dropping”accepts is a type guard on what the drag carries. A drag it refuses passes over the DropTarget as if it were not there, and onDrop gets the data typed by the guard. A DropTarget with no accepts takes every drag, and its onDrop gets unknown, so TypeScript refuses a typed onDrop without the guard that makes it true.
A drop lands on the DropTarget under the pointer through anything drawn over it, such as a window’s dimmed screen. While a drag it takes is over it, the DropTarget sets its data-over attribute, which a class such as data-[over]:ring-2 styles.
Both components draw a div around their children, which takes className and style for its size and place, and each shows as a row in the devtools tree.
Keyboard
Section titled “Keyboard”A drag takes a pointer. Enter and Space press the Button a Draggable holds, as they do anywhere else, so a keyboard player uses the Button’s own action.
What other engines do
Section titled “What other engines do”Godot, Unity, Unreal and Phaser split a drag the same way: the thing dragged carries the data, and the target decides whether to take it. Godot overrides _get_drag_data, _can_drop_data and _drop_data on a Control. Unity’s UGUI puts IDragHandler on the source and IDropHandler on the target. Unreal makes a UDragDropOperation with a payload in OnDragDetected, and the target overrides OnDrop. Roblox’s UIDragDetector and PlayCanvas’s ElementDragHelper drag without a target, and the game checks what lies under the pointer when the drag ends. Here, accepts is Godot’s _can_drop_data, and onDragEnd’s dropped is Godot’s is_drag_successful() and Unity’s OnEndDrag. The engine builds both on dnd-kit.
Cost per frame: none. During a drag, each move of the pointer tests the dragged copy’s box against each DropTarget’s box.