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 + setActive(null)}>‹ TOOLS + {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 + +} + +// Placeholder notebook tool — a lined page you can scribble on (not yet persisted). +function NotebookTool() { + const [text, setText] = useState('') + return + + FIELD NOTEBOOK + setText(e.target.value)} placeholder="Jot a lead…" spellCheck={false} /> + + +} diff --git a/src/main.tsx b/src/main.tsx index 6644b9d..f0f678e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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') ? - : isBoard ? : + : search.has('inventory') + ? + : isBoard ? : createRoot(document.getElementById('root')!).render({root}) diff --git a/src/phone.tsx b/src/phone.tsx index 9013233..a3b1f29 100644 --- a/src/phone.tsx +++ b/src/phone.tsx @@ -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[] = [] diff --git a/src/styles.css b/src/styles.css index 7f64057..57bab76 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; }
INVENTORY · dev preview