2026-08-22 17:34:56 +02:00
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
|
|
|
import * as THREE from 'three'
|
2026-08-22 19:49:00 +02:00
|
|
|
|
import { buildPhone, CLOSED_ANGLE, PhonePreview, type PhoneSession } from './phone'
|
2026-08-22 17:34:56 +02:00
|
|
|
|
|
|
|
|
|
|
// 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" />
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 19:49:00 +02:00
|
|
|
|
export function Inventory({ onClose, session }: { onClose?: () => void; session?: PhoneSession }) {
|
2026-08-22 17:34:56 +02:00
|
|
|
|
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>
|
2026-08-22 19:49:00 +02:00
|
|
|
|
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool />}
|
2026-08-22 17:34:56 +02:00
|
|
|
|
</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>
|
|
|
|
|
|
}
|