Reusable inventory: 3D tool rack (notebook + phone)

A tool-driven inventory overlay — a three.js rack you cycle with ◀ ▶ and
open with USE. Registry-based (not tied to a level type) so new tools just
add a model + component. Reuses the phone's 3D model; notebook is a blocky
placeholder tool. Dev route /?inventory=1; drop <Inventory/> into the navbar
after the merges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 17:34:56 +02:00
co-authored by Claude Opus 4.8
parent bbf34bab09
commit b029c9bc47
4 changed files with 153 additions and 4 deletions
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import { buildPhone, CLOSED_ANGLE, PhonePreview } from './phone'
// A reusable "tools" inventory: a three.js rack you cycle through, one 3D tool at a
// time, then USE to open it. Tool-driven (see TOOLS below), so it isn't tied to any
// level type — new tools (map, visual novel, …) just register a model + a component.
// Rendered as a dev overlay at /?inventory=1 for now; drop <Inventory/> into the
// real navbar later.
function buildPhoneModel() {
const { group, hinge } = buildPhone()
hinge.rotation.x = CLOSED_ANGLE // sit closed on the rack
group.scale.setScalar(1.15)
group.position.y = -0.05
return group
}
function buildNotebook() {
const g = new THREE.Group()
const coverMat = new THREE.MeshStandardMaterial({ color: 0x7c3a2b, roughness: 0.85, metalness: 0.05, flatShading: true })
const pageMat = new THREE.MeshStandardMaterial({ color: 0xe7dcbf, roughness: 0.95, flatShading: true })
const wireMat = new THREE.MeshStandardMaterial({ color: 0xcaa74a, roughness: 0.5, metalness: 0.5, flatShading: true })
const back = new THREE.Mesh(new THREE.BoxGeometry(0.86, 1.16, 0.05), coverMat); back.position.z = -0.08; g.add(back)
const pages = new THREE.Mesh(new THREE.BoxGeometry(0.8, 1.08, 0.12), pageMat); g.add(pages)
const front = new THREE.Mesh(new THREE.BoxGeometry(0.86, 1.16, 0.05), coverMat); front.position.z = 0.09; g.add(front)
const strap = new THREE.Mesh(new THREE.BoxGeometry(0.06, 1.2, 0.02), new THREE.MeshStandardMaterial({ color: 0x2a2320, roughness: 0.8, flatShading: true }))
strap.position.set(0.3, 0, 0.12); g.add(strap)
for (let i = 0; i < 8; i++) { // spiral binding down the spine
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.035, 0.012, 6, 10), wireMat)
ring.position.set(-0.43, 0.49 - i * 0.14, 0); ring.rotation.y = Math.PI / 2; g.add(ring)
}
g.traverse(obj => { if (obj instanceof THREE.Mesh) obj.castShadow = true })
return g
}
const TOOLS: { key: string; name: string; build: () => THREE.Group }[] = [
{ key: 'notebook', name: 'Notebook', build: buildNotebook },
{ key: 'phone', name: 'Phone', build: buildPhoneModel },
]
function ToolRack({ index }: { index: number }) {
const stageRef = useRef<HTMLDivElement>(null)
const holderRef = useRef<THREE.Group | null>(null)
useEffect(() => {
const stage = stageRef.current
if (!stage) return
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
stage.appendChild(renderer.domElement)
renderer.domElement.style.cssText = 'position:absolute;inset:0;width:100%;height:100%'
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
camera.position.set(0.5, 0.45, 3.3)
camera.lookAt(0, 0, 0)
scene.add(new THREE.AmbientLight(0x40483a, 0.9))
const key = new THREE.DirectionalLight(0xfff4e0, 1.1); key.position.set(2, 3, 3); scene.add(key)
const rim = new THREE.DirectionalLight(0x9fd020, 0.4); rim.position.set(-2, 1, -2); scene.add(rim)
const holder = new THREE.Group(); scene.add(holder); holderRef.current = holder
const resize = () => { const w = stage.clientWidth, h = stage.clientHeight; if (w && h) { camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h, false) } }
resize(); const ro = new ResizeObserver(resize); ro.observe(stage)
let raf = 0
const tick = () => { holder.rotation.y += 0.008; renderer.render(scene, camera); raf = requestAnimationFrame(tick) }
tick()
return () => {
cancelAnimationFrame(raf); ro.disconnect(); renderer.dispose(); renderer.domElement.remove()
scene.traverse(o => { if (o instanceof THREE.Mesh) { o.geometry.dispose(); (o.material as THREE.Material).dispose() } })
holderRef.current = null
}
}, [])
// Swap the model when the selected tool changes.
useEffect(() => {
const holder = holderRef.current
if (!holder) return
while (holder.children.length) { const child = holder.children[0]; holder.remove(child); child.traverse(o => { if (o instanceof THREE.Mesh) { o.geometry.dispose(); (o.material as THREE.Material).dispose() } }) }
holder.rotation.set(0, 0, 0)
holder.add(TOOLS[index].build())
}, [index])
return <div ref={stageRef} className="inv-stage" />
}
export function Inventory({ onClose }: { onClose?: () => void }) {
const [index, setIndex] = useState(0)
const [active, setActive] = useState<string | null>(null)
if (active) return <div className="inv-tool">
<button className="inv-back" onClick={() => setActive(null)}> TOOLS</button>
{active === 'phone' ? <PhonePreview /> : <NotebookTool />}
</div>
return <div className="inv-backdrop">
<ToolRack index={index} />
<button className="inv-arrow left" onClick={() => setIndex(i => (i - 1 + TOOLS.length) % TOOLS.length)} aria-label="Previous tool"></button>
<button className="inv-arrow right" onClick={() => setIndex(i => (i + 1) % TOOLS.length)} aria-label="Next tool"></button>
<div className="inv-plate">
<div className="inv-name">{TOOLS[index].name}</div>
<div className="inv-dots">{TOOLS.map((tool, i) => <span key={tool.key} className={i === index ? 'on' : ''} />)}</div>
<button className="inv-use" onClick={() => setActive(TOOLS[index].key)}>USE </button>
</div>
{onClose && <button className="inv-close" onClick={onClose}>×</button>}
<p className="inv-hint">INVENTORY · dev preview</p>
</div>
}
// Placeholder notebook tool — a lined page you can scribble on (not yet persisted).
function NotebookTool() {
const [text, setText] = useState('')
return <div className="notebook">
<div className="notebook-page">
<div className="notebook-head">FIELD NOTEBOOK</div>
<textarea value={text} onChange={e => setText(e.target.value)} placeholder="Jot a lead…" spellCheck={false} />
</div>
</div>
}
+5 -2
View File
@@ -6,13 +6,16 @@ import './styles.css'
// three.js is lazy-loaded so the board never pays for it up front.
const PhonePreview = lazy(() => import('./phone').then(m => ({ default: m.PhonePreview })))
const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory })))
// Routing: the bare root is the game's front door (splash + campaign); /level/:id,
// /admin, and the legacy ?level= deep link open the board; ?phone=1 is the spike.
// /admin, and the legacy ?level= deep link open the board; ?phone / ?inventory spikes.
const path = window.location.pathname
const search = new URLSearchParams(window.location.search)
const isBoard = path.startsWith('/level/') || path === '/admin' || search.has('level')
const root = search.has('phone')
? <Suspense fallback={null}><PhonePreview /></Suspense>
: isBoard ? <App /> : <Play />
: search.has('inventory')
? <Suspense fallback={null}><Inventory /></Suspense>
: isBoard ? <App /> : <Play />
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
+2 -2
View File
@@ -11,7 +11,7 @@ import * as THREE from 'three'
// flag requirements come from the story graph (see the "mobile" gate discussion).
const SCREEN_RECT = { top: 9, left: 25, width: 50, height: 30 } // % of the stage
const CLOSED_ANGLE = 3.12 // hinge rotation.x when shut (~179°: lid folds over the keypad)
export const CLOSED_ANGLE = 3.12 // hinge rotation.x when shut (~179°: lid folds over the keypad)
const OPEN_ANGLE = 0 // lid stands up, coplanar with the keypad, facing camera
// ---- placeholder telephony audio (to be replaced by recorded assets) ----------
@@ -46,7 +46,7 @@ const sfx = {
type Built = { group: THREE.Group; hinge: THREE.Group; keys: THREE.Mesh[] }
function buildPhone(): Built {
export function buildPhone(): Built {
const group = new THREE.Group()
const keys: THREE.Mesh[] = []
+26
View File
@@ -663,3 +663,29 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.splash-case:disabled { opacity: .5; cursor: default; }
.splash-case-title { font-weight: 600; letter-spacing: .5px; }
.splash-case-action { color: #d58a46; font-size: 12px; letter-spacing: 1px; white-space: nowrap; }
/* === Inventory (tools rack) — src/inventory.tsx ===================== */
.inv-backdrop { position: fixed; inset: 0; z-index: 500; display: flex; flex-direction: column; align-items: center; justify-content: center;
background: radial-gradient(120% 90% at 50% 35%, #14201c 0%, #070d0b 70%, #030605 100%); }
.inv-stage { position: absolute; inset: 0; }
.inv-arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; width: 46px; height: 46px; border: 1px solid #3c5a52;
background: #0a211de0; color: #cdea6a; font-size: 16px; cursor: pointer; }
.inv-arrow.left { left: 6vw; } .inv-arrow.right { right: 6vw; }
.inv-arrow:hover { border-color: #cdea6a; }
.inv-plate { position: absolute; bottom: 8vh; z-index: 2; display: flex; flex-direction: column; align-items: center; gap: 10px; }
.inv-name { font: 600 15px IBM Plex Mono, ui-monospace, monospace; letter-spacing: 3px; color: #eafaa0; text-transform: uppercase; }
.inv-dots { display: flex; gap: 7px; }
.inv-dots span { width: 7px; height: 7px; border: 1px solid #5f7b73; border-radius: 50%; }
.inv-dots span.on { background: #cdea6a; border-color: #cdea6a; }
.inv-use { border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace; letter-spacing: 2px; padding: 9px 26px; cursor: pointer; }
.inv-use:hover { border-color: #cdea6a; color: #eafaa0; }
.inv-close { position: absolute; top: 16px; right: 18px; z-index: 2; width: 34px; height: 34px; border: 1px solid #3c5a52; background: #0a211de0; color: #cfe8df; cursor: pointer; }
.inv-hint { position: absolute; bottom: 14px; z-index: 2; margin: 0; color: #5f7b73; font: 11px ui-monospace, monospace; letter-spacing: 2px; }
.inv-tool { position: fixed; inset: 0; z-index: 500; }
.inv-back { position: absolute; top: 16px; left: 18px; z-index: 520; border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace; letter-spacing: 1px; padding: 6px 14px; cursor: pointer; }
.inv-back:hover { border-color: #cdea6a; }
/* notebook tool (placeholder) */
.notebook { position: fixed; inset: 0; z-index: 500; display: grid; place-items: center; background: radial-gradient(120% 90% at 50% 30%, #1c1a12 0%, #0a0906 75%); }
.notebook-page { width: min(560px, 88vw); height: min(72vh, 720px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 26px 30px; display: flex; flex-direction: column; gap: 14px; }
.notebook-head { font: 700 13px IBM Plex Mono, monospace; letter-spacing: 3px; color: #6a5a3a; }
.notebook-page textarea { flex: 1; background: transparent; border: none; outline: none; resize: none; font: 16px/31px "Courier New", monospace; color: #3a3020; }