Add 3D clamshell phone dialer spike + persistent-board design
A diegetic 90s handset rendered as a small three.js scene in a fixed 1:2 portrait stage (visual spike at /?phone=1, three.js lazy-loaded so the board bundle is unchanged). Blocky flat-shaded clamshell with glowing keys, a flip-open animation with an intro camera orbit that settles head-on before the DOM screen appears, a hinge barrel on the pivot axis, and a chubby antenna. Pressable 3D keypad (raycast + keyboard) with DTMF tones drives a dialer: connect / voicemail / SIT "unobtainable", against a stub number->node directory with flag-gated node enablement. The screen UI is real DOM positioned in percent of the stage, kept pixel-exact by the head-on ortho camera. Also documents the persistent-board flow model (docs/persistent-boards.md): board_key reuse across level nodes, present-but-hidden flag-gated exhibits with live arrival reveals, reset/new-game semantics, and A/B/M citation codes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+325
@@ -0,0 +1,325 @@
|
||||
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<string, [number, number]> = {
|
||||
'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<HTMLDivElement>(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<THREE.Mesh, number>()
|
||||
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 <div ref={stageRef} className="phone-canvas" />
|
||||
}
|
||||
|
||||
// ---- directory (spike stub) ---------------------------------------------------
|
||||
// Real version: numbers are the phone node's terminals -> dialogue nodes; `requires`
|
||||
// are the target node's flag requirements, checked against the playthrough flags.
|
||||
type Contact = { name: string; requires: string[] }
|
||||
const DIRECTORY: Record<string, Contact> = {
|
||||
'55501': { name: 'Elias Board', requires: [] }, // always enabled -> connects
|
||||
'55502': { name: 'Voss Antiquities', requires: ['found_voss_number'] }, // enabled below -> connects
|
||||
'55503': { name: 'Preservation Soc.', requires: ['society_clearance'] }, // not enabled -> voicemail
|
||||
}
|
||||
const FLAGS = new Set<string>(['found_voss_number']) // toggle to demo connect vs. 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 <div className={`phone-screen${visible ? ' on' : ''}`}
|
||||
style={{ top: `${SCREEN_RECT.top}%`, left: `${SCREEN_RECT.left}%`, width: `${SCREEN_RECT.width}%`, height: `${SCREEN_RECT.height}%` }}>
|
||||
<div className="pscr-status"><span>▮▮▮</span><span>GU-NET</span><span>▚▚</span></div>
|
||||
{mode === 'home' && <>
|
||||
<div className="pscr-clock">{hhmm}</div>
|
||||
<div className="pscr-date">17 AUG</div>
|
||||
<div className="pscr-dir">55501 · 55502 · 55503</div>
|
||||
<div className="pscr-soft"><span>Menu</span><span>Names</span></div>
|
||||
</>}
|
||||
{mode === 'dial' && <>
|
||||
<div className="pscr-num">{dialed || '_'}</div>
|
||||
<div className="pscr-soft"><span>▸ Call</span><span>Clr ◂</span></div>
|
||||
</>}
|
||||
{mode === 'calling' && <><div className="pscr-big">CALLING<span className="pscr-dots" /></div><div className="pscr-num sm">{dialed}</div></>}
|
||||
{mode === 'unknown' && <><div className="pscr-big warn">NUMBER NOT</div><div className="pscr-big warn">IN SERVICE</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||
{mode === 'voicemail' && <><div className="pscr-big">VOICEMAIL</div><div className="pscr-callee">{callee}</div><div className="pscr-line">leave a message…</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||
{mode === 'connected' && <><div className="pscr-big ok">CONNECTED</div><div className="pscr-callee">{callee}</div><div className="pscr-line">[dialogue plays here]</div><div className="pscr-soft"><span /><span>End ◂</span></div></>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export function PhonePreview() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [screenOn, setScreenOn] = useState(false)
|
||||
const [mode, setMode] = useState<Mode>('home')
|
||||
const [dialed, setDialed] = useState('')
|
||||
const [callee, setCallee] = useState('')
|
||||
const callTimer = useRef<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { const t = setTimeout(() => setScreenOn(true), 560); return () => clearTimeout(t) }
|
||||
setScreenOn(false); setMode('home'); setDialed('')
|
||||
}, [open])
|
||||
|
||||
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)
|
||||
const enabled = contact.requires.every(f => FLAGS.has(f))
|
||||
if (enabled) 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 <div className="phone-backdrop">
|
||||
<div className="phone-stage">
|
||||
<PhoneDevice open={open} onKey={press} />
|
||||
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
|
||||
</div>
|
||||
<button className="phone-open-btn" onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
||||
<p className="phone-hint">spike — dial <code>55501</code> connects · <code>55503</code> voicemail · anything else unobtainable</p>
|
||||
</div>
|
||||
}
|
||||
Reference in New Issue
Block a user