From b029c9bc47cbdd6c0076c31b52aca4b75b17bc25 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sat, 22 Aug 2026 17:34:56 +0200 Subject: [PATCH 1/2] Reusable inventory: 3D tool rack (notebook + phone) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 into the navbar after the merges. Co-Authored-By: Claude Opus 4.8 --- src/inventory.tsx | 120 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.tsx | 7 ++- src/phone.tsx | 4 +- src/styles.css | 26 ++++++++++ 4 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 src/inventory.tsx diff --git a/src/inventory.tsx b/src/inventory.tsx new file mode 100644 index 0000000..3c77ddf --- /dev/null +++ b/src/inventory.tsx @@ -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 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(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 }: { onClose?: () => void }) { + const [index, setIndex] = useState(0) + const [active, setActive] = useState(null) + + if (active) return
+ + {active === 'phone' ? : } +
+ + return
+ + + +
+
{TOOLS[index].name}
+
{TOOLS.map((tool, i) => )}
+ +
+ {onClose && } +

INVENTORY · dev preview

+
+} + +// Placeholder notebook tool — a lined page you can scribble on (not yet persisted). +function NotebookTool() { + const [text, setText] = useState('') + return
+
+
FIELD NOTEBOOK
+