diff --git a/docs/persistent-boards.md b/docs/persistent-boards.md new file mode 100644 index 0000000..6d9013e --- /dev/null +++ b/docs/persistent-boards.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 5ea047b..2dc746a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "pg": "8.16.3", "react": "19.1.0", "react-dom": "19.1.0", + "three": "^0.185.1", "tsx": "4.20.3" }, "devDependencies": { @@ -32,6 +33,7 @@ "@types/pg": "8.15.4", "@types/react": "19.1.8", "@types/react-dom": "19.1.6", + "@types/three": "^0.185.4", "@vitejs/plugin-react": "4.5.2", "concurrently": "9.1.2", "typescript": "5.8.3", @@ -632,6 +634,13 @@ "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": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1569,6 +1578,13 @@ "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": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1817,6 +1833,35 @@ "@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": { "version": "4.5.2", "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3143,6 +3195,13 @@ "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": { "version": "1.54.0", "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" } }, + "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": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index f122487..927b063 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "pg": "8.16.3", "react": "19.1.0", "react-dom": "19.1.0", + "three": "^0.185.1", "tsx": "4.20.3" }, "devDependencies": { @@ -41,6 +42,7 @@ "@types/pg": "8.15.4", "@types/react": "19.1.8", "@types/react-dom": "19.1.6", + "@types/three": "^0.185.4", "@vitejs/plugin-react": "4.5.2", "concurrently": "9.1.2", "typescript": "5.8.3", diff --git a/src/main.tsx b/src/main.tsx index b8f3cfa..c37ecec 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,13 @@ -import { StrictMode } from 'react' +import { StrictMode, Suspense, lazy } from 'react' import { createRoot } from 'react-dom/client' import { App } from './App' import './styles.css' -createRoot(document.getElementById('root')!).render() +// 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') + ? + : +createRoot(document.getElementById('root')!).render({root}) diff --git a/src/phone.tsx b/src/phone.tsx new file mode 100644 index 0000000..d3f0c3c --- /dev/null +++ b/src/phone.tsx @@ -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 = { + '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 (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 = { + '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(['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
+
▮▮▮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 callTimer = useRef(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
+
+ + +
+ +

spike — dial 55501 connects · 55503 voicemail · anything else unobtainable

+
+} diff --git a/src/styles.css b/src/styles.css index 074948c..5339d66 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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:hover { border-color: #6f8f85; color: #e7b57e; } .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; }