import { useEffect, useRef, useState } from 'react' import * as THREE from 'three' // A diegetic 90s handset rendered as a small three.js scene inside a fixed 1:2 // portrait stage. The 3D layer owns the blocky body, the flip-open animation, and // the pressable keys; the screen UI is real DOM positioned in PERCENT of the stage // (the ortho camera is head-on, so the screen face maps to a constant rectangle). // // SPIKE STATUS: the directory + flag checks below are local stubs. In the game the // phone is an always-available surface whose number->node directory and node-enable // 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) const OPEN_ANGLE = 0 // lid stands up, coplanar with the keypad, facing camera // ---- placeholder telephony audio (to be replaced by recorded assets) ---------- let pctx: AudioContext | null = null function ac() { if (!pctx) { const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (AC) pctx = new AC() } if (pctx?.state === 'suspended') void pctx.resume() return pctx } function tone(freqs: number[], dur: number, when = 0, gain = 0.05) { const ctx = ac(); if (!ctx) return const t = ctx.currentTime + when const g = ctx.createGain() g.gain.setValueAtTime(0.0001, t) g.gain.exponentialRampToValueAtTime(gain, t + 0.01) g.gain.exponentialRampToValueAtTime(0.0001, t + dur) g.connect(ctx.destination) for (const f of freqs) { const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = f; o.connect(g); o.start(t); o.stop(t + dur + 0.02) } } const DTMF: Record = { '1': [697, 1209], '2': [697, 1336], '3': [697, 1477], '4': [770, 1209], '5': [770, 1336], '6': [770, 1477], '7': [852, 1209], '8': [852, 1336], '9': [852, 1477], '*': [941, 1209], '0': [941, 1336], '#': [941, 1477], } const sfx = { key(k: string) { const d = DTMF[k]; if (d) tone(d, 0.12, 0, 0.06) }, ring() { tone([440, 480], 0.9, 0, 0.04) }, unobtainable() { tone([950], 0.28, 0, 0.06); tone([1400], 0.28, 0.33, 0.06); tone([1800], 0.28, 0.66, 0.06) }, // SIT-ish voicemail() { tone([1000], 0.5, 0, 0.05) }, } // ---- scene -------------------------------------------------------------------- type Built = { group: THREE.Group; hinge: THREE.Group; keys: THREE.Mesh[] } function buildPhone(): Built { const group = new THREE.Group() const keys: THREE.Mesh[] = [] const bodyMat = new THREE.MeshStandardMaterial({ color: 0x181a1c, roughness: 0.85, metalness: 0.05, flatShading: true }) const trimMat = new THREE.MeshStandardMaterial({ color: 0x101214, roughness: 0.9, flatShading: true }) const glassMat = new THREE.MeshStandardMaterial({ color: 0x0a1206, emissive: 0x101d09, emissiveIntensity: 0.6, roughness: 0.4, flatShading: true }) const keyMat = new THREE.MeshStandardMaterial({ color: 0x9fb23a, emissive: 0x8fa522, emissiveIntensity: 0.55, roughness: 0.55, flatShading: true }) const keypad = new THREE.Mesh(new THREE.BoxGeometry(0.66, 0.92, 0.16), bodyMat) keypad.position.set(0, -0.47, 0) keypad.castShadow = true keypad.receiveShadow = true group.add(keypad) // Hinge at the top-FRONT edge (ahead of the ~0.145 button tops); the lid child // cancels the hinge z so OPEN (rotation 0) is coplanar and SCREEN_RECT holds. const hinge = new THREE.Group() hinge.position.set(0, 0, 0.10) group.add(hinge) const lid = new THREE.Mesh(new THREE.BoxGeometry(0.66, 0.92, 0.10), bodyMat) lid.position.set(0, 0.47, -0.10) lid.castShadow = true lid.receiveShadow = true hinge.add(lid) const bezel = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.66, 0.02), trimMat) bezel.position.set(0, 0.05, 0.052) lid.add(bezel) const glass = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.02), glassMat) glass.position.set(0, 0.05, 0.062) lid.add(glass) // Chunky protruding keys (LOCAL to the keypad, whose body spans local y [-0.46,0.46]). const key = (w: number, h: number, x: number, y: number, id: string) => { const k = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.09), keyMat) k.position.set(x, y, 0.10) k.userData = { key: id, baseZ: 0.10 } k.castShadow = true keypad.add(k) keys.push(k) } key(0.16, 0.10, -0.20, 0.40, 'call') // left soft key key(0.16, 0.10, 0.20, 0.40, 'end') // right soft key key(0.20, 0.16, 0, 0.22, 'nav') // nav pad const rows = [0.04, -0.10, -0.24, -0.38] const cols = [-0.20, 0, 0.20] const digits = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['*', '0', '#']] rows.forEach((y, r) => cols.forEach((x, c) => key(0.15, 0.10, x, y, digits[r][c]))) // Hinge barrel: parented to the hinge group at its local origin, so it sits // EXACTLY on the rotation axis (the pivot passes through the cylinder centre). // A cylinder spinning about its own axis is invisible, so it stays put as the // lid swings. Pins stick out the sides to read as the pivot. const hingeMat = new THREE.MeshStandardMaterial({ color: 0x26292c, roughness: 0.5, metalness: 0.35, flatShading: true }) const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.055, 0.74, 12), hingeMat) barrel.rotation.z = Math.PI / 2 // lay the cylinder along X (the hinge axis) barrel.castShadow = true hinge.add(barrel) // Chubby stub antenna on the top-right of the body, with a rounded cap. const antMat = new THREE.MeshStandardMaterial({ color: 0x2a2d30, roughness: 0.6, metalness: 0.2, flatShading: true }) const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.042, 0.052, 0.22, 8), antMat) antenna.position.set(0.25, 0.10, -0.01) antenna.castShadow = true group.add(antenna) const tip = new THREE.Mesh(new THREE.SphereGeometry(0.055, 10, 8), antMat) tip.position.set(0.25, 0.24, -0.01) tip.castShadow = true group.add(tip) return { group, hinge, keys } } function PhoneDevice({ open, onKey }: { open: boolean; onKey: (k: string) => void }) { const stageRef = useRef(null) const targetRef = useRef(open ? 1 : 0) const onKeyRef = useRef(onKey) onKeyRef.current = onKey // Open at once; on close, hold a beat so the screen fades out before the lid swings. useEffect(() => { if (open) { targetRef.current = 1; return } const t = setTimeout(() => { targetRef.current = 0 }, 170) return () => clearTimeout(t) }, [open]) useEffect(() => { const stage = stageRef.current if (!stage) return const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) renderer.shadowMap.enabled = true renderer.shadowMap.type = THREE.PCFSoftShadowMap 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.OrthographicCamera(-0.5, 0.5, 1, -1, 0.1, 100) camera.position.set(0, 0, 6) camera.lookAt(0, 0, 0) scene.add(new THREE.AmbientLight(0x3a4030, 0.75)) const keyLight = new THREE.DirectionalLight(0xfff4e0, 1.05) keyLight.position.set(1.4, 2.2, 2.4) keyLight.castShadow = true keyLight.shadow.mapSize.set(1024, 1024) const sc = keyLight.shadow.camera as THREE.OrthographicCamera sc.left = -1; sc.right = 1; sc.top = 1.3; sc.bottom = -1.3; sc.near = 0.1; sc.far = 12 scene.add(keyLight) // Cool fill lifts the shadow side; warm rim from behind-top catches the top edges // so the closed clamshell reads as a 3D object rather than a flat slab. const fill = new THREE.DirectionalLight(0x8fa6c0, 0.4) fill.position.set(-1.8, 0.5, 2.0) scene.add(fill) const rim = new THREE.DirectionalLight(0xffd7a0, 0.55) rim.position.set(-0.3, 1.6, -2.6) scene.add(rim) const glow = new THREE.PointLight(0x9fd020, 0.5, 4) glow.position.set(0, -0.5, 0.9) scene.add(glow) const { group, hinge, keys } = buildPhone() scene.add(group) // Pressable keys via raycasting on the canvas. const ray = new THREE.Raycaster() const ndc = new THREE.Vector2() const pressedAt = new Map() const onPointer = (e: PointerEvent) => { if (targetRef.current < 0.5) return // only when open const r = renderer.domElement.getBoundingClientRect() ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1) ray.setFromCamera(ndc, camera) const hit = ray.intersectObjects(keys, false)[0] if (hit) { const m = hit.object as THREE.Mesh; pressedAt.set(m, performance.now()); onKeyRef.current(m.userData.key) } } renderer.domElement.addEventListener('pointerdown', onPointer) const resize = () => { const w = stage.clientWidth, h = stage.clientHeight; if (w && h) renderer.setSize(w, h, false) } resize() const ro = new ResizeObserver(resize) ro.observe(stage) const R = 6 let raf = 0 let progress = 0 const tick = () => { progress += (targetRef.current - progress) * 0.08 hinge.rotation.x = CLOSED_ANGLE + (OPEN_ANGLE - CLOSED_ANGLE) * progress // Intro orbit: start angled (blocky form + seam visible) and rotate to exactly // head-on. Finishes by 80% open, so the DOM screen only ever appears aligned. const cam = Math.max(0, 1 - progress / 0.8) const az = 0.95 * cam, el = 0.28 * cam camera.position.set(Math.sin(az) * Math.cos(el) * R, Math.sin(el) * R, Math.cos(az) * Math.cos(el) * R) camera.lookAt(0, 0, 0) const now = performance.now() for (const k of keys) { const t0 = pressedAt.get(k) const base = k.userData.baseZ as number k.position.z = t0 ? base - 0.04 * Math.max(0, Math.sin(Math.min(1, (now - t0) / 130) * Math.PI)) : base } renderer.render(scene, camera) raf = requestAnimationFrame(tick) } tick() return () => { cancelAnimationFrame(raf) ro.disconnect() renderer.domElement.removeEventListener('pointerdown', onPointer) renderer.dispose() renderer.domElement.remove() scene.traverse(obj => { if (obj instanceof THREE.Mesh) { obj.geometry.dispose(); (obj.material as THREE.Material).dispose() } }) } }, []) return
} // ---- directory ---------------------------------------------------------------- // The number->node directory stays authored config for now; `requires` are the // target node's flag requirements, checked live against the player's ACHIEVEMENTS // (the real playthrough case-state). Wiring numbers to actual dialogue nodes and // moving the directory server-side is the next slice. type Contact = { name: string; requires: string[] } const DIRECTORY: Record = { '55501': { name: 'Elias Board', requires: ['elias_number_callable'] }, // dev-grant unlocks -> connects '55502': { name: 'Voss Antiquities', requires: ['voss_number_known'] }, // not earned -> voicemail } type Mode = 'home' | 'dial' | 'calling' | 'unknown' | 'voicemail' | 'connected' // ---- screen ------------------------------------------------------------------- function PhoneScreen({ visible, mode, dialed, callee }: { visible: boolean; mode: Mode; dialed: string; callee: string }) { const hhmm = new Date().toTimeString().slice(0, 5) return
▮▮▮GU-NET▚▚
{mode === 'home' && <>
{hhmm}
17 AUG
55501 · 55502 · 55503
MenuNames
} {mode === 'dial' && <>
{dialed || '_'}
▸ CallClr ◂
} {mode === 'calling' && <>
CALLING
{dialed}
} {mode === 'unknown' && <>
NUMBER NOT
IN SERVICE
End ◂
} {mode === 'voicemail' && <>
VOICEMAIL
{callee}
leave a message…
End ◂
} {mode === 'connected' && <>
CONNECTED
{callee}
[dialogue plays here]
End ◂
}
} export function PhonePreview() { const [open, setOpen] = useState(false) const [screenOn, setScreenOn] = useState(false) const [mode, setMode] = useState('home') const [dialed, setDialed] = useState('') const [callee, setCallee] = useState('') const [playthroughId, setPlaythroughId] = useState(null) const [achieved, setAchieved] = useState>(new Set()) const [called, setCalled] = useState>(new Set()) const callTimer = useRef(undefined) useEffect(() => { if (open) { const t = setTimeout(() => setScreenOn(true), 560); return () => clearTimeout(t) } setScreenOn(false); setMode('home'); setDialed('') }, [open]) const refreshAchievements = async (id: string) => { const res = await fetch(`/api/playthroughs/${id}/achievements`) if (res.ok) setAchieved(new Set(await res.json() as string[])) } // Attach to the player's live playthrough (or create one) and load its case-state. useEffect(() => { let cancelled = false ;(async () => { let id: string | null = null const cur = await fetch('/api/playthroughs/current') if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null if (!id) { const made = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) }) if (made.ok) id = (await made.json())?.playthrough?.id ?? null } if (cancelled || !id) return setPlaythroughId(id) await refreshAchievements(id) })() return () => { cancelled = true } }, []) const connectable = (num: string) => { const c = DIRECTORY[num]; return c ? c.requires.every(f => achieved.has(f)) : false } // Glow the handset when an enabled, not-yet-called number is waiting. const glow = Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num)) const grantElias = async () => { if (!playthroughId) return await fetch(`/api/playthroughs/${playthroughId}/achievements`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ flagKey: 'elias_number_callable' }) }) await refreshAchievements(playthroughId) } const resolveCall = (num: string) => { sfx.ring() setMode('calling') const contact = DIRECTORY[num] window.clearTimeout(callTimer.current) callTimer.current = window.setTimeout(() => { if (!contact) { sfx.unobtainable(); setMode('unknown'); return } setCallee(contact.name) setCalled(prev => new Set(prev).add(num)) if (connectable(num)) setMode('connected') else { sfx.voicemail(); setMode('voicemail') } }, 950) } const press = (k: string) => { // In a result screen, any key hangs up back to the dialer. if (mode === 'calling' || mode === 'unknown' || mode === 'voicemail' || mode === 'connected') { window.clearTimeout(callTimer.current); setMode(dialed ? 'dial' : 'home'); if (k === 'end') setDialed(''); return } if (k in DTMF) { sfx.key(k); setDialed(d => (d + k).slice(0, 14)); setMode('dial'); return } if (k === 'call' && dialed) { resolveCall(dialed); return } if (k === 'end') { setDialed(d => d.slice(0, -1)); if (dialed.length <= 1) setMode('home') } } // Hardware keyboard convenience: digits, Enter = call, Backspace = delete. useEffect(() => { if (!open) return const onKey = (e: KeyboardEvent) => { if (e.key >= '0' && e.key <= '9') press(e.key) else if (e.key === '*' || e.key === '#') press(e.key) else if (e.key === 'Enter') press('call') else if (e.key === 'Backspace') { e.preventDefault(); press('end') } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }) // re-bind each render so `press` closes over fresh state return

dial 55501 Elias (voicemail → connect once granted) · 55502 Voss (voicemail) · else unobtainable

}