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
AppGameObject with a JSRunner, click Initialize Project (the Nameplates tutorial covers this workflow in detail). - Install the component library:
npm install onejs-uiinside~/. - 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
backgroundImagetextures, loaded once throughloadImagefromonejs-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
nameprop 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.slotThe 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