Inventory#

In this tutorial we build the classic game UI: a drag-and-drop inventory. A backpack grid, typed equipment slots that only take the right category, a drag ghost that follows the pointer with a little weight, drop targets that light up, invalid drops that fly home, hover tooltips with rarity colors, and one more thing: the whole board is built on onejs-ui's theme variables, so swapping themes restyles every custom slot live.

The item art comes from the free RPG icons free starter pack by icreatepixels; any square transparent PNGs work. Every bullet point is an actionable step. Everything else is explanation.

Project setup#

  • Create a scene, add an App GameObject with a JSRunner, click Initialize Project (the Nameplates tutorial covers this workflow in detail).
  • Install the component library: npm install onejs-ui inside ~/.
  • Drop your icons into ~/assets/icons/.

Items#

Item data is plain TypeScript. Categories double as equipment slot types, which is what makes slot validation a one-liner later. In ~/items.ts:

export const EQUIP_SLOTS = [
    "weapon", "offhand", "helmet", "chest", "legs",
    "boots", "gloves", "cloak", "necklace", "ring",
] as const

export type EquipSlot = typeof EQUIP_SLOTS[number]
export type Category = EquipSlot | "consumable" | "material"
export type Rarity = "common" | "uncommon" | "rare" | "epic"

export type Item = {
    id: string
    name: string
    icon: string
    category: Category
    rarity: Rarity
    desc: string
}

/** Rarity colors stay fixed across themes, like most games do it. */
export const RARITY_COLOR: Record<Rarity, string> = {
    common: "#9aa3b2",
    uncommon: "#4ade80",
    rare: "#38bdf8",
    epic: "#c084fc",
}

export const ITEMS: Record<string, Item> = {
    sword: { id: "sword", name: "Knight's Blade", icon: "sword.png", category: "weapon", rarity: "epic", desc: "A blade that remembers every battle it has won." },
    armor: { id: "armor", name: "Gilded Cuirass", icon: "armor.png", category: "chest", rarity: "epic", desc: "Polished bright enough to blind an enemy." },
    helmet: { id: "helmet", name: "Sturdy Helm", icon: "helmets.png", category: "helmet", rarity: "uncommon", desc: "Smells faintly of its previous owner." },
    apple: { id: "apple", name: "Crisp Apple", icon: "apple.png", category: "consumable", rarity: "common", desc: "Keeps something away, allegedly." },
    // ...as many as you like
}

export const BAG_COLS = 6
export const STARTING_BAG: (string | null)[] = ["sword", "armor", "apple", null /* ...24 cells */]
export const STARTING_EQUIP: Record<EquipSlot, string | null> =
    Object.fromEntries(EQUIP_SLOTS.map(s => [s, null])) as Record<EquipSlot, string | null>

Style on the theme's variables#

This is the load-bearing decision of the whole tutorial. onejs-ui themes work by emitting --ojs-* USS custom properties, and its components read them with var(). Custom UI that does the same inherits every theme automatically. In ~/inventory.module.uss:

.slot {
    width: 56px;
    height: 56px;
    margin: 3px;
    background-color: var(--ojs-bg);
    border-width: 1px;
    border-color: var(--ojs-border);
    border-radius: var(--ojs-radius-md);
    align-items: center;
    justify-content: center;
    transition-property: background-color, border-color, scale, opacity;
    transition-duration: 0.12s;
    transition-timing-function: ease-out;
}

/* The slot currently under a drag. */
.slotHot {
    border-color: var(--ojs-primary);
    background-color: var(--ojs-overlay-hover);
    scale: 1.07;
}

/* Equipment slots that accept the dragged item's category. */
.slotAccepts { border-color: var(--ojs-success); }

/* Everything that cannot take the dragged item. */
.slotDim { opacity: 0.45; }

.item {
    width: 46px;
    height: 46px;
    transition-property: scale, translate;
    transition-duration: 0.1s;
    transition-timing-function: ease-out;
}

.item:hover {
    scale: 1.1;
    translate: 0 -2px;
}

/* The origin cell's icon while its item is being dragged. */
.dragSource { opacity: 0.25; }

.ghost {
    position: absolute;
    width: 56px;
    height: 56px;
    opacity: 0.95;
}

Pseudo-classes and transitions have to live in a .uss file (they cannot be inline styles), which is why the module exists. The transitions are the smoothness: slot highlights ease in, items lift on hover, and none of it costs JavaScript.

The board#

The board is ordinary React: a 2x5 equipment column and a 6x4 bag grid, with onejs-ui providing the chrome. The full JSX is in the sample; the shape of one cell is what matters:

<View
    name={`bag-${i}`}
    ref={(el: VisualElement | null) => { bagRefs.current[i] = el }}
    className={cellClass({ kind: "bag", index: i })}
    style={id ? { borderColor: RARITY_COLOR[ITEMS[id].rarity] + "88" } : {}}
    onPointerDown={(e: any) => beginDrag({ kind: "bag", index: i }, e)}
    onPointerEnter={() => showTooltip({ kind: "bag", index: i }, bagRefs.current[i])}
    onPointerLeave={() => setTooltip(null)}
>
    {id && <View pickingMode="Ignore" className={styles.item}
        style={{ backgroundImage: icon(ITEMS[id].icon) }} />}
</View>
  • Icons render as backgroundImage textures, loaded once through loadImage from onejs-unity/assets.
  • The rarity tint rides the inline borderColor, on top of the class's themed border.
  • pickingMode="Ignore" on the icon keeps pointer events landing on the cell.
  • The name prop is how a cell gets identified from the outside; the address of a cell in code is a tiny union: { kind: "bag", index } | { kind: "equip", slot }.

The drag engine#

The split follows every tutorial in this series: React state owns what is where, refs own where the pixels are. A drag renders React exactly twice (pickup mounts the ghost, commit applies the move); everything per-frame is imperative.

Pointer events in OneJS arrive with panel coordinates and bubble to the root, so the board's root handles onPointerMove and onPointerUp and no capture is needed: once a drag starts, the root sees every move wherever the pointer goes.

const beginDrag = (cell: CellId, e: { x: number, y: number, button: number }) => {
    const L = live.current                    // one ref object for all per-frame state
    if (e.button !== 0 || L.drag || L.settling) return
    const itemId = itemAt(cell)
    if (!itemId) return
    L.drag = { itemId, from: cell }
    L.rects = measure()                       // worldBound of every cell, once per drag
    L.pointer = L.ghost = { x: e.x, y: e.y }
    L.hot = null
    setDrag(L.drag)                           // mounts the ghost

    const tick = () => {                      // the ghost chases the pointer with a
        L.raf = requestAnimationFrame(tick)   // light lag, which reads as weight
        const el = ghostRef.current
        if (!el || L.settling) return
        L.ghost.x += (L.pointer.x - L.ghost.x) * 0.42
        L.ghost.y += (L.pointer.y - L.ghost.y) * 0.42
        el.style.left = L.ghost.x - HALF
        el.style.top = L.ghost.y - HALF
    }
    L.raf = requestAnimationFrame(tick)
}

const onMove = (e: { x: number, y: number }) => {
    const L = live.current
    if (!L.drag || L.settling) return
    L.pointer = { x: e.x, y: e.y }
    const hit = hitTest(e.x, e.y)             // point-in-rect over the measured cells
    const hot = hit && accepts(hit, L.drag.itemId) ? hit : null
    if (JSON.stringify(hot) !== JSON.stringify(L.hot)) {
        setHotClass(L.hot, false)             // AddToClassList/RemoveFromClassList
        setHotClass(hot, true)                // through the refs; the USS transition
        L.hot = hot                           // does the easing
    }
}

Validation is the one-liner the category design bought us:

const accepts = (cell: CellId, itemId: string): boolean =>
    cell.kind === "bag" || ITEMS[itemId].category === cell.slot

The drop resolves in onPointerUp, and this is where the polish lives: whatever happens, the ghost flies to its destination before state commits. A valid drop eases into the target cell; an invalid or missed drop eases back to the origin. Nothing teleports.

const onUp = () => {
    const L = live.current
    if (!L.drag || L.settling) return
    const d = L.drag
    const target = L.hot
    setHotClass(L.hot, false)

    let commit: (() => void) | null = null
    let destCell: CellId = d.from             // default: fly home
    if (target && !sameCell(target, d.from)) {
        const displaced = itemAt(target)      // swap support: the displaced item
        if (!displaced || accepts(d.from, displaced)) {   // must fit the origin
            destCell = target
            commit = () => { put(target, d.itemId); put(d.from, displaced) }
        }
    }

    const r = rectOf(destCell)
    const from = { ...L.ghost }
    const to = { x: r.x + r.width / 2, y: r.y + r.height / 2 }
    const t0 = performance.now()
    L.settling = true
    const fly = () => {
        const el = ghostRef.current
        const p = Math.min(1, (performance.now() - t0) / 150)
        const ease = 1 - (1 - p) ** 3
        if (el) {
            el.style.left = from.x + (to.x - from.x) * ease - HALF
            el.style.top = from.y + (to.y - from.y) * ease - HALF
        }
        if (p < 1) requestAnimationFrame(fly)
        else {
            cancelAnimationFrame(L.raf)
            L.settling = false
            L.drag = null
            commit?.()                        // state changes only after the flight
            setDrag(null)                     // unmounts the ghost
        }
    }
    requestAnimationFrame(fly)
}

The tooltip is twenty lines on the same primitives: onPointerEnter measures the cell's worldBound, React renders a small themed card next to it, and a class added one frame after mount runs its fade-in transition.

Live theme swapping#

Wrap the app in ThemeProvider and theme switching is one call:

function ThemePicker() {
    const { setTheme } = useTheme()
    return ["dark", "light", "emerald"].map(name => (
        <Button key={name} text={name} size="sm" onClick={() => setTheme(name)} />
    ))
}

dark and light ship with onejs-ui. A theme of your own is a token object away:

registerTheme("emerald", {
    ...darkTheme,
    bg: "#0a1410",
    surface: "#122019",
    border: "#234234",
    primary: "#34d399",
    onPrimary: "#04150e",
})

Because the slots, ghost, and tooltip all read --ojs-* variables, setTheme restyles the entire inventory in one frame with zero re-renders. The same switch also drives premade Asset Store themes (Pixel, Kawaii, Sketch below): import the theme module to register it, and its 9-slice frames and fonts skin your custom slots the same way.

Where to go next#

  • The full event surface the drag engine rides on: Events
  • What the tokens mean and how themes register: onejs-ui Theming
  • Wire the inventory to game state in C#: State Sync