import { useEffect, useRef, useState } from 'react' import * as THREE from 'three' import { buildPhone, CLOSED_ANGLE, PhonePreview, type PhoneSession } 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 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.5 // the phone's mass hangs below its pivot — lift it to centre in frame 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(null) const holderRef = useRef(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 } export function Inventory({ onClose, session, onTearToBoard }: { onClose?: () => void; session?: PhoneSession; onTearToBoard?: (text: string) => void }) { const [index, setIndex] = useState(0) const [active, setActive] = useState(null) if (active) return setActive(null)}>‹ TOOLS {onClose && EXIT ✕} {active === 'phone' ? : } return setIndex(i => (i - 1 + TOOLS.length) % TOOLS.length)} aria-label="Previous tool">◀ setIndex(i => (i + 1) % TOOLS.length)} aria-label="Next tool">▶ {TOOLS[index].name} {TOOLS.map((tool, i) => )} setActive(TOOLS[index].key)}>USE ▸ {onClose && ×} INVENTORY · dev preview } // Field notebook: the lines captured from NPCs during play. A page can be torn off // onto the board as a note (when a board tear handler is available). function NotebookTool({ playthroughId, onTear }: { playthroughId?: string; onTear?: (text: string) => void }) { const [pages, setPages] = useState<{ id: string; text: string }[]>([]) const [draft, setDraft] = useState('') useEffect(() => { if (!playthroughId) return fetch(`/api/playthroughs/${playthroughId}/notebook`).then(r => r.ok ? r.json() : []).then(setPages).catch(() => {}) }, [playthroughId]) const remove = async (id: string) => { if (playthroughId) await fetch(`/api/playthroughs/${playthroughId}/notebook/${id}`, { method: 'DELETE' }) setPages(list => list.filter(page => page.id !== id)) } const tear = (page: { id: string; text: string }) => { onTear?.(page.text); void remove(page.id) } const add = async () => { const text = draft.trim() if (!text || !playthroughId) return const res = await fetch(`/api/playthroughs/${playthroughId}/notebook`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) }) if (res.ok) { const page = await res.json(); setPages(list => [...list, { id: page.id, text: page.text }]); setDraft('') } } return FIELD NOTEBOOK {playthroughId && setDraft(event.target.value)} placeholder="Write your own note…" spellCheck={false} rows={2} /> + Add } {!pages.length && Nothing noted yet. Write one above, or during a conversation use “✎ Note this”.} {pages.map(page => {page.text} {onTear && tear(page)} title="Tear off onto the board">✂ Tear to board} void remove(page.id)} title="Discard">× )} }
INVENTORY · dev preview
Nothing noted yet. Write one above, or during a conversation use “✎ Note this”.
{page.text}