326 lines
15 KiB
TypeScript
326 lines
15 KiB
TypeScript
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>
|
||
|
|
}
|