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:
2026-08-22 12:14:11 +02:00
co-authored by Claude Opus 4.8
parent 1b3ff1e78c
commit 1893bf23af
6 changed files with 576 additions and 2 deletions
+139
View File
@@ -0,0 +1,139 @@
# Persistent boards, gated exhibits, and citation codes
Status: **proposed** (design locked, not implemented). Extends the story flow graph
([story-graph.md](story-graph.md)) and the narrative layer
([narrative-todo.md](narrative-todo.md)); interlocks with the Claim/Case Report
work in [TODO.md](TODO.md) Milestone 5. Depends on the **flags / case-state**
primitive (a per-playthrough key→value store) that the gates and dialogue effects
also read and write.
## Why
A mystery is broken into short pieces so we can **meter how much evidence the
player carries at once**. Interruptions — a phone call, a knock, a revealed note —
should not throw the player off the board they are working on. So a single
investigation scene is a **persistent, player-mutated board** that several graph
nodes return to, and new evidence **arrives into that same board** when the story
warrants it rather than being dumped up front or split across duplicated templates.
Two capabilities, independent and both wanted:
1. **A board that survives across nodes**`[board] → [phone call] → [same board]`
returns the player to their exact arrangement and connections.
2. **Flag-gated exhibits inside that board**, revealed with a diegetic "it arrived"
moment (reuses the existing document locator beam).
## Board identity and reuse
An authored level node **cannot** reference a clone id — the clone is created per
playthrough at runtime. Instead:
- A **level node** references a `level_template_version_id` **plus a `board_key`**
(a logical board slot, e.g. `"harbor-desk"`). `board_key` defaults to the
template if the author doesn't care about sharing.
- A **playthrough** holds a map `board_key → level_id` (the mutable clone).
- Entering a level node:
- if the playthrough already has a clone for that `board_key`**reuse it**
(the player's mutated board, arrangement and connections intact);
- otherwise → **clone the template** and record `board_key → new level_id`.
`board_key` is the thing that makes two nodes share a board. Two nodes on the same
template with **different** keys get **separate** boards.
## Gated exhibits
- Every exhibit is **cloned in**, including gated ones; a gated exhibit carries a
**`reveal` condition over flags**. The player's saved board already contains the
note — it is simply inert until the flag flips.
- **Visibility is computed server-side at load.** Given the playthrough's flags,
the play-mode payload includes only exhibits whose `reveal` condition is met;
hidden exhibits are stripped from the response (same discipline that already
hides authoring-only fields like `expectedPartyKind`). Players cannot peek
unrevealed evidence in the API.
- **Gate the edges too.** Any `exhibit_connection` or event link whose endpoint is
a not-yet-revealed exhibit is hidden until that exhibit appears, so there are no
dangling references.
## The arrival reveal
- The board's effective content can change **while the player is standing on it**
the phone is an always-available surface, so a call can set a flag mid-scene. So
reveal is **not load-only**: after any phone/dialogue interaction that sets flags,
re-evaluate board visibility and reveal live (the phone overlay refetches
visibility on hang-up).
- Track a per-playthrough **`revealed-seen` set**. On (re)load or after an
interaction, any exhibit now visible but not yet in the set → play the arrival
animation (a sibling/reuse of the **document locator beam**), then add it to the
set. The flourish fires **once**, not on every reload.
## Reset and New Game
- **Reset = tidy the desk back to the delivered state.** Authored exhibits return
to their arrival positions and folders; the player's rearrangement is undone.
**Flags are untouched**, so anything a flag has already revealed is still
"arrived" and is restored at its authored position.
- **New Game** re-clones the whole tree and is therefore the only thing that
**resets flags** and returns gated exhibits to hidden.
**Open decision — player notes on reset.** Milestone 5's rule is "reset discards
player-created claims." Current lean: reset also removes player-created exhibits
(it restores the *delivered* state), and New Game is the only wipe of flags. The
alternative is a gentler reset that re-files arrived exhibits but leaves the
player's own notes in place. Settle before implementing.
## Exhibit citation codes (A / B / M)
A single running number breaks when players create many note exhibits, so codes
live in fixed namespaces:
- **Authored exhibits: group letter + index** — `A1, A2, …` for the initial
dossier, `B1, B2, …` for a batch that arrives later via a flag reveal, etc.
**The letter is the arrival group**, which ties straight into gated reveal: "new
evidence arrived" *is* the B-series lighting up. Indices are assigned **within
each group at freeze time** and frozen in the template, so authored codes never
shift, no matter what the player does.
- **Player-created exhibits: a running `M` (miscellaneous) series** — `M1, M2,
M3…` in creation order, in their own namespace so player note-making cannot
disturb authored codes. M-numbering is per-level-instance and (given the reset
lean above) restarts only when player notes are cleared.
- The author chooses an exhibit's **group**; the system owns the **numbering**.
These codes are the **citation token everywhere** — board, luggage-tag Claims, the
Case Report, and the LLM gate.
## LLM gate contract
- A gate can be **scripted to require specific authored codes** — e.g. "the report
must cite `A1` and `B2`, connected." That authored requirement list is the
deterministic backbone *under* the LLM, per the section-9 intent of scripting the
cognitive shim rather than trusting it blind.
- If the player types the full report freehand, they must include the Exhibit codes;
the gate checks the required codes are present (and, later, that the reasoning
holds).
## Relationship to fresh templates
This **complements**, not replaces, new templates:
- **Shared `board_key`** — interruptions *within* one investigation scene (calls, a
knock, a revealed note). Cleanly subsumes the earlier "note appears on the next
level" idea: the note is a gated exhibit on the *same* board that reveals after
the call.
- **A fresh template** — the player genuinely moves to a new location/chapter with a
different board.
## Implemented vs deferred
Nothing here is built yet. New plumbing, smallest-first:
1. **Flags** — the per-playthrough key→value store (shared with gates/dialogue).
2. **`board_key` reuse** in level traversal + the playthrough `board_key → level_id`
map (clone-or-reuse).
3. **`reveal` conditions on exhibits** + server-side play-mode visibility filtering
(exhibits and their edges).
4. **`revealed-seen` set** + the arrival animation (locator-beam sibling), live
after flag changes.
5. **A / B / M citation codes** — authored group + frozen index, player M-series;
surfaced on the board and threaded into Claims/report/LLM gate.
Reset (desk-restore, flags-persist) and New Game (full re-clone) semantics as above.
+65
View File
@@ -19,6 +19,7 @@
"pg": "8.16.3", "pg": "8.16.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"three": "^0.185.1",
"tsx": "4.20.3" "tsx": "4.20.3"
}, },
"devDependencies": { "devDependencies": {
@@ -32,6 +33,7 @@
"@types/pg": "8.15.4", "@types/pg": "8.15.4",
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "4.5.2", "@vitejs/plugin-react": "4.5.2",
"concurrently": "9.1.2", "concurrently": "9.1.2",
"typescript": "5.8.3", "typescript": "5.8.3",
@@ -632,6 +634,13 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -1569,6 +1578,13 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1817,6 +1833,35 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.185.4",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz",
"integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.5.2", "version": "4.5.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.2.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.2.tgz",
@@ -2692,6 +2737,13 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -3143,6 +3195,13 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"dev": true,
"license": "MIT"
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.54.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -4077,6 +4136,12 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "url": "https://github.com/chalk/supports-color?sponsor=1"
} }
}, },
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+2
View File
@@ -28,6 +28,7 @@
"pg": "8.16.3", "pg": "8.16.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"three": "^0.185.1",
"tsx": "4.20.3" "tsx": "4.20.3"
}, },
"devDependencies": { "devDependencies": {
@@ -41,6 +42,7 @@
"@types/pg": "8.15.4", "@types/pg": "8.15.4",
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "4.5.2", "@vitejs/plugin-react": "4.5.2",
"concurrently": "9.1.2", "concurrently": "9.1.2",
"typescript": "5.8.3", "typescript": "5.8.3",
+9 -2
View File
@@ -1,6 +1,13 @@
import { StrictMode } from 'react' import { StrictMode, Suspense, lazy } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { App } from './App' import { App } from './App'
import './styles.css' import './styles.css'
createRoot(document.getElementById('root')!).render(<StrictMode><App /></StrictMode>) // three.js is lazy-loaded so the board never pays for it up front.
const PhonePreview = lazy(() => import('./phone').then(m => ({ default: m.PhonePreview })))
// Visual spike: /?phone=1 renders the handset standalone, isolated from the board.
const root = new URLSearchParams(window.location.search).has('phone')
? <Suspense fallback={null}><PhonePreview /></Suspense>
: <App />
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
+325
View File
@@ -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>
}
+36
View File
@@ -592,3 +592,39 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.audio-toggle { position: fixed; top: 14px; right: 14px; z-index: 300; width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #3c5a52; background: #0a211de6; color: #d58a46; font-size: 16px; line-height: 1; cursor: pointer; } .audio-toggle { position: fixed; top: 14px; right: 14px; z-index: 300; width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #3c5a52; background: #0a211de6; color: #d58a46; font-size: 16px; line-height: 1; cursor: pointer; }
.audio-toggle:hover { border-color: #6f8f85; color: #e7b57e; } .audio-toggle:hover { border-color: #6f8f85; color: #e7b57e; }
.audio-toggle.muted { color: #5f7b73; text-decoration: line-through; } .audio-toggle.muted { color: #5f7b73; text-decoration: line-through; }
/* === 90s handset spike (src/phone.tsx) ============================ */
.phone-backdrop { position: fixed; inset: 0; z-index: 500; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
background: radial-gradient(120% 90% at 50% 30%, #10201c 0%, #060b0a 70%, #020403 100%); }
/* Fixed 1:2 portrait stage. The 3D scene and the DOM screen both live here, so
percentages resolve against the same box at every size. */
.phone-stage { position: relative; height: min(88vh, 720px); aspect-ratio: 1 / 2; max-width: 92vw; }
.phone-canvas { position: absolute; inset: 0; }
/* The interactive screen, positioned in % of the stage over the 3D glass. */
.phone-screen { position: absolute; box-sizing: border-box; padding: 4% 6%; overflow: hidden;
background: linear-gradient(180deg, #12200b, #0a1607); color: #cdea6a; font-family: "DejaVu Sans Mono", ui-monospace, monospace;
text-shadow: 0 0 6px #7fa02988; opacity: 0; transition: opacity .1s ease; pointer-events: none;
box-shadow: inset 0 0 18px #0006, inset 0 0 0 1px #2c3d16; }
.phone-screen.on { opacity: 1; pointer-events: auto; transition: opacity .28s ease .1s; }
.phone-screen::after { content: ""; position: absolute; inset: 0; pointer-events: none;
background: repeating-linear-gradient(180deg, #0000 0 2px, #00000022 2px 3px); mix-blend-mode: multiply; }
.pscr-status { display: flex; justify-content: space-between; font-size: 9px; letter-spacing: .5px; opacity: .85; }
.pscr-clock { text-align: center; font-size: 34px; font-weight: 700; margin-top: 14%; letter-spacing: 2px; }
.pscr-date { text-align: center; font-size: 11px; opacity: .8; letter-spacing: 3px; }
.pscr-soft { position: absolute; left: 6%; right: 6%; bottom: 4%; display: flex; justify-content: space-between; font-size: 10px; opacity: .9; }
.pscr-dir { text-align: center; font-size: 9px; opacity: .55; margin-top: 8%; letter-spacing: 1px; }
.pscr-num { text-align: center; font-size: 22px; letter-spacing: 3px; margin-top: 16%; word-break: break-all; }
.pscr-num.sm { font-size: 13px; margin-top: 4%; opacity: .8; }
.pscr-big { text-align: center; font-size: 17px; letter-spacing: 1px; margin-top: 12%; font-weight: 700; }
.pscr-big.warn { color: #e88a4a; text-shadow: 0 0 6px #e88a4a66; }
.pscr-big.ok { color: #8fe86a; }
.pscr-big:nth-of-type(3) { margin-top: 2%; }
.pscr-callee { text-align: center; font-size: 12px; margin-top: 6%; opacity: .95; }
.pscr-line { text-align: center; font-size: 10px; opacity: .7; margin-top: 3%; }
.pscr-dots::after { content: "…"; animation: pscr-blink 1s steps(1) infinite; }
@keyframes pscr-blink { 50% { opacity: .2; } }
.phone-open-btn { border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace;
letter-spacing: 2px; padding: 8px 22px; cursor: pointer; }
.phone-open-btn:hover { border-color: #cdea6a; }
.phone-hint { color: #5f7b73; font-family: ui-monospace, monospace; font-size: 11px; margin: 0; }
.phone-hint code { color: #8fae4a; }