From 266d254e8e6e3a13309c24a86f5177483b6055ee Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Thu, 17 Sep 2026 11:16:32 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat(3d):=20revamp=20the=203D=20terminal=20?= =?UTF-8?q?=E2=80=94=20room,=20scene=20command,=20CRT=20screen,=20Web=20Au?= =?UTF-8?q?dio=20clicks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scene - Retro computer now sits on a desk in a dark room: desk + legs, back wall, grid floor, desk lamp with a warm shadow-casting spotlight, mug, floppy stack and a sticky note that says "type help ↵" (all procedural, no new assets) - Camera: 42° FOV so the screen is readable, five named presets (default/close/side/top/wide) with smooth fly-to, double-click reset, optional auto-spin, no panning under the desk - CRT screen texture rewritten: JetBrains Mono at 26px, char-accurate word wrap, phosphor glow, baked scanlines and vignette, block cursor, dead-glass look when powered off; `color set` now recolours the tube live (MutationObserver on --terminal instead of a one-shot read) - Post-processing tuned: calmer bloom, film grain, vignette, radial chromatic aberration; the green screen-glow light no longer blasts the bezel - Boot sequence prints a POST screen line by line and plays a power-on hum - Loader tracks real asset progress (drei useProgress) instead of a 1.5 s timer - WebGL context loss is prevented from tearing the page down Terminal integration - New `scene` command: `scene cam `, `scene lamp|power|fx|sound|spin|props on|off`, `scene reset`, `scene` for status (with fps in 3D mode); listed under System in help - Scene state lives in a small external store (sceneStore) shared by the command, the header buttons and the R3F scene; prefs persist and the old performance-mode flag is migrated - Header gains Sound / Lamp / Power / Effects toggles; status bar gets view chips + fps - Fix: command echo in the 3D buffer lost its spaces ("❯colorset #ff8800") Sound - useKeyClick rebuilt on Web Audio: sample decoded once on mount (first key no longer swallowed), overlapping clicks allowed (no more AbortError), natural pitch with slight per-key variation, silent for modifier keys, mute toggle persisted Tests: sceneStore, SceneCommand, screenTexture, audio, useKeyClick, plus a TerminalHandler regression for the 3D prompt spacing (317 passing). Co-Authored-By: Claude Opus 5 (1M context) --- src/components/TerminalHandler.test.tsx | 6 + src/components/TerminalHandler.tsx | 10 +- src/components/commands/CommandMap.tsx | 2 + src/components/commands/HelpCommand.tsx | 2 +- src/components/commands/SceneCommand.test.tsx | 114 ++++++ src/components/commands/SceneCommand.tsx | 141 ++++++++ src/components/terminal/AmbientEffects.tsx | 23 +- src/components/terminal/BootSequence.tsx | 55 +-- src/components/terminal/CRTEffects.tsx | 28 +- src/components/terminal/DeskProps.tsx | 204 +++++++++++ src/components/terminal/TerminalCanvas.tsx | 208 ++++++----- src/components/terminal/TerminalScene.tsx | 333 +++++++++--------- src/components/terminal/sceneStore.test.ts | 88 +++++ src/components/terminal/sceneStore.ts | 143 ++++++++ src/components/terminal/screenTexture.test.ts | 83 +++++ src/components/terminal/screenTexture.ts | 92 +++++ src/hooks/useKeyClick.test.ts | 136 +++---- src/hooks/useKeyClick.ts | 29 +- src/hooks/usePerformanceMode.ts | 45 --- src/types/terminal3d.ts | 38 +- src/utils/audio.test.ts | 210 +++++++++++ src/utils/audio.ts | 129 +++++++ 22 files changed, 1657 insertions(+), 462 deletions(-) create mode 100644 src/components/commands/SceneCommand.test.tsx create mode 100644 src/components/commands/SceneCommand.tsx create mode 100644 src/components/terminal/DeskProps.tsx create mode 100644 src/components/terminal/sceneStore.test.ts create mode 100644 src/components/terminal/sceneStore.ts create mode 100644 src/components/terminal/screenTexture.test.ts create mode 100644 src/components/terminal/screenTexture.ts delete mode 100644 src/hooks/usePerformanceMode.ts create mode 100644 src/utils/audio.test.ts create mode 100644 src/utils/audio.ts diff --git a/src/components/TerminalHandler.test.tsx b/src/components/TerminalHandler.test.tsx index 1650ba5..901f89e 100644 --- a/src/components/TerminalHandler.test.tsx +++ b/src/components/TerminalHandler.test.tsx @@ -108,4 +108,10 @@ describe('TerminalHandler input handling', () => { expect(lastPrompt(buffer)).not.toBe(first) expect(screen.getByText('clear')).toBeInTheDocument() }) + + it('keeps the spaces between prompt, command and arguments in the 3D buffer', async () => { + const { buffer } = renderTerminal() + await runCommand('echo hello world', buffer) + expect(lastBuffer(buffer)).toContain('$ echo hello world') + }) }) diff --git a/src/components/TerminalHandler.tsx b/src/components/TerminalHandler.tsx index 68c4ccf..0da3b89 100644 --- a/src/components/TerminalHandler.tsx +++ b/src/components/TerminalHandler.tsx @@ -259,7 +259,7 @@ const TerminalHandler = ({ onBufferChange, headless = false }: Props) => { pushLine(
- ❯ + ❯{" "} {cmd}
@@ -299,10 +299,10 @@ const TerminalHandler = ({ onBufferChange, headless = false }: Props) => { pushLine(
- ❯ + ❯{" "} {base} {args.length > 0 && ( - {args.join(" ")} + {args.join(" ")} )}
@@ -320,10 +320,10 @@ const TerminalHandler = ({ onBufferChange, headless = false }: Props) => { pushLine(
- ❯ + ❯{" "} {base} {args.length > 0 && ( - {args.join(" ")} + {args.join(" ")} )}
diff --git a/src/components/commands/CommandMap.tsx b/src/components/commands/CommandMap.tsx index fbd4234..d8df2fd 100644 --- a/src/components/commands/CommandMap.tsx +++ b/src/components/commands/CommandMap.tsx @@ -14,6 +14,7 @@ import { SchoolCommand } from "./SchoolCommand"; import { CurlCommand } from "./CurlCommand"; import { WeatherCommand } from "./WeatherCommand"; import { ShortenCommand } from "./ShortenCommand"; +import { SceneCommand } from "./SceneCommand"; // Filesystem commands import { PwdCommand } from "./fs/PwdCommand"; import { CdCommand } from "./fs/CdCommand"; @@ -52,6 +53,7 @@ commandMap.set(CalcCommand.name, CalcCommand); commandMap.set(CurlCommand.name, CurlCommand); commandMap.set(WeatherCommand.name, WeatherCommand); commandMap.set(ShortenCommand.name, ShortenCommand); +commandMap.set(SceneCommand.name, SceneCommand); // Filesystem commands commandMap.set(PwdCommand.name, PwdCommand); commandMap.set(CdCommand.name, CdCommand); diff --git a/src/components/commands/HelpCommand.tsx b/src/components/commands/HelpCommand.tsx index 6c3d336..184abb6 100644 --- a/src/components/commands/HelpCommand.tsx +++ b/src/components/commands/HelpCommand.tsx @@ -5,7 +5,7 @@ import { HelpCircle, Terminal, ChevronRight, AlertCircle } from 'lucide-react' // Group commands by category const commandCategories: Record = { - 'System': ['clear', 'exit', 'echo', 'color', 'alias', 'export', 'unset'], + 'System': ['clear', 'exit', 'echo', 'color', 'scene', 'alias', 'export', 'unset'], 'Info': ['help', 'ip', 'weather'], 'Portfolio': ['projects', 'school', 'cv', 'certs'], 'Tools': ['calc', 'curl', 'shorten'], diff --git a/src/components/commands/SceneCommand.test.tsx b/src/components/commands/SceneCommand.test.tsx new file mode 100644 index 0000000..51d37b4 --- /dev/null +++ b/src/components/commands/SceneCommand.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render } from '@testing-library/react' +import { SceneCommand, parseSwitch } from './SceneCommand' +import { sceneStore } from '../terminal/sceneStore' +import type { KeyPressContextType } from '../../context/KeypressedContext' + +const context = (headless: boolean) => ({ headless }) as unknown as KeyPressContextType + +const textOf = async (args: string[], headless = true) => { + const node = await SceneCommand.run(args, context(headless)) + return render(<>{node}).container.textContent ?? '' +} + +describe('parseSwitch', () => { + it('toggles when no value is given', () => { + expect(parseSwitch(undefined, true)).toBe(false) + expect(parseSwitch('toggle', false)).toBe(true) + }) + + it('accepts on/off spellings', () => { + expect(parseSwitch('on', false)).toBe(true) + expect(parseSwitch('1', false)).toBe(true) + expect(parseSwitch('off', true)).toBe(false) + expect(parseSwitch('false', true)).toBe(false) + }) + + it('rejects anything else', () => { + expect(parseSwitch('maybe', true)).toBeNull() + }) +}) + +describe('SceneCommand', () => { + beforeEach(() => { + localStorage.clear() + sceneStore.resetForTests() + }) + + it('is registered under the name scene', () => { + expect(SceneCommand.name).toBe('scene') + }) + + it('prints the current state with no args', async () => { + const text = await textOf([]) + expect(text).toContain('camera') + expect(text).toContain('default') + expect(text).toContain('lamp') + }) + + it('points 2D users at the 3D page', async () => { + const text = await textOf([], false) + expect(text).toContain('/blob') + }) + + it('flies the camera to a preset', async () => { + const text = await textOf(['cam', 'wide']) + expect(text).toContain('camera → wide') + expect(sceneStore.getState().cameraPreset).toBe('wide') + }) + + it('accepts camera aliases and reset', async () => { + sceneStore.setCamera('top') + await textOf(['view', 'reset']) + expect(sceneStore.getState().cameraPreset).toBe('default') + }) + + it('rejects an unknown view', async () => { + const text = await textOf(['cam', 'moon']) + expect(text).toContain('Unknown view') + expect(sceneStore.getState().cameraPreset).toBe('default') + }) + + it('toggles the lamp', async () => { + await textOf(['lamp']) + expect(sceneStore.getState().lampOn).toBe(false) + await textOf(['lamp', 'on']) + expect(sceneStore.getState().lampOn).toBe(true) + }) + + it('maps fx/effects, sound, spin, props and power', async () => { + await textOf(['fx', 'off']) + await textOf(['sound', 'off']) + await textOf(['spin', 'on']) + await textOf(['props', 'off']) + await textOf(['power', 'off']) + const state = sceneStore.getState() + expect(state.effects).toBe(false) + expect(state.sound).toBe(false) + expect(state.spin).toBe(true) + expect(state.props).toBe(false) + expect(state.power).toBe(false) + await textOf(['effects', 'on']) + expect(sceneStore.getState().effects).toBe(true) + }) + + it('rejects a bad switch value', async () => { + const text = await textOf(['lamp', 'dim']) + expect(text).toContain('Expected on, off or toggle') + expect(sceneStore.getState().lampOn).toBe(true) + }) + + it('resets everything', async () => { + await textOf(['lamp', 'off']) + await textOf(['cam', 'side']) + const text = await textOf(['reset']) + expect(text).toContain('reset') + expect(sceneStore.getState().lampOn).toBe(true) + expect(sceneStore.getState().cameraPreset).toBe('default') + }) + + it('shows usage for help and errors for unknown subcommands', async () => { + expect(await textOf(['help'])).toContain('Usage') + expect(await textOf(['dance'])).toContain('Unknown scene command') + }) +}) diff --git a/src/components/commands/SceneCommand.tsx b/src/components/commands/SceneCommand.tsx new file mode 100644 index 0000000..cd1a792 --- /dev/null +++ b/src/components/commands/SceneCommand.tsx @@ -0,0 +1,141 @@ +import React from 'react' +import Command from './Command' +import { KeyPressContextType } from '../../context/KeypressedContext' +import { Box, AlertCircle, Check } from 'lucide-react' +import { sceneStore, CAMERA_PRESET_NAMES, type CameraPreset, type SceneState } from '../terminal/sceneStore' + +type ToggleKey = 'power' | 'lampOn' | 'effects' | 'sound' | 'spin' | 'props' + +/** Subcommand → store key for the on/off toggles */ +const TOGGLES: Record = { + power: 'power', + lamp: 'lampOn', + fx: 'effects', + effects: 'effects', + sound: 'sound', + spin: 'spin', + props: 'props', +} + +const CAMERA_ALIASES = ['cam', 'camera', 'view'] + +const onOff = (value: boolean) => (value ? 'on' : 'off') + +/** Parses on/off/toggle; returns null for anything else */ +export const parseSwitch = (arg: string | undefined, current: boolean): boolean | null => { + if (arg === undefined || arg === 'toggle') return !current + if (arg === 'on' || arg === '1' || arg === 'true') return true + if (arg === 'off' || arg === '0' || arg === 'false') return false + return null +} + +const Ok = ({ children }: { children: React.ReactNode }) => ( +
+
+ + {children} +
+
+) + +const Fail = ({ children }: { children: React.ReactNode }) => ( +
+
+ + {children} +
+
+) + +const Status = ({ state, headless }: { state: SceneState; headless: boolean }) => { + const rows: [string, string][] = [ + ['camera', state.cameraPreset], + ['power', onOff(state.power)], + ['lamp', onOff(state.lampOn)], + ['fx', onOff(state.effects)], + ['sound', onOff(state.sound)], + ['spin', onOff(state.spin)], + ['props', onOff(state.props)], + ] + if (headless && state.fps > 0) rows.push(['fps', String(state.fps)]) + return ( +
+
+
+ + 3d scene +
+
+ {rows.map(([key, value]) => ( +

+ {key.padEnd(8)} + {value} +

+ ))} +
+ {!headless && ( +

+ Settings apply to the 3D terminal — open /blob to see them. +

+ )} +
+
+ ) +} + +export const SceneCommand: Command = { + name: 'scene', + description: 'Control the 3D terminal scene (camera, lamp, effects, sound…)', + usage: ( +
+

Usage:

+

scene [command] [value]

+

Commands:

+
+

cam {CAMERA_PRESET_NAMES.join('|')} — Fly the camera to a view

+

power on|off — Switch the monitor on or off

+

lamp on|off — Desk lamp

+

fx on|off — CRT post-processing

+

sound on|off — Key clicks

+

spin on|off — Slowly orbit the desk

+

props on|off — Desk clutter

+

reset — Back to defaults

+
+

Omit the value to toggle. Run scene alone for the current state.

+
+ ), + args: [], + run: async (args: string[], context: KeyPressContextType) => { + const headless = !!context.headless + const [sub, value] = args + const state = sceneStore.getState() + + if (!sub || sub === 'status') return + + if (sub === 'help') return SceneCommand.usage ?? null + + if (sub === 'reset') { + sceneStore.reset() + return scene reset to defaults + } + + if (CAMERA_ALIASES.includes(sub)) { + const preset = (value === 'reset' || value === undefined ? 'default' : value) as CameraPreset + if (!CAMERA_PRESET_NAMES.includes(preset)) { + return Unknown view "{value}" — try {CAMERA_PRESET_NAMES.join(', ')} + } + sceneStore.setCamera(preset) + return camera → {preset} + } + + const key = TOGGLES[sub] + if (key) { + const next = parseSwitch(value, state[key]) + if (next === null) return Expected on, off or toggle — got "{value}" + sceneStore.setState({ [key]: next }) + return {sub} {onOff(next)} + } + + return Unknown scene command "{sub}" — try scene help + }, +} diff --git a/src/components/terminal/AmbientEffects.tsx b/src/components/terminal/AmbientEffects.tsx index 6b856bc..9169650 100644 --- a/src/components/terminal/AmbientEffects.tsx +++ b/src/components/terminal/AmbientEffects.tsx @@ -26,24 +26,33 @@ export default function AmbientEffects({ ) } -/** Screen glow emanating from the monitor */ +/** Screen glow emanating from the monitor, with the faint flicker of a real tube */ function ScreenGlow() { + const mainRef = useRef(null) + + useFrame(({ clock }) => { + if (!mainRef.current) return + const t = clock.getElapsedTime() + mainRef.current.intensity = 0.55 + Math.sin(t * 17) * 0.03 + Math.sin(t * 3.1) * 0.05 + }) + return ( <> {/* Main screen glow */} - {/* Subtle ambient fill */} + {/* Subtle ambient fill spilling onto the keyboard */} diff --git a/src/components/terminal/BootSequence.tsx b/src/components/terminal/BootSequence.tsx index 80bbf7b..973914d 100644 --- a/src/components/terminal/BootSequence.tsx +++ b/src/components/terminal/BootSequence.tsx @@ -1,30 +1,35 @@ 'use client' -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect } from 'react' import type { BootSequenceProps, BootPhase } from '../../types/terminal3d' /** Boot sequence timing configuration */ const BOOT_TIMING = { - warmUp: 800, // CRT warm-up duration - bootText: 1200, // Boot text display duration - total: 2000, // Total boot sequence duration + warmUp: 700, // CRT warm-up before the POST text starts + lineDelay: 320, // Delay between POST lines + hold: 450, // Pause on the final line before the overlay lifts } -/** Boot text messages */ +/** POST lines, printed one after another */ const BOOT_MESSAGES = [ - 'INITIALIZING TERMINAL...', - 'LOADING SYSTEM...', + 'TOMASP RETRO SYSTEMS BIOS v2.6', + 'MEMORY TEST ........ 640K OK', + 'PHOSPHOR WARM-UP .... OK', + 'MOUNTING /home/user . OK', + 'LOADING SHELL ....... OK', 'READY.', ] +const BOOT_TOTAL = BOOT_TIMING.warmUp + BOOT_MESSAGES.length * BOOT_TIMING.lineDelay + BOOT_TIMING.hold + /** * CRT power-on boot sequence animation - * Displays warm-up effect and boot text before terminal appears + * Displays warm-up effect and POST text before the terminal appears */ -export default function BootSequence({ onComplete, skip = false }: BootSequenceProps) { +export default function BootSequence({ onComplete, onStart, skip = false }: BootSequenceProps) { const [phase, setPhase] = useState(skip ? 'ready' : 'off') const [brightness, setBrightness] = useState(skip ? 1 : 0) - const [bootTextIndex, setBootTextIndex] = useState(0) + const [visibleLines, setVisibleLines] = useState(0) // Handle skip mode useEffect(() => { @@ -35,28 +40,26 @@ export default function BootSequence({ onComplete, skip = false }: BootSequenceP useEffect(() => { if (skip) return - // Start warm-up phase const warmUpTimer = setTimeout(() => { setPhase('warming') + onStart?.() }, 100) - // Transition to booting phase const bootingTimer = setTimeout(() => { setPhase('booting') }, BOOT_TIMING.warmUp) - // Complete boot sequence const completeTimer = setTimeout(() => { setPhase('ready') onComplete() - }, BOOT_TIMING.total) + }, BOOT_TOTAL) return () => { clearTimeout(warmUpTimer) clearTimeout(bootingTimer) clearTimeout(completeTimer) } - }, [skip, onComplete]) + }, [skip, onComplete, onStart]) // Brightness animation during warm-up useEffect(() => { @@ -72,16 +75,13 @@ export default function BootSequence({ onComplete, skip = false }: BootSequenceP return () => clearInterval(interval) }, [phase]) - // Boot text progression + // POST lines appear one by one useEffect(() => { if (phase !== 'booting') return const interval = setInterval(() => { - setBootTextIndex((prev) => { - if (prev >= BOOT_MESSAGES.length - 1) return prev - return prev + 1 - }) - }, 400) + setVisibleLines((prev) => Math.min(prev + 1, BOOT_MESSAGES.length)) + }, BOOT_TIMING.lineDelay) return () => clearInterval(interval) }, [phase]) @@ -92,7 +92,7 @@ export default function BootSequence({ onComplete, skip = false }: BootSequenceP return (
{/* CRT warm-up glow */} {(phase === 'warming' || phase === 'booting') && ( @@ -102,10 +102,15 @@ export default function BootSequence({ onComplete, skip = false }: BootSequenceP background: `radial-gradient(ellipse at center, rgba(34, 197, 94, ${brightness * 0.15}) 0%, transparent 70%)`, }} > - {/* Boot text */} + {/* POST text */} {phase === 'booting' && ( -
- {BOOT_MESSAGES[bootTextIndex]} +
+ {BOOT_MESSAGES.slice(0, visibleLines).map((line) => ( +
{line}
+ ))} +
+ _ +
)}
diff --git a/src/components/terminal/CRTEffects.tsx b/src/components/terminal/CRTEffects.tsx index b148e2f..b2bb7ad 100644 --- a/src/components/terminal/CRTEffects.tsx +++ b/src/components/terminal/CRTEffects.tsx @@ -1,14 +1,14 @@ 'use client' import React from 'react' -import { Bloom, ChromaticAberration, Scanline } from '@react-three/postprocessing' +import { Bloom, ChromaticAberration, Noise, Scanline, Vignette } from '@react-three/postprocessing' import { BlendFunction } from 'postprocessing' import type { CRTEffectsProps } from '../../types/terminal3d' import { DEFAULT_CRT_SETTINGS } from '../../types/terminal3d' /** * CRT post-processing effects for authentic retro monitor look - * Includes bloom, scanlines, and chromatic aberration + * Includes bloom, scanlines, film grain, vignette and chromatic aberration */ export default function CRTEffects({ enabled, settings }: CRTEffectsProps) { const effectSettings = { ...DEFAULT_CRT_SETTINGS, ...settings } @@ -20,28 +20,34 @@ export default function CRTEffects({ enabled, settings }: CRTEffectsProps) { {/* Phosphor glow bloom effect */} {effectSettings.bloom && ( )} - {/* Scanline overlay */} + {/* Faint scanline overlay across the whole frame; the screen itself has its own baked in */} {effectSettings.scanlines && ( )} + {/* Film grain keeps the dark room from banding */} + + {/* Subtle color fringing for CRT authenticity */} + + ) } diff --git a/src/components/terminal/DeskProps.tsx b/src/components/terminal/DeskProps.tsx new file mode 100644 index 0000000..a3f1fa1 --- /dev/null +++ b/src/components/terminal/DeskProps.tsx @@ -0,0 +1,204 @@ +'use client' + +import React, { useMemo } from 'react' +import { Grid } from '@react-three/drei' +import * as THREE from 'three' + +/** Top surface of the desk; the computer model's base sits exactly here */ +export const DESK_TOP_Y = -0.062 +const FLOOR_Y = -0.86 + +/** Static room: desk, wall and a retro grid floor. Always visible. */ +export function Room() { + return ( + + {/* Desk slab + legs */} + + + + + {([-1.2, 1.2] as const).map((x) => + ([-0.6, 0.9] as const).map((z) => ( + + + + + )) + )} + + {/* Back wall and floor so the lamp and screen glow have something to land on */} + + + + + + + + + + + ) +} + +/** Desk lamp with a warm spotlight aimed at the computer; the bulb only glows when on */ +export function DeskLamp({ on }: { on: boolean }) { + const target = useMemo(() => { + const t = new THREE.Object3D() + t.position.set(-0.15, DESK_TOP_Y, 0.35) + return t + }, []) + const base: [number, number, number] = [-0.8, DESK_TOP_Y, -0.05] + const head: [number, number, number] = [-0.58, 0.44, 0.08] + + return ( + + + + + + + + + + + {/* Arm from pole top to the shade */} + + + + + + + + + + + + + + {/* Lights the inside of the shade and the pole so the lamp itself reads as lit */} + {on && } + + {on && ( + + )} + + ) +} + +/** Renders a short handwritten label onto a small square canvas */ +function makeNoteTexture(lines: string[]) { + const canvas = document.createElement('canvas') + canvas.width = 256 + canvas.height = 256 + const ctx = canvas.getContext('2d')! + ctx.fillStyle = '#e9d64f' + ctx.fillRect(0, 0, 256, 256) + ctx.fillStyle = '#2a2410' + ctx.font = '600 44px "Winky Sans", "Comic Sans MS", cursive' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + lines.forEach((line, i) => ctx.fillText(line, 128, 108 + i * 52)) + const texture = new THREE.CanvasTexture(canvas) + texture.colorSpace = THREE.SRGBColorSpace + return texture +} + +/** Sticky note with the one hint every visitor needs */ +function StickyNote() { + const texture = useMemo(() => makeNoteTexture(['type', 'help ↵']), []) + return ( + + + + + ) +} + +/** Coffee mug */ +function Mug() { + const x = 0.64 + const z = 0.42 + return ( + + + + + + + + + + + + + + + ) +} + +const FLOPPY_COLORS = ['#15151a', '#2a2a3d', '#3d1f1f'] + +/** Three 3.5" floppies stacked with a bit of slop */ +function Floppies() { + return ( + + {FLOPPY_COLORS.map((color, i) => ( + + + + + + + + + + + + + + + ))} + + ) +} + +/** Desk clutter, toggled with `scene props` */ +export function DeskProps({ lampOn }: { lampOn: boolean }) { + return ( + + + + + + + ) +} diff --git a/src/components/terminal/TerminalCanvas.tsx b/src/components/terminal/TerminalCanvas.tsx index 52e3a37..a647606 100644 --- a/src/components/terminal/TerminalCanvas.tsx +++ b/src/components/terminal/TerminalCanvas.tsx @@ -2,60 +2,88 @@ import React, { useState, useCallback, useEffect } from 'react' import { isMobile } from 'react-device-detect' -import { Monitor, Zap, ZapOff, ExternalLink } from 'lucide-react' +import { useProgress } from '@react-three/drei' +import { Monitor, Zap, ZapOff, Volume2, VolumeX, Lightbulb, LightbulbOff, RotateCcw, ExternalLink, Power } from 'lucide-react' import TerminalHandler from '../TerminalHandler' import { KeyPressProvider } from '../../context/KeypressedContext' import useKeyClick from '../../hooks/useKeyClick' -import { usePerformanceMode } from '../../hooks/usePerformanceMode' +import { playPowerOn } from '../../utils/audio' import TerminalScene from './TerminalScene' import BootSequence from './BootSequence' +import { CAMERA_PRESET_NAMES, sceneStore, useSceneStore } from './sceneStore' + +const toggleButton = (active: boolean) => + `flex items-center gap-1.5 px-2 py-1 font-mono text-[10px] rounded border transition ${ + active + ? 'border-terminal/50 text-terminal bg-terminal/10' + : 'border-neutral-700 text-gray-500 hover:text-terminal hover:border-terminal/50' + }` + +/** Header shared by the desktop scene and the mobile fallback */ +function Header({ children }: { children?: React.ReactNode }) { + return ( +
+
+
+ +
+
+
+ 3d terminal + — + retro +
+

+ type scene help to control the room from the shell +

+
+
+
{children}
+
+ ) +} /** * Main 3D Terminal Canvas component * Orchestrates the retro computing experience with header, status bar, and scene */ export default function TerminalCanvas() { - const playClick = useKeyClick() - const { performanceMode, togglePerformanceMode } = usePerformanceMode() + const effects = useSceneStore((s) => s.effects) + const sound = useSceneStore((s) => s.sound) + const lampOn = useSceneStore((s) => s.lampOn) + const power = useSceneStore((s) => s.power) + const cameraPreset = useSceneStore((s) => s.cameraPreset) + const fps = useSceneStore((s) => s.fps) + const playClick = useKeyClick(sound) + const { progress } = useProgress() const [buffer, setBuffer] = useState([]) const [bootComplete, setBootComplete] = useState(false) - const [isLoading, setIsLoading] = useState(true) - - const handleBootComplete = useCallback(() => { - setBootComplete(true) - // Also dismiss loading when boot completes - setIsLoading(false) - }, []) + const [loaderTimedOut, setLoaderTimedOut] = useState(false) - // Dismiss loading after a short delay (assets typically load quickly) + // The loader tracks real asset progress; the timeout is a safety net if the loading manager never reports useEffect(() => { - const timer = setTimeout(() => { - setIsLoading(false) - }, 1500) + const timer = setTimeout(() => setLoaderTimedOut(true), 8000) return () => clearTimeout(timer) }, []) + const handleBootComplete = useCallback(() => setBootComplete(true), []) + const handleBootStart = useCallback(() => { + if (sceneStore.getState().sound) playPowerOn() + }, []) + + const togglePower = () => { + const next = !sceneStore.getState().power + sceneStore.setState({ power: next }) + if (next && sceneStore.getState().sound) playPowerOn() + } + + const modelLoading = progress < 100 && !loaderTimedOut + // Mobile fallback with link to 2D terminal if (isMobile) { return (
- {/* Header */} -
-
-
- -
-
-
- 3d terminal - — - retro -
-
-
-
- - {/* Mobile message */} +
@@ -79,35 +107,30 @@ export default function TerminalCanvas() { return (
- {/* Header */} -
-
-
- -
-
-
- 3d terminal - — - retro -
-

- Immersive retro computing experience -

-
-
-
- - 2D Mode - - - three.js - -
-
+
+ + + + + + 2D Mode + +
{/* Main 3D Scene */}
@@ -115,28 +138,22 @@ export default function TerminalCanvas() { {/* Boot sequence overlay */} - + - {/* Loading indicator */} - {isLoading && !performanceMode && ( + {/* Loading indicator until the model is in */} + {modelLoading && (
- Loading 3D scene... + Loading 3D scene… {Math.round(progress)}%
)} {/* 3D Scene */} - +
{/* Status Bar */} @@ -144,36 +161,37 @@ export default function TerminalCanvas() {
- - 3D Mode - + 3D Mode
- + drag to orbit • scroll to zoom • double-click to reset
-
+
+ view + {CAMERA_PRESET_NAMES.map((preset) => ( + + ))} + {fps > 0 && ( + {fps} fps + )}
diff --git a/src/components/terminal/TerminalScene.tsx b/src/components/terminal/TerminalScene.tsx index a8589c5..02899eb 100644 --- a/src/components/terminal/TerminalScene.tsx +++ b/src/components/terminal/TerminalScene.tsx @@ -1,199 +1,180 @@ 'use client' import { OrbitControls, useGLTF } from '@react-three/drei' -import { Canvas, useThree } from '@react-three/fiber' -import React, { useMemo, useEffect, useRef, useCallback, useSyncExternalStore } from 'react' +import { Canvas, useFrame, useThree } from '@react-three/fiber' +import React, { useCallback, useMemo, useEffect, useRef, useSyncExternalStore } from 'react' import * as THREE from 'three' import { EffectComposer } from '@react-three/postprocessing' import CRTEffects from './CRTEffects' import AmbientEffects from './AmbientEffects' +import { Room, DeskProps } from './DeskProps' +import { CAMERA_PRESETS, sceneStore, useSceneStore } from './sceneStore' +import { SCREEN, drawScreen, phosphorColor, type DrawScreenOptions } from './screenTexture' import { DEFAULT_CAMERA_CONFIG } from '../../types/terminal3d' import type { TerminalSceneProps, CameraConfig } from '../../types/terminal3d' +const MODEL_URL = '/scene.gltf' +useGLTF.preload(MODEL_URL) + const subscribeNoop = () => () => {} -/** Reads the terminal accent from CSS on the client; the default green maps to pure phosphor green */ -const readTerminalColor = () => { - const c = getComputedStyle(document.documentElement).getPropertyValue('--terminal').trim() || '#0f0' - return c === '#22c55e' ? '#0f0' : c -} +/** Reads the terminal accent from CSS on the client */ +const readTerminalColor = () => + phosphorColor(getComputedStyle(document.documentElement).getPropertyValue('--terminal').trim() || '#22c55e') -const VISIBLE_HEIGHT = 768 -const MARGIN = 20 -const LINE_HEIGHT = 42 - -type DrawTerminalArgs = { - canvas: HTMLCanvasElement - ctx: CanvasRenderingContext2D - texture: THREE.CanvasTexture - lines: string[] - color: string - maxWidth: number - cursorPosRef: React.MutableRefObject<{ x: number; y: number; visible: boolean }> - cursorVisibleRef: React.MutableRefObject +/** The `color` command writes `--terminal` onto ; watch that attribute */ +const subscribeTerminalColor = (onChange: () => void) => { + const observer = new MutationObserver(onChange) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }) + return () => observer.disconnect() } -/** Word-wraps `lines` and invokes `onLine` for each rendered row, returning the final y */ -function layoutLines(ctx: CanvasRenderingContext2D, lines: string[], maxWidth: number, startY: number, onLine?: (text: string, y: number) => void) { - let y = startY - lines.forEach((ln) => { - const words = ln.split(' ') - let line = '' - words.forEach((word, idx) => { - const test = line + (idx ? ' ' : '') + word - if (ctx.measureText(test).width > maxWidth) { - onLine?.(line, y) - y += LINE_HEIGHT - line = word - } else { - line = test - } - }) - onLine?.(line, y) - y += LINE_HEIGHT - }) - return y +/** Loads JetBrains Mono for the canvas and resolves once it is usable (or immediately if unsupported) */ +const whenScreenFontReady = (): Promise => { + if (typeof document === 'undefined' || !('fonts' in document)) return Promise.resolve() + return document.fonts.load(`${SCREEN.fontSize}px "JetBrains Mono"`).catch(() => undefined) } -/** Paints terminal lines (scrolled to the bottom) and the cursor onto the screen canvas */ -function drawTerminal({ canvas, ctx, texture, lines, color, maxWidth, cursorPosRef, cursorVisibleRef }: DrawTerminalArgs) { - ctx.clearRect(0, 0, canvas.width, canvas.height) - ctx.fillStyle = '#000' - ctx.fillRect(0, 0, canvas.width, canvas.height) - ctx.fillStyle = color - ctx.font = '20px monospace' - ctx.textBaseline = 'top' - - const totalHeight = layoutLines(ctx, lines, maxWidth, MARGIN) - const startY = totalHeight > VISIBLE_HEIGHT ? MARGIN - (totalHeight - VISIBLE_HEIGHT) : MARGIN - const y = layoutLines(ctx, lines, maxWidth, startY, (text, rowY) => { - if (rowY >= MARGIN && rowY <= VISIBLE_HEIGHT) ctx.fillText(text, MARGIN, rowY) - }) +/** Owns the offscreen canvas and the three texture drawn from it */ +class ScreenSurface { + readonly texture: THREE.CanvasTexture + private readonly ctx: CanvasRenderingContext2D | null - if (lines.length > 0) { - const lastLine = lines[lines.length - 1] - cursorPosRef.current = { - x: MARGIN + ctx.measureText(lastLine).width, - y: y - LINE_HEIGHT, - visible: cursorVisibleRef.current, - } - if (cursorVisibleRef.current && cursorPosRef.current.y >= MARGIN && cursorPosRef.current.y <= VISIBLE_HEIGHT) { - ctx.fillText('_', cursorPosRef.current.x, cursorPosRef.current.y) + constructor() { + // Guard for SSR - a 1x1 placeholder that is never painted + if (typeof document === 'undefined') { + this.ctx = null + this.texture = new THREE.CanvasTexture(new ImageData(1, 1)) + return } + const canvas = document.createElement('canvas') + canvas.width = SCREEN.width + canvas.height = SCREEN.height + this.ctx = canvas.getContext('2d') + this.texture = new THREE.CanvasTexture(canvas) + this.texture.colorSpace = THREE.SRGBColorSpace + this.texture.anisotropy = 4 } - texture.needsUpdate = true + paint(options: Omit) { + if (!this.ctx) return + drawScreen({ ctx: this.ctx, ...options }) + this.texture.needsUpdate = true + } } /** * Hook that returns a CanvasTexture driven by terminal lines */ -export function useTerminalTexture(lines: string[]) { - const color = useSyncExternalStore(subscribeNoop, readTerminalColor, () => '#0f0') +export function useTerminalTexture(lines: string[], power: boolean) { + const color = useSyncExternalStore(subscribeTerminalColor, readTerminalColor, () => '#39ff6e') + const surface = useMemo(() => new ScreenSurface(), []) - const { canvas, ctx, texture } = useMemo(() => { - // Guard for SSR - return dummy values that will be replaced on client - if (typeof document === 'undefined') { - return { canvas: null as HTMLCanvasElement | null, ctx: null as CanvasRenderingContext2D | null, texture: new THREE.CanvasTexture(new ImageData(1, 1)) } - } - const canvas = document.createElement('canvas') - canvas.width = 1024 - canvas.height = 768 - const ctx = canvas.getContext('2d')! - const texture = new THREE.CanvasTexture(canvas) - texture.flipY = true - texture.wrapS = THREE.RepeatWrapping - texture.center.set(0.5, 0.5) - return { canvas, ctx, texture } - }, []) - - const visibleHeight = VISIBLE_HEIGHT - const margin = MARGIN - const lineHeight = LINE_HEIGHT - const maxWidth = canvas ? canvas.width - margin * 2 : 1024 - margin * 2 - - const cursorPosRef = useRef<{ x: number; y: number; visible: boolean }>({ x: 0, y: 0, visible: true }) const cursorVisibleRef = useRef(true) - const linesRef = useRef([]) + const linesRef = useRef(lines) + const fontReadyRef = useRef(false) + + const redraw = useCallback(() => { + surface.paint({ lines: linesRef.current, color, cursorVisible: cursorVisibleRef.current, power }) + }, [surface, color, power]) useEffect(() => { - if (!canvas || !ctx) return linesRef.current = lines - drawTerminal({ canvas, ctx, texture, lines, color, maxWidth, cursorPosRef, cursorVisibleRef }) - }, [lines, color, canvas, ctx, texture, maxWidth]) + redraw() + }, [lines, redraw]) + // Repaint once the web font arrives so the first frames are not in the fallback face useEffect(() => { - if (!canvas || !ctx) return - - const cursorWidth = 15 - const cursorHeight = lineHeight + if (fontReadyRef.current) return + let cancelled = false + whenScreenFontReady().then(() => { + if (cancelled) return + fontReadyRef.current = true + redraw() + }) + return () => { + cancelled = true + } + }, [redraw]) + useEffect(() => { + if (!power) return const interval = setInterval(() => { cursorVisibleRef.current = !cursorVisibleRef.current - const pos = cursorPosRef.current - - if (linesRef.current.length === 0 || pos.y < margin || pos.y > visibleHeight) return - - ctx.fillStyle = '#000' - ctx.fillRect(pos.x, pos.y, cursorWidth, cursorHeight) - - if (cursorVisibleRef.current) { - ctx.fillStyle = color - ctx.font = '20px monospace' - ctx.textBaseline = 'top' - ctx.fillText('_', pos.x, pos.y) - } - - texture.needsUpdate = true - }, 500) - + redraw() + }, 530) return () => clearInterval(interval) - }, [color, canvas, ctx, texture]) + }, [power, redraw]) - return texture + return surface.texture } /** GLTF wrapper for the retro computer model */ function RetroComputer({ screenTexture }: { screenTexture: THREE.Texture }) { - const { scene } = useGLTF('/scene.gltf') + const { scene } = useGLTF(MODEL_URL) useEffect(() => { - const screen = scene.getObjectByName('PC_M_Screen_0') as THREE.Mesh + scene.traverse((obj) => { + if ((obj as THREE.Mesh).isMesh) { + obj.castShadow = true + obj.receiveShadow = true + } + }) + const screen = scene.getObjectByName('PC_M_Screen_0') as THREE.Mesh | undefined if (screen) { screen.material = new THREE.MeshBasicMaterial({ map: screenTexture, toneMapped: false }) + screen.castShadow = false } }, [scene, screenTexture]) return } -/** Camera controls with configurable boundaries */ -function CameraController({ - config, - onDoubleClick, -}: { - config: CameraConfig - onDoubleClick?: () => void -}) { - const { camera } = useThree() +/** Orbit controls plus smooth fly-to for `scene cam …`, double-click reset and optional auto-spin */ +function CameraRig({ config }: { config: CameraConfig }) { + const { camera, gl } = useThree() const controlsRef = useRef>(null) + const preset = useSceneStore((s) => s.cameraPreset) + const nonce = useSceneStore((s) => s.cameraNonce) + const spin = useSceneStore((s) => s.spin) - const handleDoubleClick = useCallback(() => { - if (controlsRef.current) { - camera.position.set(...config.defaultPosition) - controlsRef.current.target.set(...config.target) - controlsRef.current.update() - } - onDoubleClick?.() - }, [camera, config, onDoubleClick]) + const flight = useRef<{ position: THREE.Vector3; target: THREE.Vector3 } | null>(null) useEffect(() => { - const canvas = document.querySelector('canvas') - if (canvas) { - canvas.addEventListener('dblclick', handleDoubleClick) - return () => canvas.removeEventListener('dblclick', handleDoubleClick) + const { position, target } = CAMERA_PRESETS[preset] + flight.current = { position: new THREE.Vector3(...position), target: new THREE.Vector3(...target) } + }, [preset, nonce]) + + useEffect(() => { + const el = gl.domElement + const reset = () => sceneStore.setCamera('default') + // Dragging takes over from an in-progress flight + const cancel = () => { + flight.current = null + } + el.addEventListener('dblclick', reset) + el.addEventListener('pointerdown', cancel) + el.addEventListener('wheel', cancel, { passive: true }) + return () => { + el.removeEventListener('dblclick', reset) + el.removeEventListener('pointerdown', cancel) + el.removeEventListener('wheel', cancel) } - }, [handleDoubleClick]) + }, [gl]) + + useFrame((_, delta) => { + const controls = controlsRef.current + const goal = flight.current + if (!controls || !goal) return + const k = 1 - Math.exp(-delta * 4.5) + camera.position.lerp(goal.position, k) + controls.target.lerp(goal.target, k) + if (camera.position.distanceTo(goal.position) < 0.002 && controls.target.distanceTo(goal.target) < 0.002) { + camera.position.copy(goal.position) + controls.target.copy(goal.target) + flight.current = null + } + }) return ( ) } +/** Publishes a smoothed frame rate to the scene store once a second */ +function FpsSampler() { + const frames = useRef(0) + const elapsed = useRef(0) + useFrame((_, delta) => { + frames.current += 1 + elapsed.current += delta + if (elapsed.current >= 1) { + sceneStore.setState({ fps: Math.round(frames.current / elapsed.current) }) + frames.current = 0 + elapsed.current = 0 + } + }) + return null +} + /** Scene content rendered inside Canvas */ function SceneContent({ screenTexture, enableEffects, cameraConfig, - onDoubleClick, }: { screenTexture: THREE.Texture enableEffects: boolean cameraConfig: CameraConfig - onDoubleClick?: () => void }) { + const power = useSceneStore((s) => s.power) + const lampOn = useSceneStore((s) => s.lampOn) + const props = useSceneStore((s) => s.props) + return ( <> - - + + + - - + {props && } + + + {enableEffects && ( @@ -239,18 +245,19 @@ function SceneContent({ ) } +/** Keeps a lost WebGL context from tearing the page down; three restores it on `webglcontextrestored` */ +const handleCreated = ({ gl }: { gl: THREE.WebGLRenderer }) => { + gl.domElement.addEventListener('webglcontextlost', (event) => event.preventDefault()) +} + /** * Main 3D scene component with terminal rendering */ -export default function TerminalScene({ - buffer, - enableEffects, - cameraConfig: customConfig, - onDoubleClick, -}: TerminalSceneProps) { +export default function TerminalScene({ buffer, enableEffects, cameraConfig: customConfig }: TerminalSceneProps) { // Wait for client-side mount before rendering Canvas const mounted = useSyncExternalStore(subscribeNoop, () => true, () => false) - const screenTexture = useTerminalTexture(buffer) + const power = useSceneStore((s) => s.power) + const screenTexture = useTerminalTexture(buffer, power) const cameraConfig = { ...DEFAULT_CAMERA_CONFIG, ...customConfig } if (!mounted) { @@ -264,15 +271,15 @@ export default function TerminalScene({ return ( - + + + ) } diff --git a/src/components/terminal/sceneStore.test.ts b/src/components/terminal/sceneStore.test.ts new file mode 100644 index 0000000..7eab205 --- /dev/null +++ b/src/components/terminal/sceneStore.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { sceneStore, useSceneStore, DEFAULT_SCENE_STATE, CAMERA_PRESET_NAMES } from './sceneStore' + +describe('sceneStore', () => { + beforeEach(() => { + localStorage.clear() + sceneStore.resetForTests() + }) + + it('starts from defaults', () => { + expect(sceneStore.getState()).toEqual(DEFAULT_SCENE_STATE) + }) + + it('setState merges and notifies subscribers only on change', () => { + const listener = vi.fn() + sceneStore.subscribe(listener) + sceneStore.setState({ lampOn: false }) + sceneStore.setState({ lampOn: false }) + expect(listener).toHaveBeenCalledTimes(1) + expect(sceneStore.getState().lampOn).toBe(false) + }) + + it('persists only the preference keys', () => { + sceneStore.setState({ lampOn: false, spin: true, fps: 60 }) + expect(JSON.parse(localStorage.getItem('terminal3d-scene')!)).toEqual({ + lampOn: false, + effects: true, + sound: true, + props: true, + }) + }) + + it('hydrates persisted prefs on first read', () => { + localStorage.setItem('terminal3d-scene', JSON.stringify({ sound: false, effects: false, spin: true })) + sceneStore.resetForTests() + const state = sceneStore.getState() + expect(state.sound).toBe(false) + expect(state.effects).toBe(false) + // spin is session-only and never hydrated + expect(state.spin).toBe(false) + }) + + it('migrates the legacy performance-mode flag', () => { + localStorage.setItem('terminal3d-performance-mode', 'true') + sceneStore.resetForTests() + expect(sceneStore.getState().effects).toBe(false) + }) + + it('setCamera bumps the nonce so the same preset re-animates', () => { + sceneStore.setCamera('close') + const first = sceneStore.getState().cameraNonce + sceneStore.setCamera('close') + expect(sceneStore.getState().cameraPreset).toBe('close') + expect(sceneStore.getState().cameraNonce).toBe(first + 1) + }) + + it('toggle flips a boolean', () => { + sceneStore.toggle('sound') + expect(sceneStore.getState().sound).toBe(false) + sceneStore.toggle('sound') + expect(sceneStore.getState().sound).toBe(true) + }) + + it('reset restores defaults but keeps fps and re-flies the camera', () => { + sceneStore.setState({ lampOn: false, spin: true, fps: 42 }) + sceneStore.setCamera('top') + const nonce = sceneStore.getState().cameraNonce + sceneStore.reset() + const state = sceneStore.getState() + expect(state.lampOn).toBe(true) + expect(state.spin).toBe(false) + expect(state.cameraPreset).toBe('default') + expect(state.fps).toBe(42) + expect(state.cameraNonce).toBe(nonce + 1) + }) + + it('exposes every preset name', () => { + expect(CAMERA_PRESET_NAMES).toEqual(['default', 'close', 'side', 'top', 'wide']) + }) + + it('useSceneStore re-renders on the selected slice', () => { + const { result } = renderHook(() => useSceneStore((s) => s.lampOn)) + expect(result.current).toBe(true) + act(() => sceneStore.setState({ lampOn: false })) + expect(result.current).toBe(false) + }) +}) diff --git a/src/components/terminal/sceneStore.ts b/src/components/terminal/sceneStore.ts new file mode 100644 index 0000000..aa7cfbd --- /dev/null +++ b/src/components/terminal/sceneStore.ts @@ -0,0 +1,143 @@ +import { useSyncExternalStore } from 'react' + +/** Named camera views; each is a position + look-at target in world units */ +export type CameraPreset = 'default' | 'close' | 'side' | 'top' | 'wide' + +export const CAMERA_PRESETS: Record = { + default: { position: [0.05, 0.62, 1.25], target: [0, 0.3, 0.2] }, + close: { position: [0, 0.42, 0.66], target: [0, 0.41, 0.21] }, + side: { position: [1.2, 0.5, 1.0], target: [0, 0.27, 0.15] }, + top: { position: [0, 1.9, 0.55], target: [0, 0, 0.2] }, + wide: { position: [0.3, 0.75, 1.9], target: [0, 0.25, 0.1] }, +} + +export const CAMERA_PRESET_NAMES = Object.keys(CAMERA_PRESETS) as CameraPreset[] + +export type SceneState = { + /** Which preset the camera is flying to; `cameraNonce` bumps so re-selecting the same preset re-animates */ + cameraPreset: CameraPreset + cameraNonce: number + /** Monitor power — off blanks the screen and kills its glow */ + power: boolean + lampOn: boolean + /** CRT post-processing (bloom, scanlines…) */ + effects: boolean + sound: boolean + /** Slow orbit around the desk */ + spin: boolean + /** Desk clutter (mug, floppies, lamp, note) */ + props: boolean + /** Frames per second, sampled by the scene */ + fps: number +} + +const STORAGE_KEY = 'terminal3d-scene' +/** Pre-revamp key; `true` meant effects off */ +const LEGACY_PERFORMANCE_KEY = 'terminal3d-performance-mode' + +const PERSISTED_KEYS = ['lampOn', 'effects', 'sound', 'props'] as const + +export const DEFAULT_SCENE_STATE: SceneState = { + cameraPreset: 'default', + cameraNonce: 0, + power: true, + lampOn: true, + effects: true, + sound: true, + spin: false, + props: true, + fps: 0, +} + +type Listener = () => void + +let state: SceneState = DEFAULT_SCENE_STATE +let hydrated = false +const listeners = new Set() + +const readPersisted = (): Partial => { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) as Partial + const out: Partial = {} + PERSISTED_KEYS.forEach((key) => { + if (typeof parsed[key] === 'boolean') out[key] = parsed[key] + }) + return out + } + const legacy = localStorage.getItem(LEGACY_PERFORMANCE_KEY) + if (legacy !== null) return { effects: legacy !== 'true' } + } catch { + // Storage unavailable (private mode, SSR) + } + return {} +} + +const writePersisted = () => { + try { + const out: Partial = {} + PERSISTED_KEYS.forEach((key) => { + out[key] = state[key] + }) + localStorage.setItem(STORAGE_KEY, JSON.stringify(out)) + } catch { + // Ignore storage errors + } +} + +/** Loads persisted prefs once on the client; a no-op on the server */ +const hydrate = () => { + if (hydrated || typeof window === 'undefined') return + hydrated = true + state = { ...state, ...readPersisted() } +} + +export const sceneStore = { + getState(): SceneState { + hydrate() + return state + }, + setState(partial: Partial) { + hydrate() + const next = { ...state, ...partial } + const changed = (Object.keys(partial) as (keyof SceneState)[]).some((key) => next[key] !== state[key]) + if (!changed) return + state = next + if (PERSISTED_KEYS.some((key) => key in partial)) writePersisted() + listeners.forEach((listener) => listener()) + }, + subscribe(listener: Listener) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + /** Flies the camera to a preset, even if it is already selected */ + setCamera(preset: CameraPreset) { + sceneStore.setState({ cameraPreset: preset, cameraNonce: state.cameraNonce + 1 }) + }, + toggle(key: 'power' | 'lampOn' | 'effects' | 'sound' | 'spin' | 'props') { + sceneStore.setState({ [key]: !sceneStore.getState()[key] }) + }, + reset() { + sceneStore.setState({ ...DEFAULT_SCENE_STATE, cameraNonce: state.cameraNonce + 1, fps: state.fps }) + }, + /** Test hook: drops state and persistence back to defaults */ + resetForTests() { + state = DEFAULT_SCENE_STATE + hydrated = false + listeners.clear() + }, +} + +const getServerSnapshot = () => DEFAULT_SCENE_STATE + +/** Reads a slice of the scene store; the selector must return a stable primitive or reference */ +export function useSceneStore(selector: (s: SceneState) => T): T { + return useSyncExternalStore( + sceneStore.subscribe, + () => selector(sceneStore.getState()), + () => selector(getServerSnapshot()) + ) +} diff --git a/src/components/terminal/screenTexture.test.ts b/src/components/terminal/screenTexture.test.ts new file mode 100644 index 0000000..2b8eb69 --- /dev/null +++ b/src/components/terminal/screenTexture.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest' +import { wrapLines, drawScreen, phosphorColor, SCREEN, SCREEN_BG_ON, SCREEN_BG_OFF } from './screenTexture' + +/** Minimal 2D context stub that records what was painted */ +const makeCtx = (charWidth = 10) => { + const gradient = { addColorStop: vi.fn() } + const ctx = { + fillStyle: '' as string | object, + font: '', + textBaseline: '', + shadowColor: '', + shadowBlur: 0, + fillRects: [] as { x: number; y: number; w: number; h: number; style: string | object }[], + texts: [] as { text: string; x: number; y: number }[], + save: vi.fn(), + restore: vi.fn(), + measureText: vi.fn(() => ({ width: charWidth })), + createRadialGradient: vi.fn(() => gradient), + fillRect(x: number, y: number, w: number, h: number) { + this.fillRects.push({ x, y, w, h, style: this.fillStyle }) + }, + fillText(text: string, x: number, y: number) { + this.texts.push({ text, x, y }) + }, + } + return ctx as unknown as CanvasRenderingContext2D & typeof ctx +} + +describe('wrapLines', () => { + it('leaves short lines alone', () => { + expect(wrapLines(['abc', ''], 10)).toEqual(['abc', '']) + }) + + it('breaks at the last space inside the width', () => { + expect(wrapLines(['hello brave new world'], 12)).toEqual(['hello brave', 'new world']) + }) + + it('hard-breaks tokens longer than the width', () => { + expect(wrapLines(['abcdefghij'], 4)).toEqual(['abcd', 'efgh', 'ij']) + }) + + it('never loops on a zero width', () => { + expect(wrapLines(['ab'], 0)).toEqual(['a', 'b']) + }) +}) + +describe('phosphorColor', () => { + it('brightens the default accent and passes custom colors through', () => { + expect(phosphorColor('#22c55e')).toBe('#39ff6e') + expect(phosphorColor('#ff8800')).toBe('#ff8800') + }) +}) + +describe('drawScreen', () => { + it('paints the raster, the last rows and a block cursor when powered', () => { + const ctx = makeCtx(10) + const lines = Array.from({ length: 40 }, (_, i) => `line ${i}`) + drawScreen({ ctx, lines, color: '#39ff6e', cursorVisible: true, power: true }) + + expect(ctx.fillRects[0]).toMatchObject({ x: 0, y: 0, w: SCREEN.width, h: SCREEN.height, style: SCREEN_BG_ON }) + const maxRows = Math.floor((SCREEN.height - SCREEN.margin * 2) / SCREEN.lineHeight) + expect(ctx.texts).toHaveLength(maxRows) + expect(ctx.texts[ctx.texts.length - 1].text).toBe('line 39') + expect(ctx.texts[0].y).toBe(SCREEN.margin) + + const cursor = ctx.fillRects.find((r) => r.style === '#39ff6e') + expect(cursor).toBeDefined() + expect(cursor!.x).toBe(SCREEN.margin + 'line 39'.length * 10 + 2) + }) + + it('draws no cursor when it is blinked off', () => { + const ctx = makeCtx() + drawScreen({ ctx, lines: ['$ '], color: '#39ff6e', cursorVisible: false, power: true }) + expect(ctx.fillRects.some((r) => r.style === '#39ff6e')).toBe(false) + }) + + it('paints only dead glass when powered off', () => { + const ctx = makeCtx() + drawScreen({ ctx, lines: ['$ hello'], color: '#39ff6e', cursorVisible: true, power: false }) + expect(ctx.fillRects[0].style).toBe(SCREEN_BG_OFF) + expect(ctx.texts).toHaveLength(0) + }) +}) diff --git a/src/components/terminal/screenTexture.ts b/src/components/terminal/screenTexture.ts new file mode 100644 index 0000000..31d1adc --- /dev/null +++ b/src/components/terminal/screenTexture.ts @@ -0,0 +1,92 @@ +/** + * Pure 2D-canvas painter for the CRT screen texture. No three.js here so it can be unit-tested. + */ + +export const SCREEN = { + width: 1024, + height: 768, + margin: 40, + fontSize: 26, + lineHeight: 38, + font: '"JetBrains Mono", "Courier New", monospace', +} as const + +/** Phosphor palette: raster background when powered, dead glass when off */ +export const SCREEN_BG_ON = '#04130b' +export const SCREEN_BG_OFF = '#050705' + +/** The site's default accent reads dull on a CRT; nudge it toward phosphor green */ +export const phosphorColor = (cssColor: string) => (cssColor === '#22c55e' ? '#39ff6e' : cssColor) + +/** Hard-wraps each line to `cols` characters, breaking at the last space when there is one */ +export function wrapLines(lines: string[], cols: number): string[] { + const out: string[] = [] + const width = Math.max(1, cols) + lines.forEach((line) => { + let rest = line + while (rest.length > width) { + const cut = rest.lastIndexOf(' ', width) + const at = cut > 0 ? cut : width + out.push(rest.slice(0, at)) + rest = rest.slice(at).replace(/^ /, '') + } + out.push(rest) + }) + return out +} + +export type DrawScreenOptions = { + ctx: CanvasRenderingContext2D + lines: string[] + color: string + cursorVisible: boolean + power: boolean +} + +/** Paints the terminal buffer (scrolled to the bottom), block cursor, scanlines and vignette */ +export function drawScreen({ ctx, lines, color, cursorVisible, power }: DrawScreenOptions) { + const { width, height, margin, fontSize, lineHeight, font } = SCREEN + + ctx.save() + ctx.shadowBlur = 0 + ctx.fillStyle = power ? SCREEN_BG_ON : SCREEN_BG_OFF + ctx.fillRect(0, 0, width, height) + + if (power) { + ctx.font = `${fontSize}px ${font}` + ctx.textBaseline = 'top' + const charWidth = ctx.measureText('M').width || fontSize * 0.6 + const cols = Math.floor((width - margin * 2) / charWidth) + const maxRows = Math.floor((height - margin * 2) / lineHeight) + + const wrapped = wrapLines(lines, cols) + const visible = wrapped.slice(-maxRows) + + ctx.fillStyle = color + ctx.shadowColor = color + ctx.shadowBlur = 12 + visible.forEach((text, row) => { + ctx.fillText(text, margin, margin + row * lineHeight) + }) + + if (cursorVisible && visible.length > 0) { + const last = visible[visible.length - 1] + const x = margin + last.length * charWidth + const y = margin + (visible.length - 1) * lineHeight + ctx.fillRect(x + 2, y + 2, charWidth - 2, lineHeight - 8) + } + ctx.shadowBlur = 0 + + // Scanlines: every third raster row is darkened + ctx.fillStyle = 'rgba(0, 0, 0, 0.28)' + for (let y = 0; y < height; y += 3) ctx.fillRect(0, y, width, 1) + } + + // Vignette so the tube looks curved even though the mesh is flat + const vignette = ctx.createRadialGradient(width / 2, height / 2, height * 0.35, width / 2, height / 2, width * 0.72) + vignette.addColorStop(0, 'rgba(0, 0, 0, 0)') + vignette.addColorStop(1, 'rgba(0, 0, 0, 0.6)') + ctx.fillStyle = vignette + ctx.fillRect(0, 0, width, height) + ctx.restore() +} diff --git a/src/hooks/useKeyClick.test.ts b/src/hooks/useKeyClick.test.ts index 884ff7a..15084fe 100644 --- a/src/hooks/useKeyClick.test.ts +++ b/src/hooks/useKeyClick.test.ts @@ -1,36 +1,61 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import useKeyClick from './useKeyClick' +import { resetAudioForTests } from '../utils/audio' + +class MockAudioContext { + state = 'suspended' + currentTime = 0 + destination = {} + resume = vi.fn().mockResolvedValue(undefined) + decodeAudioData = vi.fn().mockResolvedValue({ duration: 1.5 }) + + createBufferSource = vi.fn(() => ({ + buffer: null, + playbackRate: { value: 1 }, + connect: vi.fn(), + start: vi.fn(), + })) + + createGain = vi.fn(() => ({ + gain: { + value: 1, + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + })) + + createOscillator = vi.fn(() => ({ + type: 'sine', + frequency: { + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + })) +} describe('useKeyClick', () => { - let mockPlay: ReturnType - let originalAudio: typeof Audio + let originalAudioContext: typeof window.AudioContext | undefined beforeEach(() => { - // Save original Audio - originalAudio = global.Audio - - mockPlay = vi.fn().mockResolvedValue(undefined) - - // Create a proper constructor mock using a class - class MockAudio { - src: string - currentTime = 0 - playbackRate = 1 - play = mockPlay - - constructor(src: string) { - this.src = src - } - } - - // @ts-expect-error - Mocking Audio constructor - global.Audio = MockAudio + originalAudioContext = window.AudioContext + // @ts-expect-error - Mocking AudioContext constructor + global.AudioContext = MockAudioContext + global.fetch = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }) as unknown as typeof fetch + resetAudioForTests() }) afterEach(() => { - // Restore original Audio - global.Audio = originalAudio + // @ts-expect-error - Restoring AudioContext constructor + global.AudioContext = originalAudioContext + resetAudioForTests() + vi.restoreAllMocks() }) it('returns a play function', () => { @@ -39,67 +64,44 @@ describe('useKeyClick', () => { expect(typeof result.current).toBe('function') }) - it('creates Audio element on first play', () => { - const audioSpy = vi.spyOn(global, 'Audio') - - const { result } = renderHook(() => useKeyClick()) + it('returns stable function reference across rerenders', () => { + const { result, rerender } = renderHook(() => useKeyClick()) - act(() => { - result.current() - }) + const firstRef = result.current + rerender() + const secondRef = result.current - expect(audioSpy).toHaveBeenCalledWith('/spacebar-click-keyboard-199448.mp3') + expect(firstRef).toBe(secondRef) }) - it('reuses Audio element on subsequent plays', () => { - const audioSpy = vi.spyOn(global, 'Audio') - - const { result } = renderHook(() => useKeyClick()) + it('warms up on mount, fetching the sample before any keypress', async () => { + renderHook(() => useKeyClick()) - act(() => { - result.current() - result.current() - result.current() + await vi.waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1) }) - - // Audio constructor should only be called once - expect(audioSpy).toHaveBeenCalledTimes(1) }) - it('calls play on the audio element', () => { + it('plays a click when called', async () => { const { result } = renderHook(() => useKeyClick()) act(() => { - result.current() + result.current('a') }) - expect(mockPlay).toHaveBeenCalled() - }) - - it('handles play errors gracefully', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const playError = new Error('Play failed') - mockPlay.mockRejectedValue(playError) - - const { result } = renderHook(() => useKeyClick()) - - await act(async () => { - result.current() - // Wait for the promise rejection to be handled - await new Promise(resolve => setTimeout(resolve, 0)) + await vi.waitFor(() => { + expect(global.fetch).toHaveBeenCalled() }) - - expect(consoleSpy).toHaveBeenCalledWith('Error playing sound:', playError) - consoleSpy.mockRestore() }) - it('returns stable function reference', () => { - const { result, rerender } = renderHook(() => useKeyClick()) + it('does not warm up or play when disabled', async () => { + const { result } = renderHook(() => useKeyClick(false)) - const firstRef = result.current - rerender() - const secondRef = result.current + act(() => { + result.current('a') + }) - expect(firstRef).toBe(secondRef) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(global.fetch).not.toHaveBeenCalled() }) }) diff --git a/src/hooks/useKeyClick.ts b/src/hooks/useKeyClick.ts index e1db986..5071def 100644 --- a/src/hooks/useKeyClick.ts +++ b/src/hooks/useKeyClick.ts @@ -1,19 +1,18 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef } from 'react' +import { playClick, warmUp } from '../utils/audio' -export default function useKeyClick() { - const audioRef = useRef(null); +export default function useKeyClick(enabled = true) { + const enabledRef = useRef(enabled) - const play = useCallback(() => { - if (!audioRef.current) { - audioRef.current = new Audio('/spacebar-click-keyboard-199448.mp3'); - audioRef.current.playbackRate = 4.0; - } - - audioRef.current.currentTime = 0; - audioRef.current.play().catch(error => { - console.error('Error playing sound:', error); - }); - }, []); + useEffect(() => { + enabledRef.current = enabled + if (enabled) warmUp() + }, [enabled]) - return play; + const play = useCallback((key?: string) => { + if (!enabledRef.current) return + playClick(key) + }, []) + + return play } diff --git a/src/hooks/usePerformanceMode.ts b/src/hooks/usePerformanceMode.ts deleted file mode 100644 index 6750783..0000000 --- a/src/hooks/usePerformanceMode.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' -import type { UsePerformanceModeReturn } from '../types/terminal3d' - -const STORAGE_KEY = 'terminal3d-performance-mode' - -/** - * Hook for managing performance mode preference with localStorage persistence - * @returns Performance mode state and controls - */ -export function usePerformanceMode(): UsePerformanceModeReturn { - const [performanceMode, setPerformanceModeState] = useState(() => { - if (typeof window === 'undefined') return false - try { - const stored = localStorage.getItem(STORAGE_KEY) - return stored === 'true' - } catch { - return false - } - }) - - // Persist to localStorage when state changes - useEffect(() => { - try { - localStorage.setItem(STORAGE_KEY, String(performanceMode)) - } catch { - // Ignore storage errors - } - }, [performanceMode]) - - const togglePerformanceMode = useCallback(() => { - setPerformanceModeState((prev) => !prev) - }, []) - - const setPerformanceMode = useCallback((enabled: boolean) => { - setPerformanceModeState(enabled) - }, []) - - return { - performanceMode, - togglePerformanceMode, - setPerformanceMode, - } -} - -export default usePerformanceMode diff --git a/src/types/terminal3d.ts b/src/types/terminal3d.ts index f6f5331..8f3fa8a 100644 --- a/src/types/terminal3d.ts +++ b/src/types/terminal3d.ts @@ -3,14 +3,6 @@ * @module terminal3d */ -/** Props for the main TerminalCanvas component */ -export interface TerminalCanvasProps { - /** Enable performance mode (disables effects) */ - performanceMode?: boolean - /** Enable sound effects */ - enableSound?: boolean -} - /** Configuration for CRT visual effects */ export interface CRTEffectSettings { /** Enable scanline overlay */ @@ -50,6 +42,8 @@ export type BootPhase = 'off' | 'warming' | 'booting' | 'ready' export interface BootSequenceProps { /** Callback when boot sequence completes */ onComplete: () => void + /** Callback when the CRT starts warming up (power-on sound hook) */ + onStart?: () => void /** Skip boot sequence (for performance mode) */ skip?: boolean } @@ -80,30 +74,18 @@ export interface TerminalSceneProps { enableEffects: boolean /** Camera configuration */ cameraConfig?: Partial - /** Callback for double-click reset */ - onDoubleClick?: () => void -} - -/** Performance mode hook return type */ -export interface UsePerformanceModeReturn { - /** Current performance mode state */ - performanceMode: boolean - /** Toggle performance mode */ - togglePerformanceMode: () => void - /** Set performance mode explicitly */ - setPerformanceMode: (enabled: boolean) => void } /** Default camera configuration */ export const DEFAULT_CAMERA_CONFIG: CameraConfig = { - defaultPosition: [0, 0.5, 1], - target: [0, 0.35, 0], - minDistance: 0.5, - maxDistance: 2.5, - minPolarAngle: Math.PI / 6, // 30 degrees - prevent going too far under - maxPolarAngle: Math.PI / 2, // 90 degrees - prevent going over - minAzimuthAngle: -Math.PI / 3, // -60 degrees - maxAzimuthAngle: Math.PI / 3, // 60 degrees + defaultPosition: [0.05, 0.62, 1.25], + target: [0, 0.3, 0.2], + minDistance: 0.35, + maxDistance: 3, + minPolarAngle: Math.PI / 12, // 15 degrees - room for the top-down view + maxPolarAngle: Math.PI / 2.05, // just under desk level - never go under the desk + minAzimuthAngle: -Math.PI * 0.42, // ±75 degrees + maxAzimuthAngle: Math.PI * 0.42, } /** Default CRT effect settings */ diff --git a/src/utils/audio.test.ts b/src/utils/audio.test.ts new file mode 100644 index 0000000..d463413 --- /dev/null +++ b/src/utils/audio.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + shouldClick, + clickRateFor, + playClick, + playPowerOn, + isAudioSupported, + resetAudioForTests, +} from './audio' + +type MockBufferSource = { + buffer: unknown + playbackRate: { value: number } + connect: ReturnType + start: ReturnType +} + +class MockAudioContext { + static instances: MockAudioContext[] = [] + + state = 'suspended' + currentTime = 0 + destination = {} + resume = vi.fn().mockResolvedValue(undefined) + decodeAudioData = vi.fn().mockResolvedValue({ duration: 1.5 }) + sources: MockBufferSource[] = [] + + constructor() { + MockAudioContext.instances.push(this) + } + + createBufferSource = vi.fn((): MockBufferSource => { + const source: MockBufferSource = { + buffer: null, + playbackRate: { value: 1 }, + connect: vi.fn(), + start: vi.fn(), + } + this.sources.push(source) + return source + }) + + createGain = vi.fn(() => ({ + gain: { + value: 1, + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + })) + + createOscillator = vi.fn(() => ({ + type: 'sine', + frequency: { + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + })) +} + +describe('audio', () => { + let originalAudioContext: typeof window.AudioContext | undefined + + beforeEach(() => { + originalAudioContext = window.AudioContext + MockAudioContext.instances = [] + // @ts-expect-error - Mocking AudioContext constructor + global.AudioContext = MockAudioContext + global.fetch = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }) as unknown as typeof fetch + resetAudioForTests() + }) + + afterEach(() => { + // @ts-expect-error - Restoring AudioContext constructor + global.AudioContext = originalAudioContext + resetAudioForTests() + vi.restoreAllMocks() + }) + + describe('shouldClick', () => { + it('returns true for undefined', () => { + expect(shouldClick(undefined)).toBe(true) + }) + + it('returns true for single characters', () => { + expect(shouldClick('a')).toBe(true) + expect(shouldClick(' ')).toBe(true) + }) + + it('returns true for sounded special keys', () => { + expect(shouldClick('Enter')).toBe(true) + }) + + it('returns false for modifier keys', () => { + expect(shouldClick('Shift')).toBe(false) + expect(shouldClick('Control')).toBe(false) + expect(shouldClick('ArrowUp')).toBe(false) + }) + }) + + describe('clickRateFor', () => { + it('returns 0.8 for Enter', () => { + expect(clickRateFor('Enter')).toBe(0.8) + }) + + it('returns 1.15 for Backspace', () => { + expect(clickRateFor('Backspace')).toBe(1.15) + }) + + it('returns 0.9 for space', () => { + expect(clickRateFor(' ')).toBe(0.9) + }) + + it('returns a value within [0.95, 1.1] for letters', () => { + const rate = clickRateFor('a') + expect(rate).toBeGreaterThanOrEqual(0.95) + expect(rate).toBeLessThanOrEqual(1.1) + }) + }) + + describe('playClick', () => { + it('fetches the sample once, creates a source per call, and resumes a suspended context', async () => { + playClick('a') + playClick('a') + playClick('a') + + await vi.waitFor(() => { + const ctx = MockAudioContext.instances[0] + expect(ctx.sources).toHaveLength(3) + }) + + const ctx = MockAudioContext.instances[0] + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(ctx.resume).toHaveBeenCalled() + for (const source of ctx.sources) { + expect(source.start).toHaveBeenCalledWith(0, 0, 0.3) + } + }) + + it('creates no source for modifier keys', async () => { + playClick('Shift') + await new Promise(resolve => setTimeout(resolve, 0)) + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('retries fetch after a decode failure', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + class FailingOnceContext extends MockAudioContext { + constructor() { + super() + this.decodeAudioData = vi + .fn() + .mockRejectedValueOnce(new Error('decode failed')) + .mockResolvedValue({ duration: 1.5 }) + } + } + // @ts-expect-error - Mocking AudioContext constructor + global.AudioContext = FailingOnceContext + + playClick('a') + await vi.waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Error playing sound:', expect.any(Error)) + }) + + playClick('a') + await vi.waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(2) + }) + + consoleSpy.mockRestore() + }) + }) + + describe('playPowerOn', () => { + it('creates an oscillator and calls start and stop', () => { + playPowerOn() + + const ctx = MockAudioContext.instances[0] + expect(ctx.createOscillator).toHaveBeenCalled() + const oscillator = ctx.createOscillator.mock.results[0].value + expect(oscillator.start).toHaveBeenCalled() + expect(oscillator.stop).toHaveBeenCalled() + }) + }) + + describe('isAudioSupported', () => { + it('returns true when AudioContext exists', () => { + expect(isAudioSupported()).toBe(true) + }) + + it('returns false and functions no-op when AudioContext is undefined', async () => { + // @ts-expect-error - Removing AudioContext constructor + delete global.AudioContext + resetAudioForTests() + + expect(isAudioSupported()).toBe(false) + + playClick('a') + playPowerOn() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(global.fetch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/utils/audio.ts b/src/utils/audio.ts new file mode 100644 index 0000000..e245b39 --- /dev/null +++ b/src/utils/audio.ts @@ -0,0 +1,129 @@ +export const CLICK_SAMPLE_URL = '/spacebar-click-keyboard-199448.mp3' +export const CLICK_DURATION = 0.3 // seconds of the sample to play + +/** Keys longer than one character that still make a click (modifiers do not) */ +export const SOUNDED_SPECIAL_KEYS: ReadonlySet = new Set([ + 'Enter', + 'Backspace', + 'Tab', + 'Delete', + 'Escape', +]) + +let context: AudioContext | null = null +let clickBuffer: Promise | null = null + +export function isAudioSupported(): boolean { + return typeof window !== 'undefined' && !!(window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext) +} + +export function getAudioContext(): AudioContext | null { + if (!isAudioSupported()) return null + + if (!context) { + const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext + context = new Ctor() + } + + return context +} + +function loadClickBuffer(ctx: AudioContext): Promise { + if (!clickBuffer) { + clickBuffer = fetch(CLICK_SAMPLE_URL) + .then(response => response.arrayBuffer()) + .then(data => ctx.decodeAudioData(data)) + .catch(err => { + clickBuffer = null + throw err + }) + } + + return clickBuffer +} + +export function warmUp(): void { + try { + const ctx = getAudioContext() + if (!ctx) return + loadClickBuffer(ctx).catch(() => {}) + } catch { + // ignore + } +} + +export function shouldClick(key?: string): boolean { + if (key === undefined) return true + if (key.length === 1) return true + return SOUNDED_SPECIAL_KEYS.has(key) +} + +export function clickRateFor(key?: string): number { + if (key === 'Enter') return 0.8 + if (key === 'Backspace') return 1.15 + if (key === ' ') return 0.9 + return 0.95 + Math.random() * 0.15 +} + +export function playClick(key?: string): void { + if (!shouldClick(key)) return + + const ctx = getAudioContext() + if (!ctx) return + + if (ctx.state === 'suspended') { + ctx.resume().catch(() => {}) + } + + loadClickBuffer(ctx) + .then(buffer => { + const source = ctx.createBufferSource() + source.buffer = buffer + source.playbackRate.value = clickRateFor(key) + + const gain = ctx.createGain() + gain.gain.value = 0.6 + + source.connect(gain) + gain.connect(ctx.destination) + source.start(0, 0, CLICK_DURATION) + }) + .catch(err => { + console.error('Error playing sound:', err) + }) +} + +export function playPowerOn(): void { + try { + const ctx = getAudioContext() + if (!ctx) return + + if (ctx.state === 'suspended') { + ctx.resume().catch(() => {}) + } + + const t = ctx.currentTime + const osc = ctx.createOscillator() + osc.type = 'triangle' + osc.frequency.setValueAtTime(70, t) + osc.frequency.exponentialRampToValueAtTime(140, t + 0.9) + + const gain = ctx.createGain() + gain.gain.setValueAtTime(0.0001, t) + gain.gain.exponentialRampToValueAtTime(0.12, t + 0.15) + gain.gain.exponentialRampToValueAtTime(0.0001, t + 1.3) + + osc.connect(gain) + gain.connect(ctx.destination) + + osc.start(t) + osc.stop(t + 1.35) + } catch { + // ignore + } +} + +export function resetAudioForTests(): void { + context = null + clickBuffer = null +} From 991d8ca457c087794875f73d7ce3cf46e091a57a Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Thu, 17 Sep 2026 11:50:35 +0000 Subject: [PATCH 2/8] =?UTF-8?q?feat(3d):=20walkable=20room=20full=20of=20t?= =?UTF-8?q?hings=20about=20T=C3=B3mas,=20plus=20party/gravity/screensaver?= =?UTF-8?q?=20toys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Room & exhibits (built from /profile, with a static fallback) - The desk now sits in a closed room: four walls, ceiling, skirting, pendant light - Exhibits hung around it: neon name sign, two certificate frames (real badge PNGs), a project poster per project on the left wall, an experience/education whiteboard, a bookshelf where every spine is a skill, a contact corkboard, a real-time Reykjavík wall clock, an aurora window with snow, plus desk residents: a debugging duck (quacks, hops), a sleeping tabby (breathes, meows, flicks its tail) and a lava lamp - Click any exhibit (orbit mode) or walk up and press E for an info card with bullets, tags and links; discoveries persist and a "found n/15" counter turns gold — with confetti and a fanfare — when everything has been seen Walk mode - Explore button / `scene walk on`: pointer-lock first person, WASD + Shift, collision with walls and desk, crosshair HUD naming what you face - Key events are captured so the terminal never types while walking; Esc leaves and flies back to the orbit view Toys (all also on the `scene` command) - `scene party`: mirror ball, colour-cycling lights and a chiptune loop - `scene gravity off`: everything on the desk floats away and tumbles - `scene saver matrix|dvd`: screensavers on the CRT - `scene xray`, `scene lights`, `scene look` (list the room), `scene goto ` Tests for exhibit layout, skill parsing, painters, clock, walk collision and proximity, store discovery/celebration, command verbs, screensavers and the new sounds (359 passing). Co-Authored-By: Claude Opus 5 (1M context) --- src/components/commands/SceneCommand.test.tsx | 43 +++ src/components/commands/SceneCommand.tsx | 170 +++++++---- src/components/terminal/DeskProps.tsx | 64 +++- src/components/terminal/ExhibitOverlay.tsx | 136 +++++++++ src/components/terminal/RoomCritters.tsx | 141 +++++++++ src/components/terminal/RoomEffects.tsx | 189 ++++++++++++ src/components/terminal/RoomExhibits.tsx | 218 ++++++++++++++ src/components/terminal/RoomGadgets.tsx | 234 +++++++++++++++ src/components/terminal/TerminalCanvas.tsx | 22 +- src/components/terminal/TerminalScene.tsx | 175 +++++++++-- src/components/terminal/WalkControls.test.ts | 58 ++++ src/components/terminal/WalkControls.tsx | 162 ++++++++++ src/components/terminal/clockTexture.ts | 82 ++++++ src/components/terminal/exhibitActions.ts | 23 ++ src/components/terminal/exhibits.test.ts | 124 ++++++++ src/components/terminal/exhibits.ts | 266 +++++++++++++++++ src/components/terminal/roomTextures.test.ts | 123 ++++++++ src/components/terminal/roomTextures.ts | 276 ++++++++++++++++++ src/components/terminal/sceneStore.test.ts | 56 +++- src/components/terminal/sceneStore.ts | 91 +++++- src/components/terminal/screenTexture.test.ts | 29 +- src/components/terminal/screenTexture.ts | 91 ++++++ src/utils/audio.test.ts | 38 +++ src/utils/audio.ts | 105 +++++++ 24 files changed, 2811 insertions(+), 105 deletions(-) create mode 100644 src/components/terminal/ExhibitOverlay.tsx create mode 100644 src/components/terminal/RoomCritters.tsx create mode 100644 src/components/terminal/RoomEffects.tsx create mode 100644 src/components/terminal/RoomExhibits.tsx create mode 100644 src/components/terminal/RoomGadgets.tsx create mode 100644 src/components/terminal/WalkControls.test.ts create mode 100644 src/components/terminal/WalkControls.tsx create mode 100644 src/components/terminal/clockTexture.ts create mode 100644 src/components/terminal/exhibitActions.ts create mode 100644 src/components/terminal/exhibits.test.ts create mode 100644 src/components/terminal/exhibits.ts create mode 100644 src/components/terminal/roomTextures.test.ts create mode 100644 src/components/terminal/roomTextures.ts diff --git a/src/components/commands/SceneCommand.test.tsx b/src/components/commands/SceneCommand.test.tsx index 51d37b4..8b0db9c 100644 --- a/src/components/commands/SceneCommand.test.tsx +++ b/src/components/commands/SceneCommand.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { render } from '@testing-library/react' import { SceneCommand, parseSwitch } from './SceneCommand' import { sceneStore } from '../terminal/sceneStore' +import { buildExhibits } from '../terminal/exhibits' import type { KeyPressContextType } from '../../context/KeypressedContext' const context = (headless: boolean) => ({ headless }) as unknown as KeyPressContextType @@ -107,6 +108,48 @@ describe('SceneCommand', () => { expect(sceneStore.getState().cameraPreset).toBe('default') }) + it('lists the room and flies to an exhibit', async () => { + expect(await textOf(['look'])).toContain('still loading') + sceneStore.setState({ exhibits: buildExhibits(null) }) + const look = await textOf(['look']) + expect(look).toContain('duck') + expect(look).toContain('Debugging duck') + const text = await textOf(['goto', 'duck']) + expect(text).toContain('looking at Debugging duck') + expect(sceneStore.getState().focusedExhibit).toBe('duck') + expect(sceneStore.getState().cameraPreset).toBeNull() + expect(await textOf(['look'])).toContain('✓ Debugging duck') + }) + + it('rejects an unknown exhibit', async () => { + sceneStore.setState({ exhibits: buildExhibits(null) }) + expect(await textOf(['goto', 'fridge'])).toContain('Nothing called') + }) + + it('toggles walk, lights, party, gravity and xray', async () => { + await textOf(['walk', 'on']) + await textOf(['lights', 'off']) + await textOf(['party', 'on']) + await textOf(['gravity', 'off']) + await textOf(['xray', 'on']) + const state = sceneStore.getState() + expect(state.walk).toBe(true) + expect(state.roomLights).toBe(false) + expect(state.party).toBe(true) + expect(state.gravity).toBe(false) + expect(state.xray).toBe(true) + }) + + it('sets the screensaver', async () => { + expect(await textOf(['saver'])).toContain('screensaver matrix') + expect(sceneStore.getState().screensaver).toBe('matrix') + await textOf(['saver', 'dvd']) + expect(sceneStore.getState().screensaver).toBe('dvd') + expect(await textOf(['saver', 'toaster'])).toContain('Unknown screensaver') + await textOf(['saver', 'off']) + expect(sceneStore.getState().screensaver).toBe('off') + }) + it('shows usage for help and errors for unknown subcommands', async () => { expect(await textOf(['help'])).toContain('Usage') expect(await textOf(['dance'])).toContain('Unknown scene command') diff --git a/src/components/commands/SceneCommand.tsx b/src/components/commands/SceneCommand.tsx index cd1a792..41b498a 100644 --- a/src/components/commands/SceneCommand.tsx +++ b/src/components/commands/SceneCommand.tsx @@ -2,12 +2,20 @@ import React from 'react' import Command from './Command' import { KeyPressContextType } from '../../context/KeypressedContext' import { Box, AlertCircle, Check } from 'lucide-react' -import { sceneStore, CAMERA_PRESET_NAMES, type CameraPreset, type SceneState } from '../terminal/sceneStore' - -type ToggleKey = 'power' | 'lampOn' | 'effects' | 'sound' | 'spin' | 'props' +import { + sceneStore, + CAMERA_PRESET_NAMES, + SCREENSAVERS, + type CameraPreset, + type SceneState, + type SceneToggle, + type Screensaver, +} from '../terminal/sceneStore' +import { exhibitView } from '../terminal/exhibits' +import { activateExhibit } from '../terminal/exhibitActions' /** Subcommand → store key for the on/off toggles */ -const TOGGLES: Record = { +const TOGGLES: Record = { power: 'power', lamp: 'lampOn', fx: 'effects', @@ -15,9 +23,17 @@ const TOGGLES: Record = { sound: 'sound', spin: 'spin', props: 'props', + walk: 'walk', + explore: 'walk', + lights: 'roomLights', + party: 'party', + disco: 'party', + gravity: 'gravity', + xray: 'xray', } const CAMERA_ALIASES = ['cam', 'camera', 'view'] +const GOTO_ALIASES = ['goto', 'go', 'inspect', 'open'] const onOff = (value: boolean) => (value ? 'on' : 'off') @@ -47,63 +63,117 @@ const Fail = ({ children }: { children: React.ReactNode }) => (
) +const Panel = ({ title, rows, footer }: { title: string; rows: [string, string][]; footer?: React.ReactNode }) => ( +
+
+
+ + {title} +
+
+ {rows.map(([key, value]) => ( +

+ {key.padEnd(10)} + {value} +

+ ))} +
+ {footer} +
+
+) + const Status = ({ state, headless }: { state: SceneState; headless: boolean }) => { const rows: [string, string][] = [ - ['camera', state.cameraPreset], + ['camera', state.walk ? 'walking' : (state.cameraPreset ?? 'exhibit')], ['power', onOff(state.power)], ['lamp', onOff(state.lampOn)], + ['lights', onOff(state.roomLights)], ['fx', onOff(state.effects)], ['sound', onOff(state.sound)], ['spin', onOff(state.spin)], ['props', onOff(state.props)], + ['gravity', onOff(state.gravity)], + ['party', onOff(state.party)], + ['xray', onOff(state.xray)], + ['saver', state.screensaver], + ['found', `${state.exhibits.filter((e) => state.discovered.includes(e.id)).length}/${state.exhibits.length}`], ] if (headless && state.fps > 0) rows.push(['fps', String(state.fps)]) return ( -
-
-
- - 3d scene -
-
- {rows.map(([key, value]) => ( -

- {key.padEnd(8)} - {value} -

- ))} -
- {!headless && ( + Settings apply to the 3D terminal — open /blob to see them.

- )} -
-
+ ) + } + /> ) } +/** `scene look`: everything in the room, ticked once inspected */ +const Look = ({ state }: { state: SceneState }) => { + if (state.exhibits.length === 0) return The room is still loading — try again in a second. + const rows: [string, string][] = state.exhibits.map((e) => [e.id, `${state.discovered.includes(e.id) ? '✓ ' : ' '}${e.title}`]) + return scene goto <id> flies to it and opens its card.

} /> +} + +const usage = ( +
+

Usage:

+

scene [command] [value]

+

Look around:

+
+

look — List everything in the room

+

goto <id> — Fly to a thing and open its card

+

walk on|off — First-person mode (WASD, mouse, E, Esc)

+

cam {CAMERA_PRESET_NAMES.join('|')} — Fly the camera to a view

+
+

Room:

+
+

power · lamp · lights · fx · sound · spin · props on|off

+

party on|off — Disco lights and a chiptune

+

gravity on|off — Off, and the desk floats away

+

xray on|off — Wireframe everything

+

saver {SCREENSAVERS.join('|')} — Screensaver on the CRT

+

reset — Back to defaults

+
+

Omit the value to toggle. Run scene alone for the current state.

+
+) + +const runCamera = (value: string | undefined) => { + const preset = (value === 'reset' || value === undefined ? 'default' : value) as CameraPreset + if (!CAMERA_PRESET_NAMES.includes(preset)) { + return Unknown view "{value}" — try {CAMERA_PRESET_NAMES.join(', ')} + } + sceneStore.setCamera(preset) + return camera → {preset} +} + +const runGoto = (value: string | undefined, state: SceneState) => { + const exhibit = state.exhibits.find((e) => e.id === value || e.id === `project-${value}`) + if (!exhibit) return Nothing called "{value}" here — scene look lists the room. + sceneStore.flyTo(exhibitView(exhibit)) + activateExhibit(exhibit.id) + return looking at {exhibit.title} +} + +const runSaver = (value: string | undefined) => { + const saver = (value ?? 'matrix') as Screensaver + if (!SCREENSAVERS.includes(saver)) return Unknown screensaver "{value}" — try {SCREENSAVERS.join(', ')} + sceneStore.setState({ screensaver: saver }) + return screensaver {saver} +} + export const SceneCommand: Command = { name: 'scene', - description: 'Control the 3D terminal scene (camera, lamp, effects, sound…)', - usage: ( -
-

Usage:

-

scene [command] [value]

-

Commands:

-
-

cam {CAMERA_PRESET_NAMES.join('|')} — Fly the camera to a view

-

power on|off — Switch the monitor on or off

-

lamp on|off — Desk lamp

-

fx on|off — CRT post-processing

-

sound on|off — Key clicks

-

spin on|off — Slowly orbit the desk

-

props on|off — Desk clutter

-

reset — Back to defaults

-
-

Omit the value to toggle. Run scene alone for the current state.

-
- ), + description: 'Control the 3D room: walk around, fly to exhibits, lights, party mode…', + usage, args: [], run: async (args: string[], context: KeyPressContextType) => { const headless = !!context.headless @@ -111,23 +181,17 @@ export const SceneCommand: Command = { const state = sceneStore.getState() if (!sub || sub === 'status') return - - if (sub === 'help') return SceneCommand.usage ?? null + if (sub === 'help') return usage + if (sub === 'look' || sub === 'ls') return + if (CAMERA_ALIASES.includes(sub)) return runCamera(value) + if (GOTO_ALIASES.includes(sub)) return runGoto(value, state) + if (sub === 'saver' || sub === 'screensaver') return runSaver(value) if (sub === 'reset') { sceneStore.reset() return scene reset to defaults } - if (CAMERA_ALIASES.includes(sub)) { - const preset = (value === 'reset' || value === undefined ? 'default' : value) as CameraPreset - if (!CAMERA_PRESET_NAMES.includes(preset)) { - return Unknown view "{value}" — try {CAMERA_PRESET_NAMES.join(', ')} - } - sceneStore.setCamera(preset) - return camera → {preset} - } - const key = TOGGLES[sub] if (key) { const next = parseSwitch(value, state[key]) diff --git a/src/components/terminal/DeskProps.tsx b/src/components/terminal/DeskProps.tsx index a3f1fa1..e28b110 100644 --- a/src/components/terminal/DeskProps.tsx +++ b/src/components/terminal/DeskProps.tsx @@ -3,12 +3,21 @@ import React, { useMemo } from 'react' import { Grid } from '@react-three/drei' import * as THREE from 'three' +import { ROOM } from './exhibits' /** Top surface of the desk; the computer model's base sits exactly here */ export const DESK_TOP_Y = -0.062 -const FLOOR_Y = -0.86 +const FLOOR_Y = ROOM.floorY -/** Static room: desk, wall and a retro grid floor. Always visible. */ +const WALL = '#1b1e26' +const ROOM_W = ROOM.maxX - ROOM.minX +const ROOM_D = ROOM.maxZ - ROOM.minZ +const ROOM_H = ROOM.ceilingY - ROOM.floorY +const ROOM_CX = (ROOM.minX + ROOM.maxX) / 2 +const ROOM_CZ = (ROOM.minZ + ROOM.maxZ) / 2 +const ROOM_CY = (ROOM.floorY + ROOM.ceilingY) / 2 + +/** Static room: desk, four walls, ceiling and a retro grid floor. Always visible. */ export function Room() { return ( @@ -26,28 +35,56 @@ export function Room() { )) )} - {/* Back wall and floor so the lamp and screen glow have something to land on */} - - - + {/* Walls, ceiling and floor so lights have something to land on */} + + + + + + + + + + + + + + + + + + + - - + + + {/* Skirting so the walls meet the floor */} + {[ + [ROOM_CX, ROOM.minZ + 0.02, ROOM_W, 0], + [ROOM_CX, ROOM.maxZ - 0.02, ROOM_W, 0], + [ROOM.minX + 0.02, ROOM_CZ, ROOM_D, Math.PI / 2], + [ROOM.maxX - 0.02, ROOM_CZ, ROOM_D, Math.PI / 2], + ].map(([x, z, len, rot], i) => ( + + + + + ))} ) } @@ -193,12 +230,13 @@ function Floppies() { /** Desk clutter, toggled with `scene props` */ export function DeskProps({ lampOn }: { lampOn: boolean }) { + // A fragment, so each prop is its own FloatGroup child and drifts independently when gravity is off return ( - + <> - + ) } diff --git a/src/components/terminal/ExhibitOverlay.tsx b/src/components/terminal/ExhibitOverlay.tsx new file mode 100644 index 0000000..b10f520 --- /dev/null +++ b/src/components/terminal/ExhibitOverlay.tsx @@ -0,0 +1,136 @@ +'use client' + +import React, { useEffect, useMemo } from 'react' +import { X, ExternalLink, Footprints, Trophy } from 'lucide-react' +import { duckTip, type ExhibitLink } from './exhibits' +import { sceneStore, useSceneStore } from './sceneStore' + +/** Tag pills and link buttons under the card body */ +function CardFooter({ tags, links }: { tags?: string[]; links?: ExhibitLink[] }) { + return ( + <> + {tags && tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} + {links && links.length > 0 && ( +
+ {links.map((link) => ( + + + {link.label} + + ))} +
+ )} + + ) +} + +/** Info card for the open exhibit; closes on Esc, the × or clicking the backdrop (orbit mode) */ +export function ExhibitCard() { + const focused = useSceneStore((s) => s.focusedExhibit) + const exhibits = useSceneStore((s) => s.exhibits) + const walk = useSceneStore((s) => s.walk) + const exhibit = exhibits.find((e) => e.id === focused) + const tip = useMemo(() => (focused === 'duck' ? duckTip() : null), [focused]) + + useEffect(() => { + if (!focused) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') sceneStore.inspect(null) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [focused]) + + if (!exhibit) return null + const lines = tip ? [tip] : exhibit.lines + + return ( +
sceneStore.inspect(null)} + > +
e.stopPropagation()} + > +
+
+

{exhibit.title}

+ {exhibit.subtitle &&

{exhibit.subtitle}

} +
+ +
+ {lines.length > 0 && ( +
    + {lines.map((line) => ( +
  • + › + {line} +
  • + ))} +
+ )} + +
+
+ ) +} + +/** Crosshair plus movement hints while walking; names the exhibit you are facing */ +export function WalkHud() { + const walk = useSceneStore((s) => s.walk) + const near = useSceneStore((s) => s.nearExhibit) + const exhibits = useSceneStore((s) => s.exhibits) + if (!walk) return null + const target = exhibits.find((e) => e.id === near) + return ( +
+
+ {target && ( +
+ [E] {target.title} +
+ )} +
+ + WASD move · mouse look · shift run · E inspect · esc leave +
+
+ ) +} + +/** "Found 3/12" pill; turns gold once everything has been inspected */ +export function DiscoveryCounter() { + const exhibits = useSceneStore((s) => s.exhibits) + const discovered = useSceneStore((s) => s.discovered) + if (exhibits.length === 0) return null + const found = exhibits.filter((e) => discovered.includes(e.id)).length + const done = found >= exhibits.length + return ( + + + {found}/{exhibits.length} + + ) +} diff --git a/src/components/terminal/RoomCritters.tsx b/src/components/terminal/RoomCritters.tsx new file mode 100644 index 0000000..4e04f3d --- /dev/null +++ b/src/components/terminal/RoomCritters.tsx @@ -0,0 +1,141 @@ +'use client' + +import React, { useRef } from 'react' +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import type { Exhibit } from './exhibits' +import { Interactive } from './RoomExhibits' +import { reactions } from './exhibitActions' + +const YELLOW = '#ffd60a' +const ORANGE = '#fb923c' + +/** Rubber duck that squashes and hops when activated */ +export function Duck({ exhibit }: { exhibit: Exhibit }) { + const body = useRef(null) + const seen = useRef(reactions.duck) + const hop = useRef(0) + + useFrame((_, delta) => { + if (!body.current) return + if (reactions.duck !== seen.current) { + seen.current = reactions.duck + hop.current = 1 + } + if (hop.current > 0) { + hop.current = Math.max(0, hop.current - delta * 2.2) + const t = 1 - hop.current + const bounce = Math.sin(t * Math.PI) * 0.06 + body.current.position.y = bounce + body.current.scale.set(1 + Math.sin(t * Math.PI * 2) * 0.15, 1 - Math.sin(t * Math.PI * 2) * 0.15, 1) + } else { + body.current.position.y = 0 + body.current.scale.setScalar(1) + } + }) + + return ( + + {(highlighted) => ( + + + + + + + + + + + + + + {[-1, 1].map((side) => ( + + + + + ))} + + )} + + ) +} + +const FUR = '#d98a3d' +const FUR_DARK = '#b86f2a' + +/** Head resting on paws: ears, closed eyes, pink nose */ +function CatHead({ highlighted }: { highlighted: boolean }) { + return ( + + + + + + {[-1, 1].map((side) => ( + + + + + ))} + {[-1, 1].map((side) => ( + + + + + ))} + + + + + + ) +} + +/** Low-poly tabby curled up asleep; breathes, and flicks its tail when activated */ +export function Cat({ exhibit }: { exhibit: Exhibit }) { + const bodyRef = useRef(null) + const tailRef = useRef(null) + const seen = useRef(reactions.cat) + const flick = useRef(0) + + useFrame(({ clock }, delta) => { + const t = clock.getElapsedTime() + if (bodyRef.current) bodyRef.current.scale.set(1, 1 + Math.sin(t * 1.6) * 0.03, 1) + if (reactions.cat !== seen.current) { + seen.current = reactions.cat + flick.current = 1 + } + if (tailRef.current) { + flick.current = Math.max(0, flick.current - delta * 1.2) + tailRef.current.rotation.y = Math.sin(t * 0.7) * 0.05 + Math.sin(flick.current * Math.PI * 4) * 0.5 * flick.current + } + }) + + return ( + + {(highlighted) => ( + + + + + + {/* Stripes */} + {[-0.05, 0, 0.05].map((x) => ( + + + + + ))} + + {/* Tail wrapped around */} + + + + + + )} + + ) +} diff --git a/src/components/terminal/RoomEffects.tsx b/src/components/terminal/RoomEffects.tsx new file mode 100644 index 0000000..7808623 --- /dev/null +++ b/src/components/terminal/RoomEffects.tsx @@ -0,0 +1,189 @@ +'use client' + +import React, { useEffect, useMemo, useRef } from 'react' +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import { ROOM } from './exhibits' +import { useSceneStore } from './sceneStore' +import { startParty, stopParty } from '../../utils/audio' + +/** Pendant lamp in the middle of the ceiling; on while walking so the walls are visible */ +export function RoomLights({ on }: { on: boolean }) { + const y = ROOM.ceilingY - 0.02 + return ( + + + + + + + + + + + + + + {on && } + + ) +} + +const DISCO_COLORS = ['#ff2d95', '#22c55e', '#38bdf8', '#facc15', '#a855f7'] + +/** Mirror ball plus three colour-cycling spots sweeping the room; also drives the chiptune */ +export function Disco({ on }: { on: boolean }) { + const ball = useRef(null) + const lights = useRef<(THREE.PointLight | null)[]>([]) + const soundOn = useSceneStore((s) => s.sound) + + useEffect(() => { + if (on && soundOn) startParty() + else stopParty() + return () => stopParty() + }, [on, soundOn]) + + useFrame(({ clock }) => { + if (!on) return + const t = clock.getElapsedTime() + if (ball.current) ball.current.rotation.y = t * 0.8 + lights.current.forEach((light, i) => { + if (!light) return + const a = t * (0.9 + i * 0.25) + (i * Math.PI * 2) / 3 + light.position.set(Math.cos(a) * 1.8, 0.6 + Math.sin(t * 2 + i) * 0.5, 1.6 + Math.sin(a) * 1.6) + light.color.set(DISCO_COLORS[(Math.floor(t * 2) + i) % DISCO_COLORS.length]) + }) + }) + + if (!on) return null + return ( + + + + + + {DISCO_COLORS.slice(0, 3).map((color, i) => ( + { + lights.current[i] = el + }} + color={color} + intensity={6} + distance={6} + decay={1.8} + /> + ))} + + ) +} + +/** + * Wraps desk clutter; when gravity is off every direct child drifts up, bobs and slowly tumbles, + * and settles back when gravity returns. + */ +export function FloatGroup({ children }: { children: React.ReactNode }) { + const group = useRef(null) + const gravity = useSceneStore((s) => s.gravity) + const lift = useRef(0) + + useFrame(({ clock }, delta) => { + if (!group.current) return + lift.current = THREE.MathUtils.damp(lift.current, gravity ? 0 : 1, 2, delta) + const t = clock.getElapsedTime() + group.current.children.forEach((child, i) => { + const base = (child.userData.baseY ??= child.position.y) as number + const rise = lift.current * (0.35 + (i % 4) * 0.12) + child.position.y = base + rise + Math.sin(t * 1.1 + i) * 0.03 * lift.current + child.rotation.z = Math.sin(t * 0.6 + i * 2) * 0.25 * lift.current + child.rotation.x = Math.cos(t * 0.5 + i) * 0.2 * lift.current + }) + }) + + return {children} +} + +const CONFETTI_COUNT = 350 +const CONFETTI_LIFE = 3.2 + +/** Burst of coloured confetti from above the desk whenever `celebrateNonce` changes */ +export function Confetti() { + const nonce = useSceneStore((s) => s.celebrateNonce) + const points = useRef(null) + const seen = useRef(nonce) + const age = useRef(CONFETTI_LIFE) + const velocities = useRef(new Float32Array(CONFETTI_COUNT * 3)) + + const geometry = useMemo(() => { + const g = new THREE.BufferGeometry() + g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(CONFETTI_COUNT * 3), 3)) + const colors = new Float32Array(CONFETTI_COUNT * 3) + const palette = DISCO_COLORS.map((c) => new THREE.Color(c)) + for (let i = 0; i < CONFETTI_COUNT; i++) { + const c = palette[i % palette.length] + colors.set([c.r, c.g, c.b], i * 3) + } + g.setAttribute('color', new THREE.BufferAttribute(colors, 3)) + return g + }, []) + + useFrame((_, delta) => { + if (!points.current) return + const attr = points.current.geometry.attributes.position as THREE.BufferAttribute + const arr = attr.array as Float32Array + if (nonce !== seen.current) { + seen.current = nonce + age.current = 0 + for (let i = 0; i < CONFETTI_COUNT; i++) { + arr.set([0, 1.3, 1.2], i * 3) + velocities.current.set([(Math.random() - 0.5) * 3, Math.random() * 2.5 + 0.5, (Math.random() - 0.5) * 3], i * 3) + } + } + if (age.current >= CONFETTI_LIFE) { + points.current.visible = false + return + } + points.current.visible = true + age.current += delta + const v = velocities.current + for (let i = 0; i < CONFETTI_COUNT; i++) { + v[i * 3 + 1] -= 3.5 * delta + arr[i * 3] += v[i * 3] * delta + arr[i * 3 + 1] += v[i * 3 + 1] * delta + arr[i * 3 + 2] += v[i * 3 + 2] * delta + if (arr[i * 3 + 1] < ROOM.floorY) arr[i * 3 + 1] = ROOM.floorY + } + attr.needsUpdate = true + const material = points.current.material as THREE.PointsMaterial + material.opacity = 1 - age.current / CONFETTI_LIFE + }) + + return ( + + + + ) +} + +/** Sets every material in the subtree to wireframe while x-ray is on */ +export function XRay({ enabled, children }: { enabled: boolean; children: React.ReactNode }) { + const group = useRef(null) + useEffect(() => { + const root = group.current + if (!root) return + const touched: THREE.Material[] = [] + root.traverse((obj) => { + const mesh = obj as THREE.Mesh + if (!mesh.isMesh) return + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + materials.forEach((m) => { + if ('wireframe' in m) { + ;(m as THREE.MeshStandardMaterial).wireframe = enabled + touched.push(m) + } + }) + }) + return () => touched.forEach((m) => ('wireframe' in m ? ((m as THREE.MeshStandardMaterial).wireframe = false) : undefined)) + }, [enabled]) + return {children} +} diff --git a/src/components/terminal/RoomExhibits.tsx b/src/components/terminal/RoomExhibits.tsx new file mode 100644 index 0000000..efcee8f --- /dev/null +++ b/src/components/terminal/RoomExhibits.tsx @@ -0,0 +1,218 @@ +'use client' + +import React, { useEffect, useMemo, useState } from 'react' +import { useTexture } from '@react-three/drei' +import type { ThreeEvent } from '@react-three/fiber' +import * as THREE from 'three' +import type { Exhibit } from './exhibits' +import { PaintedSurface, paintBoard, paintCorkboard, paintPoster, paintSign, paintSpine, whenFontsReady, type Painter } from './roomTextures' +import { sceneStore, useSceneStore } from './sceneStore' +import { activateExhibit } from './exhibitActions' + +const HIGHLIGHT = '#22c55e' + +/** Wraps any exhibit: click to inspect (orbit mode), pointer cursor, and a glow when near/hovered/open */ +export function Interactive({ exhibit, children }: { exhibit: Exhibit; children: (highlighted: boolean) => React.ReactNode }) { + const [hovered, setHovered] = useState(false) + const near = useSceneStore((s) => s.nearExhibit === exhibit.id) + const focused = useSceneStore((s) => s.focusedExhibit === exhibit.id) + + const onClick = (e: ThreeEvent) => { + e.stopPropagation() + if (sceneStore.getState().walk) return + activateExhibit(exhibit.id) + } + const onOver = (e: ThreeEvent) => { + e.stopPropagation() + if (sceneStore.getState().walk) return + setHovered(true) + document.body.style.cursor = 'pointer' + } + const onOut = () => { + setHovered(false) + document.body.style.cursor = '' + } + + return ( + + {children(hovered || near || focused)} + + ) +} + +/** Picture frame around a textured face; the frame glows when highlighted */ +function Framed({ size, texture, highlighted, frameColor = '#1a1612', depth = 0.03, transparent = false }: { + size: [number, number] + texture: THREE.Texture + highlighted: boolean + frameColor?: string + depth?: number + transparent?: boolean +}) { + const [w, h] = size + const border = Math.min(w, h) * 0.06 + return ( + + + + + + {/* Self-lit a little so wall pieces stay readable in a dark room */} + + + + + + ) +} + +const POSTER_PX = 512 + +/** Texture painted by `painter` now and again once the web fonts are in (painter must be memoised) */ +export function usePaintedTexture(width: number, height: number, painter: Painter): THREE.Texture { + const surface = useMemo(() => new PaintedSurface(width, height), [width, height]) + useEffect(() => { + surface.paint(painter) + return whenFontsReady(() => surface.paint(painter)) + }, [surface, painter]) + return surface.texture +} + +/** Project poster / whiteboard / corkboard: a canvas-painted face in a frame */ +export function PaintedExhibit({ exhibit }: { exhibit: Exhibit }) { + const [w, h] = exhibit.size + const px = exhibit.kind === 'poster' ? POSTER_PX : 768 + const painter = useMemo(() => { + const paint = exhibit.kind === 'board' ? paintBoard : exhibit.kind === 'corkboard' ? paintCorkboard : paintPoster + return paint(exhibit) + }, [exhibit]) + const texture = usePaintedTexture(px, Math.round((px * h) / w), painter) + const frameColor = exhibit.kind === 'board' ? '#c9ccc7' : exhibit.kind === 'corkboard' ? '#3f2a1a' : '#1a1612' + return ( + + {(highlighted) => } + + ) +} + +/** Certificate badge PNG in a dark frame */ +export function CertExhibit({ exhibit }: { exhibit: Exhibit }) { + const texture = useTexture(exhibit.image ?? '/icon.png', (loaded) => { + loaded.colorSpace = THREE.SRGBColorSpace + loaded.needsUpdate = true + }) + return ( + + {(highlighted) => ( + + + {/* Matte behind the transparent badge */} + + + + + + )} + + ) +} + +/** Neon name sign: glowing text plus a faint light on the wall */ +export function SignExhibit({ exhibit }: { exhibit: Exhibit }) { + const painter = useMemo(() => paintSign(exhibit), [exhibit]) + const texture = usePaintedTexture(1536, 320, painter) + return ( + + {(highlighted) => ( + + + + + + + + )} + + ) +} + +const BOOK_COLORS = ['#7f1d1d', '#1e3a5f', '#14532d', '#4a1d96', '#78350f', '#0f766e', '#831843', '#3f3f46'] + +/** Deterministic pseudo-random in [0,1) from an index */ +const jitter = (i: number, salt: number) => ((Math.sin(i * 127.1 + salt * 311.7) * 43758.5453) % 1 + 1) % 1 + +type Book = { label: string; x: number; y: number; bw: number; bh: number; color: string; painter: Painter; lean: number } + +/** Places one book per skill on `rows` shelves, left to right, with varied widths and heights */ +function layoutBooks(tags: string[], w: number, h: number, rows: number): Book[] { + const rowHeight = h / rows + const perRow = Math.ceil(tags.length / rows) + const cursor = Array.from({ length: rows }, () => -w / 2 + 0.05) + return tags.map((label, i) => { + const row = Math.floor(i / perRow) + const bw = 0.045 + jitter(i, 1) * 0.03 + const bh = rowHeight * (0.6 + jitter(i, 2) * 0.3) + const color = BOOK_COLORS[i % BOOK_COLORS.length] + const x = cursor[row] + bw / 2 + cursor[row] += bw + 0.006 + const y = -h / 2 + row * rowHeight + 0.0125 + bh / 2 + return { label, x, y, bw, bh, color, painter: paintSpine(label, color), lean: (jitter(i, 3) - 0.5) * 0.08 } + }) +} + +const SHELF_DEPTH = 0.24 + +function BookMesh({ book }: { book: Book }) { + const texture = usePaintedTexture(64, 384, book.painter) + return ( + + + + + + + + + + ) +} + +/** Bookshelf where every spine is one skill */ +export function ShelfExhibit({ exhibit }: { exhibit: Exhibit }) { + const [w, h] = exhibit.size + const rows = 3 + const books = useMemo(() => layoutBooks(exhibit.tags ?? [], w, h, rows), [exhibit.tags, w, h]) + + return ( + + {(highlighted) => ( + + {[-1, 1].map((side) => ( + + + + + ))} + + + + + {Array.from({ length: rows + 1 }, (_, r) => ( + + + + + ))} + {books.map((book) => ( + + ))} + + )} + + ) +} diff --git a/src/components/terminal/RoomGadgets.tsx b/src/components/terminal/RoomGadgets.tsx new file mode 100644 index 0000000..4159a53 --- /dev/null +++ b/src/components/terminal/RoomGadgets.tsx @@ -0,0 +1,234 @@ +'use client' + +import React, { useEffect, useMemo, useRef } from 'react' +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import type { Exhibit } from './exhibits' +import { Interactive } from './RoomExhibits' +import { ClockSurface } from './clockTexture' + +const LAVA = '#ff5a1f' +const BLOB_COUNT = 5 + +/** Cone of glass with slowly rising and falling wax blobs, lit from inside */ +export function LavaLamp({ exhibit }: { exhibit: Exhibit }) { + const blobs = useRef<(THREE.Mesh | null)[]>([]) + const [, height] = exhibit.size + const glassH = height * 0.7 + + useFrame(({ clock }) => { + const t = clock.getElapsedTime() + blobs.current.forEach((blob, i) => { + if (!blob) return + const phase = i * 1.3 + const y = 0.09 + ((Math.sin(t * 0.35 + phase) + 1) / 2) * (glassH - 0.08) + blob.position.y = y + const s = 0.7 + Math.sin(t * 0.9 + phase * 2) * 0.25 + blob.scale.set(s, 1.15 - s * 0.25, s) + }) + }) + + return ( + + {(highlighted) => ( + + + + + + + + + + + + + + {Array.from({ length: BLOB_COUNT }, (_, i) => ( + { + blobs.current[i] = el + }} + position={[0, 0.1, 0]} + > + + + + ))} + + + )} + + ) +} + +/** Analog wall clock repainted once a second in Reykjavík time */ +export function WallClock({ exhibit }: { exhibit: Exhibit }) { + const surface = useMemo(() => new ClockSurface(), []) + + useEffect(() => { + const timer = setInterval(() => surface.tick(new Date()), 1000) + return () => clearInterval(timer) + }, [surface]) + + const [w] = exhibit.size + return ( + + {(highlighted) => ( + + + + + + + + + + + )} + + ) +} + +const AURORA_VERT = /* glsl */ ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +` + +const AURORA_FRAG = /* glsl */ ` + uniform float uTime; + varying vec2 vUv; + float band(vec2 uv, float offset, float speed, float freq) { + float y = 0.6 + 0.14 * sin(uv.x * freq + uTime * speed + offset) + 0.05 * sin(uv.x * freq * 2.3 - uTime * speed * 1.7); + float d = abs(uv.y - y); + float ripple = 0.65 + 0.35 * sin(uv.x * 14.0 + uTime * 0.8 + offset); + return smoothstep(0.22, 0.0, d) * ripple; + } + float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } + void main() { + vec3 sky = mix(vec3(0.01, 0.02, 0.06), vec3(0.02, 0.05, 0.13), vUv.y); + float a1 = band(vUv, 0.0, 0.35, 3.0); + float a2 = band(vUv, 2.0, 0.22, 4.5) * 0.7; + vec3 col = sky + a1 * vec3(0.15, 0.95, 0.5) * 0.9 + a2 * vec3(0.35, 0.45, 0.95) * 0.55; + vec2 cell = floor(vUv * vec2(140.0, 100.0)); + float star = step(0.992, hash(cell)) * (0.5 + 0.5 * sin(uTime * 2.0 + hash(cell + 1.0) * 6.28)); + col += star * 0.9 * smoothstep(0.25, 0.5, vUv.y); + float ridge = 0.2 + 0.07 * sin(vUv.x * 6.0) + 0.04 * sin(vUv.x * 17.0 + 1.0); + float mountain = smoothstep(0.015, 0.0, vUv.y - ridge); + col = mix(col, vec3(0.008, 0.012, 0.02), mountain); + gl_FragColor = vec4(col, 1.0); + } +` + +const SNOW_COUNT = 120 + +/** Deterministic pseudo-random in [0,1) so the flakes are stable across renders */ +const hash = (i: number, salt: number) => ((Math.sin(i * 127.1 + salt * 311.7) * 43758.5453) % 1 + 1) % 1 + +/** Slow snowfall just behind the glass */ +function Snow({ w, h }: { w: number; h: number }) { + const snow = useRef(null) + const geometry = useMemo(() => { + const positions = new Float32Array(SNOW_COUNT * 3) + for (let i = 0; i < SNOW_COUNT; i++) { + positions[i * 3] = (hash(i, 1) - 0.5) * w + positions[i * 3 + 1] = (hash(i, 2) - 0.5) * h + positions[i * 3 + 2] = -0.02 - hash(i, 3) * 0.05 + } + const g = new THREE.BufferGeometry() + g.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + return g + }, [w, h]) + + useFrame(({ clock }, delta) => { + if (!snow.current) return + const t = clock.getElapsedTime() + const attr = snow.current.geometry.attributes.position as THREE.BufferAttribute + const arr = attr.array as Float32Array + for (let i = 0; i < SNOW_COUNT; i++) { + arr[i * 3 + 1] -= delta * (0.08 + (i % 5) * 0.02) + arr[i * 3] += Math.sin(t * 0.8 + i) * delta * 0.03 + if (arr[i * 3 + 1] < -h / 2) arr[i * 3 + 1] = h / 2 + if (arr[i * 3] > w / 2) arr[i * 3] = -w / 2 + if (arr[i * 3] < -w / 2) arr[i * 3] = w / 2 + } + attr.needsUpdate = true + }) + + return ( + + + + ) +} + +/** Window onto an Icelandic night: animated aurora shader, twinkling stars, drifting snow */ +export function AuroraWindow({ exhibit }: { exhibit: Exhibit }) { + const [w, h] = exhibit.size + const material = useRef(null) + const uniforms = useMemo(() => ({ uTime: { value: 0 } }), []) + + useFrame(({ clock }) => { + if (material.current) material.current.uniforms.uTime.value = clock.getElapsedTime() + }) + + const frame = 0.05 + const bars: [number, number, number, number][] = [ + [0, h / 2 + frame / 2, w + frame * 2, frame], + [0, -h / 2 - frame / 2, w + frame * 2, frame], + [-w / 2 - frame / 2, 0, frame, h], + [w / 2 + frame / 2, 0, frame, h], + [0, 0, w, frame * 0.5], + [0, 0, frame * 0.5, h], + ] + return ( + + {(highlighted) => ( + + + + + + + {bars.map(([x, y, bw, bh], i) => ( + + + + + ))} + + + )} + + ) +} + +/** Pot plant for the corner; purely decorative */ +export function Plant({ position }: { position: [number, number, number] }) { + const leaves = useMemo( + () => Array.from({ length: 7 }, (_, i) => ({ angle: (i / 7) * Math.PI * 2, tilt: 0.5 + (i % 3) * 0.2, len: 0.22 + (i % 2) * 0.08 })), + [] + ) + return ( + + + + + + + + + + {leaves.map((leaf, i) => ( + + + + + ))} + + ) +} diff --git a/src/components/terminal/TerminalCanvas.tsx b/src/components/terminal/TerminalCanvas.tsx index a647606..934a2e6 100644 --- a/src/components/terminal/TerminalCanvas.tsx +++ b/src/components/terminal/TerminalCanvas.tsx @@ -3,7 +3,7 @@ import React, { useState, useCallback, useEffect } from 'react' import { isMobile } from 'react-device-detect' import { useProgress } from '@react-three/drei' -import { Monitor, Zap, ZapOff, Volume2, VolumeX, Lightbulb, LightbulbOff, RotateCcw, ExternalLink, Power } from 'lucide-react' +import { Monitor, Zap, ZapOff, Volume2, VolumeX, Lightbulb, LightbulbOff, RotateCcw, ExternalLink, Power, Footprints, PartyPopper } from 'lucide-react' import TerminalHandler from '../TerminalHandler' import { KeyPressProvider } from '../../context/KeypressedContext' import useKeyClick from '../../hooks/useKeyClick' @@ -11,6 +11,7 @@ import { playPowerOn } from '../../utils/audio' import TerminalScene from './TerminalScene' import BootSequence from './BootSequence' import { CAMERA_PRESET_NAMES, sceneStore, useSceneStore } from './sceneStore' +import { ExhibitCard, WalkHud, DiscoveryCounter } from './ExhibitOverlay' const toggleButton = (active: boolean) => `flex items-center gap-1.5 px-2 py-1 font-mono text-[10px] rounded border transition ${ @@ -34,7 +35,7 @@ function Header({ children }: { children?: React.ReactNode }) { retro

- type scene help to control the room from the shell + walk around with Explore · type scene help for everything else

@@ -53,6 +54,8 @@ export default function TerminalCanvas() { const lampOn = useSceneStore((s) => s.lampOn) const power = useSceneStore((s) => s.power) const cameraPreset = useSceneStore((s) => s.cameraPreset) + const walk = useSceneStore((s) => s.walk) + const party = useSceneStore((s) => s.party) const fps = useSceneStore((s) => s.fps) const playClick = useKeyClick(sound) const { progress } = useProgress() @@ -108,6 +111,14 @@ export default function TerminalCanvas() {
+ +
{/* Status Bar */} @@ -164,8 +179,9 @@ export default function TerminalCanvas() { 3D Mode
- drag to orbit • scroll to zoom • double-click to reset + {walk ? 'WASD to walk • mouse to look • E to inspect • Esc to leave' : 'drag to orbit • scroll to zoom • click things to inspect them'} +
view diff --git a/src/components/terminal/TerminalScene.tsx b/src/components/terminal/TerminalScene.tsx index 02899eb..a3a423f 100644 --- a/src/components/terminal/TerminalScene.tsx +++ b/src/components/terminal/TerminalScene.tsx @@ -2,14 +2,32 @@ import { OrbitControls, useGLTF } from '@react-three/drei' import { Canvas, useFrame, useThree } from '@react-three/fiber' -import React, { useCallback, useMemo, useEffect, useRef, useSyncExternalStore } from 'react' +import React, { Suspense, useCallback, useMemo, useEffect, useRef, useSyncExternalStore } from 'react' import * as THREE from 'three' import { EffectComposer } from '@react-three/postprocessing' import CRTEffects from './CRTEffects' import AmbientEffects from './AmbientEffects' import { Room, DeskProps } from './DeskProps' -import { CAMERA_PRESETS, sceneStore, useSceneStore } from './sceneStore' -import { SCREEN, drawScreen, phosphorColor, type DrawScreenOptions } from './screenTexture' +import { sceneStore, useSceneStore, type Screensaver } from './sceneStore' +import { + SCREEN, + drawScreen, + phosphorColor, + createMatrixState, + drawMatrixFrame, + createDvdState, + drawDvdFrame, + type DrawScreenOptions, + type MatrixState, + type DvdState, +} from './screenTexture' +import { buildExhibits, ROOM, type Exhibit } from './exhibits' +import { CertExhibit, PaintedExhibit, ShelfExhibit, SignExhibit } from './RoomExhibits' +import { Cat, Duck } from './RoomCritters' +import { AuroraWindow, LavaLamp, Plant, WallClock } from './RoomGadgets' +import { Confetti, Disco, FloatGroup, RoomLights, XRay } from './RoomEffects' +import WalkControls from './WalkControls' +import { useProfile } from '../../hooks/useProfile' import { DEFAULT_CAMERA_CONFIG } from '../../types/terminal3d' import type { TerminalSceneProps, CameraConfig } from '../../types/terminal3d' @@ -61,22 +79,56 @@ class ScreenSurface { drawScreen({ ctx: this.ctx, ...options }) this.texture.needsUpdate = true } + + /** Advances the active screensaver by one frame */ + paintSaver(saver: Screensaver, color: string) { + if (!this.ctx) return + if (saver === 'matrix') { + this.matrix ??= createMatrixState() + drawMatrixFrame(this.ctx, this.matrix, color) + } else if (saver === 'dvd') { + this.dvd ??= createDvdState() + drawDvdFrame(this.ctx, this.dvd) + } + this.texture.needsUpdate = true + } + + /** Forgets saver state so the next activation starts fresh */ + resetSavers() { + this.matrix = null + this.dvd = null + } + + private matrix: MatrixState | null = null + private dvd: DvdState | null = null } /** * Hook that returns a CanvasTexture driven by terminal lines */ -export function useTerminalTexture(lines: string[], power: boolean) { +export function useTerminalTexture(lines: string[], power: boolean, screensaver: Screensaver = 'off') { const color = useSyncExternalStore(subscribeTerminalColor, readTerminalColor, () => '#39ff6e') const surface = useMemo(() => new ScreenSurface(), []) + const saverActive = power && screensaver !== 'off' const cursorVisibleRef = useRef(true) const linesRef = useRef(lines) const fontReadyRef = useRef(false) const redraw = useCallback(() => { + if (saverActive) return surface.paint({ lines: linesRef.current, color, cursorVisible: cursorVisibleRef.current, power }) - }, [surface, color, power]) + }, [surface, color, power, saverActive]) + + // Screensaver: ~30 fps animation loop that owns the canvas while active + useEffect(() => { + if (!saverActive) { + surface.resetSavers() + return + } + const timer = setInterval(() => surface.paintSaver(screensaver, color), 33) + return () => clearInterval(timer) + }, [saverActive, screensaver, color, surface]) useEffect(() => { linesRef.current = lines @@ -98,13 +150,13 @@ export function useTerminalTexture(lines: string[], power: boolean) { }, [redraw]) useEffect(() => { - if (!power) return + if (!power || saverActive) return const interval = setInterval(() => { cursorVisibleRef.current = !cursorVisibleRef.current redraw() }, 530) return () => clearInterval(interval) - }, [power, redraw]) + }, [power, saverActive, redraw]) return surface.texture } @@ -130,24 +182,31 @@ function RetroComputer({ screenTexture }: { screenTexture: THREE.Texture }) { return } -/** Orbit controls plus smooth fly-to for `scene cam …`, double-click reset and optional auto-spin */ +/** Orbit controls plus smooth fly-to for `scene cam …` / exhibits, double-click reset and optional auto-spin */ function CameraRig({ config }: { config: CameraConfig }) { const { camera, gl } = useThree() const controlsRef = useRef>(null) - const preset = useSceneStore((s) => s.cameraPreset) + const goal = useSceneStore((s) => s.cameraGoal) const nonce = useSceneStore((s) => s.cameraNonce) const spin = useSceneStore((s) => s.spin) + const walk = useSceneStore((s) => s.walk) const flight = useRef<{ position: THREE.Vector3; target: THREE.Vector3 } | null>(null) + // A new goal, or leaving walk mode, starts a flight useEffect(() => { - const { position, target } = CAMERA_PRESETS[preset] - flight.current = { position: new THREE.Vector3(...position), target: new THREE.Vector3(...target) } - }, [preset, nonce]) + if (walk) { + flight.current = null + return + } + flight.current = { position: new THREE.Vector3(...goal.position), target: new THREE.Vector3(...goal.target) } + }, [goal, nonce, walk]) useEffect(() => { const el = gl.domElement - const reset = () => sceneStore.setCamera('default') + const reset = () => { + if (!sceneStore.getState().walk) sceneStore.setCamera('default') + } // Dragging takes over from an in-progress flight const cancel = () => { flight.current = null @@ -164,14 +223,14 @@ function CameraRig({ config }: { config: CameraConfig }) { useFrame((_, delta) => { const controls = controlsRef.current - const goal = flight.current - if (!controls || !goal) return + const target = flight.current + if (!controls || !target || walk) return const k = 1 - Math.exp(-delta * 4.5) - camera.position.lerp(goal.position, k) - controls.target.lerp(goal.target, k) - if (camera.position.distanceTo(goal.position) < 0.002 && controls.target.distanceTo(goal.target) < 0.002) { - camera.position.copy(goal.position) - controls.target.copy(goal.target) + camera.position.lerp(target.position, k) + controls.target.lerp(target.target, k) + if (camera.position.distanceTo(target.position) < 0.002 && controls.target.distanceTo(target.target) < 0.002) { + camera.position.copy(target.position) + controls.target.copy(target.target) flight.current = null } }) @@ -179,14 +238,13 @@ function CameraRig({ config }: { config: CameraConfig }) { return ( + case 'sign': + return + case 'shelf': + return + case 'window': + return + case 'clock': + return + case 'duck': + case 'cat': + case 'lava': + // Desk residents live inside the FloatGroup so they can drift when gravity is off + return null + default: + return + } +} + +/** Loads the profile once and publishes the room layout to the store */ +function useRoomExhibits() { + const { profile } = useProfile() + const exhibits = useMemo(() => buildExhibits(profile), [profile]) + useEffect(() => { + sceneStore.setState({ exhibits }) + }, [exhibits]) + return exhibits +} + /** Scene content rendered inside Canvas */ function SceneContent({ screenTexture, @@ -225,15 +316,42 @@ function SceneContent({ const power = useSceneStore((s) => s.power) const lampOn = useSceneStore((s) => s.lampOn) const props = useSceneStore((s) => s.props) + const roomLights = useSceneStore((s) => s.roomLights) + const party = useSceneStore((s) => s.party) + const xray = useSceneStore((s) => s.xray) + const exhibits = useRoomExhibits() + const byId = (id: string) => exhibits.find((e) => e.id === id) + const duck = byId('duck') + const cat = byId('cat') + const lava = byId('lava') return ( <> - - - {props && } + + + + {props && ( + + + {duck && } + {cat && } + {lava && } + + )} + + {exhibits.map((exhibit) => ( + + ))} + + + + + + + {enableEffects && ( @@ -257,7 +375,8 @@ export default function TerminalScene({ buffer, enableEffects, cameraConfig: cus // Wait for client-side mount before rendering Canvas const mounted = useSyncExternalStore(subscribeNoop, () => true, () => false) const power = useSceneStore((s) => s.power) - const screenTexture = useTerminalTexture(buffer, power) + const screensaver = useSceneStore((s) => s.screensaver) + const screenTexture = useTerminalTexture(buffer, power, screensaver) const cameraConfig = { ...DEFAULT_CAMERA_CONFIG, ...customConfig } if (!mounted) { @@ -278,7 +397,7 @@ export default function TerminalScene({ buffer, enableEffects, cameraConfig: cus onCreated={handleCreated} > - + ) diff --git a/src/components/terminal/WalkControls.test.ts b/src/components/terminal/WalkControls.test.ts new file mode 100644 index 0000000..ce0a206 --- /dev/null +++ b/src/components/terminal/WalkControls.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import * as THREE from 'three' +import { clampToRoom, findNearExhibit } from './WalkControls' +import { ROOM, DESK_BOUNDS, buildExhibits } from './exhibits' + +describe('clampToRoom', () => { + it('keeps the player inside the walls and at eye height', () => { + const p = new THREE.Vector3(-10, 5, 10) + clampToRoom(p, new THREE.Vector3(0, 0, 3)) + expect(p.x).toBeGreaterThan(ROOM.minX) + expect(p.z).toBeLessThan(ROOM.maxZ) + expect(p.y).toBeCloseTo(ROOM.floorY + 1.55) + }) + + it('stops the player walking into the desk from the front', () => { + const previous = new THREE.Vector3(0, 0, DESK_BOUNDS.maxZ + 0.05) + const p = new THREE.Vector3(0, 0, DESK_BOUNDS.maxZ - 0.05) + clampToRoom(p, previous) + expect(p.z).toBe(previous.z) + }) + + it('lets the player slide along the desk edge', () => { + const previous = new THREE.Vector3(0.2, 0, DESK_BOUNDS.maxZ + 0.05) + const p = new THREE.Vector3(0.4, 0, DESK_BOUNDS.maxZ - 0.02) + clampToRoom(p, previous) + expect(p.x).toBe(0.4) + expect(p.z).toBe(previous.z) + }) +}) + +describe('findNearExhibit', () => { + const sign = buildExhibits(null).find((e) => e.id === 'sign')! + const exhibits = [sign] + + it('finds the exhibit the player is facing within reach', () => { + const position = new THREE.Vector3(sign.position[0], 0.7, sign.position[2] + 1.5) + expect(findNearExhibit(exhibits, position, new THREE.Vector3(0, 0, -1))).toBe('sign') + }) + + it('ignores exhibits behind the player', () => { + const position = new THREE.Vector3(sign.position[0], 0.7, sign.position[2] + 1.5) + expect(findNearExhibit(exhibits, position, new THREE.Vector3(0, 0, 1))).toBeNull() + }) + + it('ignores exhibits that are too far away', () => { + const position = new THREE.Vector3(sign.position[0], 0.7, sign.position[2] + 4) + expect(findNearExhibit(exhibits, position, new THREE.Vector3(0, 0, -1))).toBeNull() + }) +}) + +describe('findNearExhibit with desk residents', () => { + it('prefers the closer thing when two are in front', () => { + const exhibits = buildExhibits(null) + const sign = exhibits.find((e) => e.id === 'sign')! + const position = new THREE.Vector3(sign.position[0], 0.7, sign.position[2] + 1.5) + expect(findNearExhibit(exhibits, position, new THREE.Vector3(0, 0, -1))).toBe('duck') + }) +}) diff --git a/src/components/terminal/WalkControls.tsx b/src/components/terminal/WalkControls.tsx new file mode 100644 index 0000000..e1d9ce4 --- /dev/null +++ b/src/components/terminal/WalkControls.tsx @@ -0,0 +1,162 @@ +'use client' + +import React, { useEffect, useRef } from 'react' +import { PointerLockControls } from '@react-three/drei' +import { useFrame, useThree } from '@react-three/fiber' +import * as THREE from 'three' +import { DESK_BOUNDS, ROOM, exhibitNormal } from './exhibits' +import type { Exhibit } from './exhibits' +import { sceneStore, useSceneStore } from './sceneStore' +import { activateExhibit } from './exhibitActions' + +/** Eye height above the floor and how close you must be to an exhibit to inspect it */ +const EYE_Y = ROOM.floorY + 1.55 +const WALK_SPEED = 1.7 +const SPRINT = 1.8 +const REACH = 2.1 +const SPAWN = new THREE.Vector3(0, EYE_Y, 2.9) +const MARGIN = 0.25 + +const MOVE_KEYS: Record = { + KeyW: 'forward', + ArrowUp: 'forward', + KeyS: 'back', + ArrowDown: 'back', + KeyA: 'left', + ArrowLeft: 'left', + KeyD: 'right', + ArrowRight: 'right', +} + +type Moves = { forward: boolean; back: boolean; left: boolean; right: boolean; sprint: boolean } + +/** Keeps the player inside the room and out of the desk */ +export function clampToRoom(p: THREE.Vector3, previous: THREE.Vector3) { + p.x = THREE.MathUtils.clamp(p.x, ROOM.minX + MARGIN, ROOM.maxX - MARGIN) + p.z = THREE.MathUtils.clamp(p.z, ROOM.minZ + MARGIN, ROOM.maxZ - MARGIN) + const inDesk = p.x > DESK_BOUNDS.minX && p.x < DESK_BOUNDS.maxX && p.z > DESK_BOUNDS.minZ && p.z < DESK_BOUNDS.maxZ + if (inDesk) { + // Slide along whichever axis was not blocked + const xBlocked = previous.x > DESK_BOUNDS.minX && previous.x < DESK_BOUNDS.maxX + const zBlocked = previous.z > DESK_BOUNDS.minZ && previous.z < DESK_BOUNDS.maxZ + if (zBlocked) p.x = previous.x + if (xBlocked) p.z = previous.z + if (xBlocked && zBlocked) p.copy(previous) + } + p.y = EYE_Y +} + +/** Nearest exhibit within reach that the player is roughly facing, or null */ +export function findNearExhibit(exhibits: Exhibit[], position: THREE.Vector3, forward: THREE.Vector3): string | null { + let best: { id: string; score: number } | null = null + exhibits.forEach((e) => { + const dx = e.position[0] - position.x + const dz = e.position[2] - position.z + const dist = Math.hypot(dx, dz) + if (dist > REACH) return + const facing = (dx * forward.x + dz * forward.z) / (dist || 1) + const [nx, nz] = exhibitNormal(e) + // Must be in front of the exhibit's face and looking toward it + const inFront = (dx * nx + dz * nz) < 0.1 + if (facing < 0.55 || !inFront) return + const score = dist - facing * 0.5 + if (!best || score < best.score) best = { id: e.id, score } + }) + return best ? (best as { id: string }).id : null +} + +const IDLE: Moves = { forward: false, back: false, left: false, right: false, sprint: false } + +/** Capture-phase key handling so KeyPressProvider (window, bubble phase) never sees walking keys */ +function useWalkKeys(walk: boolean, moves: React.MutableRefObject) { + useEffect(() => { + if (!walk) return + const down = (e: KeyboardEvent) => { + const move = MOVE_KEYS[e.code] + if (move) moves.current[move] = true + if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') moves.current.sprint = true + if (e.code === 'KeyE' || e.code === 'Enter') { + const near = sceneStore.getState().nearExhibit + if (near) activateExhibit(near) + } + e.stopImmediatePropagation() + e.preventDefault() + } + const up = (e: KeyboardEvent) => { + const move = MOVE_KEYS[e.code] + if (move) moves.current[move] = false + if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') moves.current.sprint = false + e.stopImmediatePropagation() + } + window.addEventListener('keydown', down, true) + window.addEventListener('keyup', up, true) + return () => { + window.removeEventListener('keydown', down, true) + window.removeEventListener('keyup', up, true) + moves.current = { ...IDLE } + } + }, [walk, moves]) +} + +/** + * First-person controls: pointer lock for looking, WASD/arrows to move, Shift to sprint, E to inspect. + * Swallows keyboard events so the terminal does not type while you walk. + */ +export default function WalkControls() { + const walk = useSceneStore((s) => s.walk) + const { camera } = useThree() + const controls = useRef>(null) + const moves = useRef({ ...IDLE }) + const previous = useRef(new THREE.Vector3()) + const forward = useRef(new THREE.Vector3()) + const lastNear = useRef(null) + + // Enter: spawn by the door facing the desk and grab the pointer + useEffect(() => { + if (!walk) return + camera.position.copy(SPAWN) + camera.lookAt(0, 0.4, 0.2) + sceneStore.setState({ roomLights: true }) + const lock = controls.current + lock?.lock() + return () => { + lock?.unlock() + sceneStore.setState({ nearExhibit: null }) + } + }, [walk, camera]) + + useWalkKeys(walk, moves) + + useFrame((_, delta) => { + if (!walk) return + const m = moves.current + const speed = WALK_SPEED * (m.sprint ? SPRINT : 1) * Math.min(delta, 0.05) + previous.current.copy(camera.position) + camera.getWorldDirection(forward.current) + forward.current.y = 0 + forward.current.normalize() + const right = new THREE.Vector3().crossVectors(forward.current, camera.up).normalize() + if (m.forward) camera.position.addScaledVector(forward.current, speed) + if (m.back) camera.position.addScaledVector(forward.current, -speed) + if (m.right) camera.position.addScaledVector(right, speed) + if (m.left) camera.position.addScaledVector(right, -speed) + clampToRoom(camera.position, previous.current) + + const near = findNearExhibit(sceneStore.getState().exhibits, camera.position, forward.current) + if (near !== lastNear.current) { + lastNear.current = near + sceneStore.setState({ nearExhibit: near }) + } + }) + + return ( + { + if (sceneStore.getState().walk) sceneStore.setState({ walk: false, focusedExhibit: null }) + }} + /> + ) +} diff --git a/src/components/terminal/clockTexture.ts b/src/components/terminal/clockTexture.ts new file mode 100644 index 0000000..2b2c3f9 --- /dev/null +++ b/src/components/terminal/clockTexture.ts @@ -0,0 +1,82 @@ +import * as THREE from 'three' + +const MONO = '"JetBrains Mono", "Courier New", monospace' + +/** Hours, minutes and seconds in Reykjavík (UTC all year) */ +export function reykjavikTime(date: Date): { hours: number; minutes: number; seconds: number } { + return { hours: date.getUTCHours(), minutes: date.getUTCMinutes(), seconds: date.getUTCSeconds() } +} + +/** Analog clock face with RVK badge and a red second hand */ +export function paintClock(ctx: CanvasRenderingContext2D, w: number, h: number, date: Date) { + const cx = w / 2 + const cy = h / 2 + const r = w * 0.46 + const { hours, minutes, seconds } = reykjavikTime(date) + + ctx.clearRect(0, 0, w, h) + ctx.fillStyle = '#f3f1ea' + ctx.beginPath() + ctx.arc(cx, cy, r, 0, Math.PI * 2) + ctx.fill() + + for (let i = 0; i < 60; i++) { + const a = (i / 60) * Math.PI * 2 + const major = i % 5 === 0 + ctx.strokeStyle = major ? '#1f2937' : '#9ca3af' + ctx.lineWidth = major ? w * 0.014 : w * 0.005 + ctx.beginPath() + ctx.moveTo(cx + Math.sin(a) * r * (major ? 0.84 : 0.9), cy - Math.cos(a) * r * (major ? 0.84 : 0.9)) + ctx.lineTo(cx + Math.sin(a) * r * 0.95, cy - Math.cos(a) * r * 0.95) + ctx.stroke() + } + + ctx.fillStyle = '#22c55e' + ctx.font = `700 ${w * 0.07}px ${MONO}` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('RVK', cx, cy + r * 0.4) + ctx.fillStyle = '#6b7280' + ctx.font = `${w * 0.045}px ${MONO}` + ctx.fillText('UTC±0', cx, cy + r * 0.55) + + const hand = (angle: number, length: number, width: number, color: string) => { + ctx.strokeStyle = color + ctx.lineWidth = width + ctx.lineCap = 'round' + ctx.beginPath() + ctx.moveTo(cx - Math.sin(angle) * r * 0.12, cy + Math.cos(angle) * r * 0.12) + ctx.lineTo(cx + Math.sin(angle) * r * length, cy - Math.cos(angle) * r * length) + ctx.stroke() + } + hand(((hours % 12) + minutes / 60) * (Math.PI / 6), 0.5, w * 0.03, '#111827') + hand((minutes + seconds / 60) * (Math.PI / 30), 0.74, w * 0.02, '#111827') + hand(seconds * (Math.PI / 30), 0.8, w * 0.008, '#dc2626') + ctx.fillStyle = '#dc2626' + ctx.beginPath() + ctx.arc(cx, cy, w * 0.02, 0, Math.PI * 2) + ctx.fill() +} + +/** Canvas texture that can be repainted with the current time */ +export class ClockSurface { + readonly texture: THREE.CanvasTexture + private readonly ctx: CanvasRenderingContext2D + private readonly size: number + + constructor(size = 512) { + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + this.size = size + this.ctx = canvas.getContext('2d')! + this.texture = new THREE.CanvasTexture(canvas) + this.texture.colorSpace = THREE.SRGBColorSpace + this.tick(new Date()) + } + + tick(date: Date) { + paintClock(this.ctx, this.size, this.size, date) + this.texture.needsUpdate = true + } +} diff --git a/src/components/terminal/exhibitActions.ts b/src/components/terminal/exhibitActions.ts new file mode 100644 index 0000000..db73754 --- /dev/null +++ b/src/components/terminal/exhibitActions.ts @@ -0,0 +1,23 @@ +import { playFanfare, playMeow, playQuack } from '../../utils/audio' +import { sceneStore } from './sceneStore' + +/** Bumps that other components animate on (duck bounce, cat tail flick) */ +export const reactions = { duck: 0, cat: 0 } + +/** + * Opens an exhibit's card (and marks it discovered); living-room residents react with a sound. + * Shared by clicks in orbit mode, the E key in walk mode and `scene inspect`. + */ +export function activateExhibit(id: string) { + const before = sceneStore.getState().celebrateNonce + const sound = sceneStore.getState().sound + sceneStore.inspect(id) + if (id === 'duck') { + reactions.duck += 1 + if (sound) playQuack() + } else if (id === 'cat') { + reactions.cat += 1 + if (sound) playMeow() + } + if (sound && sceneStore.getState().celebrateNonce !== before) playFanfare() +} diff --git a/src/components/terminal/exhibits.test.ts b/src/components/terminal/exhibits.test.ts new file mode 100644 index 0000000..af610c8 --- /dev/null +++ b/src/components/terminal/exhibits.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest' +import { buildExhibits, duckTip, exhibitNormal, exhibitView, skillBooks, ROOM, DESK_BOUNDS } from './exhibits' +import type { Profile } from '@/types/profile' + +const profile: Profile = { + name: 'Tómas Ari Pálsson', + birthday: '2004-12-22', + role: { en: 'Software Engineer · AI & Cloud' }, + summary: { en: '' }, + contact: { + location: { en: 'Kópavogur, Iceland' }, + phone: '', + email: 'tomas@p5.is', + website: 'https://tomasari.is', + github: 'https://github.com/TomasPalsson', + linkedin: 'https://linkedin.com/in/x', + }, + experience: [ + { company: 'Apró', role: { en: 'Software Engineer' }, start: '2025-12', end: null, location: { en: 'Reykjavík' }, link: 'https://apro.is', bullets: { en: ['a'] } }, + { company: 'Hidden', role: { en: 'Nope' }, start: '2020', end: '2021', location: { en: '' }, bullets: { en: [] }, show: ['cv'] }, + ], + projects: [ + { name: { en: 'Næsta Skref' }, tagline: { en: 'BSc final project' }, stack: ['Python'], link: 'https://ai.naestaskref.is', bullets: { en: ['x'] } }, + { name: { en: 'Terminal Portfolio' }, stack: ['React'], github: 'https://github.com/x/y', bullets: { en: ['y'] } }, + ], + education: [{ school: { en: 'Reykjavík University' }, degree: { en: 'BSc Software Engineering' }, start: '2023', end: '2026' }], + certifications: [ + { name: 'Claude Certified Architect – Professional', issuer: 'Anthropic', issued: '2026-08', badge: '/pro.png', link: 'https://verify/1' }, + { name: 'Claude Certified Architect – Foundations', issuer: 'Anthropic', issued: '2026-07', badge: '/found.png', link: 'https://verify/2' }, + ], + skills: [ + { label: { en: 'Cloud' }, items: { en: 'AWS (Lambda, S3), Terraform' } }, + { label: { en: 'Spoken' }, items: { en: 'Icelandic (native), English (fluent, 7 years in the UK)' } }, + ], + references: { en: '' }, +} + +describe('skillBooks', () => { + it('expands bracketed lists of single words into their own books', () => { + expect(skillBooks('AWS (Lambda, S3), Terraform')).toEqual(['AWS', 'Terraform', 'Lambda', 'S3']) + }) + + it('drops brackets that are prose', () => { + expect(skillBooks('Icelandic (native), English (fluent, 7 years in the UK)')).toEqual(['Icelandic', 'English']) + }) +}) + +describe('buildExhibits', () => { + it('always includes the sign, window, duck, cat, clock and lava lamp', () => { + const ids = buildExhibits(null).map((e) => e.id) + expect(ids).toEqual(['sign', 'window', 'duck', 'cat', 'clock', 'lava']) + }) + + it('falls back to a default name without a profile', () => { + expect(buildExhibits(null).find((e) => e.id === 'sign')?.title).toBe('Tómas Ari Pálsson') + }) + + it('hangs the profile around the room', () => { + const exhibits = buildExhibits(profile) + const ids = exhibits.map((e) => e.id) + expect(ids).toContain('cert-1') + expect(ids).toContain('cert-2') + expect(ids).toContain('project-naesta-skref') + expect(ids).toContain('project-terminal-portfolio') + expect(ids).toContain('timeline') + expect(ids).toContain('skills') + expect(ids).toContain('contact') + }) + + it('respects show targets', () => { + const timeline = buildExhibits(profile).find((e) => e.id === 'timeline')! + expect(timeline.lines.join('\n')).not.toContain('Hidden') + expect(timeline.lines[0]).toBe('Dec 2025 - Present — Software Engineer @ Apró') + expect(timeline.links).toEqual([{ label: 'Apró', href: 'https://apro.is' }]) + }) + + it('links projects to their site or repo', () => { + const exhibits = buildExhibits(profile) + expect(exhibits.find((e) => e.id === 'project-naesta-skref')?.links).toEqual([{ label: 'Open project', href: 'https://ai.naestaskref.is' }]) + expect(exhibits.find((e) => e.id === 'project-terminal-portfolio')?.links).toEqual([{ label: 'GitHub', href: 'https://github.com/x/y' }]) + }) + + it('places posters on the left wall facing into the room and certs on the back wall', () => { + const exhibits = buildExhibits(profile) + const poster = exhibits.find((e) => e.id === 'project-naesta-skref')! + expect(poster.position[0]).toBeCloseTo(ROOM.minX, 1) + expect(exhibitNormal(poster)[0]).toBeCloseTo(1) + const cert = exhibits.find((e) => e.id === 'cert-1')! + expect(cert.position[2]).toBeCloseTo(ROOM.minZ, 1) + expect(cert.image).toBe('/pro.png') + }) + + it('keeps every exhibit inside the room and off the walkable desk area unless it sits on the desk', () => { + buildExhibits(profile).forEach((e) => { + expect(e.position[0]).toBeGreaterThanOrEqual(ROOM.minX) + expect(e.position[0]).toBeLessThanOrEqual(ROOM.maxX) + expect(e.position[2]).toBeGreaterThanOrEqual(ROOM.minZ) + expect(e.position[2]).toBeLessThanOrEqual(ROOM.maxZ) + }) + const duck = buildExhibits(profile).find((e) => e.id === 'duck')! + expect(duck.position[0]).toBeGreaterThan(DESK_BOUNDS.minX) + }) +}) + +describe('exhibitView', () => { + it('stands in front of the face, looking at it', () => { + const poster = buildExhibits(profile).find((e) => e.id === 'project-naesta-skref')! + const view = exhibitView(poster) + expect(view.position[0]).toBeGreaterThan(poster.position[0]) + expect(view.target).toEqual(poster.position) + }) + + it('looks down at desk residents', () => { + const duck = buildExhibits(null).find((e) => e.id === 'duck')! + expect(exhibitView(duck).position[1]).toBeGreaterThan(duck.position[1]) + }) +}) + +describe('duckTip', () => { + it('picks deterministically from the tip list', () => { + expect(duckTip(() => 0)).toContain('Explain the bug') + expect(duckTip(() => 0.999)).toContain('pair programmer') + }) +}) diff --git a/src/components/terminal/exhibits.ts b/src/components/terminal/exhibits.ts new file mode 100644 index 0000000..ae39da5 --- /dev/null +++ b/src/components/terminal/exhibits.ts @@ -0,0 +1,266 @@ +import type { Profile } from '@/types/profile' +import { formatMonth, formatPeriod, visible } from '@/services/profile' + +/** What kind of thing hangs in the room; drives both the 3D rendering and the info card */ +export type ExhibitKind = 'poster' | 'cert' | 'board' | 'shelf' | 'sign' | 'corkboard' | 'duck' | 'window' | 'cat' | 'clock' | 'lava' + +export type ExhibitLink = { label: string; href: string } + +export type Exhibit = { + id: string + kind: ExhibitKind + title: string + subtitle?: string + /** Body lines shown on the info card (and painted onto posters/boards) */ + lines: string[] + tags?: string[] + links?: ExhibitLink[] + /** Optional image for framed pictures (certificates) */ + image?: string + /** World position of the exhibit's centre */ + position: [number, number, number] + /** Yaw in radians; 0 faces +z (toward the player at the desk) */ + rotationY: number + /** Width and height of the face in world units */ + size: [number, number] +} + +/** Interior of the room; walls sit just outside these */ +export const ROOM = { + floorY: -0.86, + ceilingY: 2.1, + minX: -3.2, + maxX: 3.2, + minZ: -0.8, + maxZ: 4.4, +} as const + +/** Footprint of the desk (with a little margin) that the player cannot walk into */ +export const DESK_BOUNDS = { minX: -1.4, maxX: 1.4, minZ: -0.75, maxZ: 1.05 } as const + +const WALL_GAP = 0.012 +const LEFT_X = ROOM.minX + WALL_GAP +const RIGHT_X = ROOM.maxX - WALL_GAP +const BACK_Z = ROOM.minZ + WALL_GAP + +const DUCK_TIPS = [ + 'Explain the bug to me, line by line. I will not interrupt.', + 'Have you tried turning the terminal off and on again? Type: scene power off', + 'Quack. That is duck for "add a test first".', + 'The room is full of things about Tómas — walk up to them and press E.', + 'Type "scene cam wide" to see the whole desk.', + 'Rubber ducks are the original AI pair programmer.', +] + +/** A random tip from the debugging duck */ +export const duckTip = (random = Math.random) => DUCK_TIPS[Math.floor(random() * DUCK_TIPS.length)] + +/** Things that need no profile data */ +const staticExhibits = (name: string, role: string): Exhibit[] => [ + { + id: 'sign', + kind: 'sign', + title: name, + subtitle: role, + lines: ['Welcome to the room. Walk around (Explore) or type scene look.'], + position: [0, 1.45, BACK_Z], + rotationY: 0, + size: [2.4, 0.5], + }, + { + id: 'window', + kind: 'window', + title: 'Kópavogur, Iceland', + subtitle: 'The view from the office', + lines: ['Aurora most winter nights. Wind every night.'], + position: [2.3, 1.05, BACK_Z], + rotationY: 0, + size: [1.1, 0.9], + }, + { + id: 'duck', + kind: 'duck', + title: 'Debugging duck', + subtitle: 'Senior rubber consultant', + lines: [], + position: [-0.34, -0.062, 0.32], + rotationY: 0.4, + size: [0.1, 0.1], + }, + { + id: 'cat', + kind: 'cat', + title: 'Kisa', + subtitle: 'Office cat · Head of Naps', + lines: ['Sleeps 22 hours a day. Reviews pull requests the other two.', 'Approves anything with fish in the commit message.'], + position: [-1.0, -0.062, 0.55], + rotationY: -0.6, + size: [0.3, 0.15], + }, + { + id: 'clock', + kind: 'clock', + title: 'Reykjavík time', + subtitle: 'UTC all year — Iceland skips daylight saving', + lines: ['Runs on real time. Deadlines run on it too.'], + position: [-2.3, 1.3, BACK_Z], + rotationY: 0, + size: [0.42, 0.42], + }, + { + id: 'lava', + kind: 'lava', + title: 'Lava lamp', + subtitle: 'Est. 1996 · still compiling', + lines: ['Thinking indicator for the whole desk. Wax rises when the build passes.'], + position: [0.98, -0.062, -0.1], + rotationY: 0, + size: [0.12, 0.36], + }, +] + +/** "Næsta Skref" → "project-naesta-skref": Icelandic letters get ASCII stand-ins before the slug */ +const projectId = (name: string) => + 'project-' + + name + .toLowerCase() + .replace(/æ/g, 'ae') + .replace(/ð/g, 'd') + .replace(/þ/g, 'th') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + +const certExhibits = (profile: Profile): Exhibit[] => + visible(profile.certifications).slice(0, 2).map((cert, i) => ({ + id: `cert-${i + 1}`, + kind: 'cert', + title: cert.name, + subtitle: `${cert.issuer} · ${formatMonth(cert.issued)}`, + lines: cert.description ? [cert.description.en] : [], + links: [{ label: 'Verify credential', href: cert.link }], + image: cert.badge, + position: [i === 0 ? -1.25 : 1.25, 0.85, BACK_Z], + rotationY: 0, + size: [0.55, 0.55], + })) + +const projectExhibits = (profile: Profile): Exhibit[] => + visible(profile.projects).slice(0, 4).map((project, i) => { + const links: ExhibitLink[] = [] + if (project.link) links.push({ label: 'Open project', href: project.link }) + if (project.github) links.push({ label: 'GitHub', href: project.github }) + return { + id: projectId(project.name.en), + kind: 'poster', + title: project.name.en, + subtitle: project.tagline?.en, + lines: project.bullets.en, + tags: project.stack, + links, + position: [LEFT_X, 0.95, 0.35 + i * 0.95], + rotationY: Math.PI / 2, + size: [0.7, 0.95], + } + }) + +const timelineExhibit = (profile: Profile): Exhibit => { + const experience = visible(profile.experience) + const education = visible(profile.education) + return { + id: 'timeline', + kind: 'board', + title: 'Experience & education', + subtitle: profile.contact.location.en, + lines: [ + ...experience.map((e) => `${formatPeriod(e.start, e.end)} — ${e.role.en} @ ${e.company}`), + ...education.map((e) => `${formatPeriod(e.start, e.end)} — ${e.degree.en}, ${e.school.en}`), + ], + links: experience.filter((e) => e.link).map((e) => ({ label: e.company, href: e.link! })), + position: [RIGHT_X, 1.0, 1.0], + rotationY: -Math.PI / 2, + size: [1.7, 1.05], + } +} + +/** + * Splits "AWS (Lambda, S3), Terraform, Icelandic (native)" into book titles: + * bracketed lists of single words become their own books, other brackets are dropped. + */ +export function skillBooks(items: string): string[] { + const out: string[] = [] + const withExtras = items.replace(/\s*\(([^)]*)\)/g, (_, inner: string) => { + const parts = inner.split(/,\s*/).map((p) => p.trim()).filter(Boolean) + if (parts.length > 1 && parts.every((p) => !p.includes(' '))) out.push(...parts) + return '' + }) + return [...withExtras.split(/,\s*/).map((p) => p.trim()).filter(Boolean), ...out] +} + +const skillsExhibit = (profile: Profile): Exhibit => { + const skills = visible(profile.skills) + return { + id: 'skills', + kind: 'shelf', + title: 'Skills shelf', + subtitle: 'Every book is something I use', + lines: skills.map((s) => `${s.label.en}: ${s.items.en}`), + tags: skills.flatMap((s) => skillBooks(s.items.en)), + position: [RIGHT_X - 0.14, ROOM.floorY + 0.86, 3.0], + rotationY: -Math.PI / 2, + size: [1.0, 1.72], + } +} + +const contactExhibit = ({ contact }: Profile): Exhibit => ({ + id: 'contact', + kind: 'corkboard', + title: 'Get in touch', + subtitle: contact.location.en, + lines: [contact.email, contact.website.replace(/^https?:\/\//, '')], + links: [ + { label: 'GitHub', href: contact.github }, + { label: 'LinkedIn', href: contact.linkedin }, + { label: 'Email', href: `mailto:${contact.email}` }, + { label: 'Website', href: contact.website }, + ], + position: [LEFT_X, 1.0, 4.0], + rotationY: Math.PI / 2, + size: [0.8, 0.65], +}) + +/** + * Lays the profile out around the room: + * - name sign, window and duck (always) + * - certificates flanking the monitor on the back wall + * - project posters along the left wall + * - experience/education whiteboard and the skills shelf on the right wall + * - contact corkboard by the door + */ +export function buildExhibits(profile: Profile | null): Exhibit[] { + const base = staticExhibits(profile?.name ?? 'Tómas Ari Pálsson', profile?.role.en ?? 'Software Engineer') + if (!profile) return base + return [ + ...base, + ...certExhibits(profile), + ...projectExhibits(profile), + timelineExhibit(profile), + skillsExhibit(profile), + contactExhibit(profile), + ] +} + +/** Unit normal the exhibit faces (where a viewer should stand) */ +export const exhibitNormal = (e: Exhibit): [number, number] => [Math.sin(e.rotationY), Math.cos(e.rotationY)] + +/** Camera position + target that frames the exhibit from the front */ +export function exhibitView(e: Exhibit): { position: [number, number, number]; target: [number, number, number] } { + const [nx, nz] = exhibitNormal(e) + const distance = Math.min(2.2, Math.max(0.7, Math.max(...e.size) * 1.4)) + const eyeY = e.kind === 'duck' || e.kind === 'cat' || e.kind === 'lava' ? e.position[1] + 0.3 : e.position[1] + 0.05 + return { + position: [e.position[0] + nx * distance, eyeY, e.position[2] + nz * distance], + target: [e.position[0], e.position[1], e.position[2]], + } +} diff --git a/src/components/terminal/roomTextures.test.ts b/src/components/terminal/roomTextures.test.ts new file mode 100644 index 0000000..6166ff0 --- /dev/null +++ b/src/components/terminal/roomTextures.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi } from 'vitest' +import { wrapText, paintPoster, paintBoard, paintSign, paintCorkboard, paintSpine } from './roomTextures' +import { paintClock, reykjavikTime } from './clockTexture' +import type { Exhibit } from './exhibits' + +/** 2D context stub with a monospace-ish measureText; records painted text */ +const makeCtx = () => { + const ctx = { + fillStyle: '' as string | object, + strokeStyle: '', + lineWidth: 1, + lineCap: 'butt', + font: '', + textAlign: 'left', + textBaseline: 'alphabetic', + shadowColor: '', + shadowBlur: 0, + texts: [] as string[], + measureText: vi.fn((text: string) => ({ width: text.length * 8 })), + fillRect: vi.fn(), + strokeRect: vi.fn(), + clearRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + arc: vi.fn(), + roundRect: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + translate: vi.fn(), + rotate: vi.fn(), + fillText(text: string) { + this.texts.push(text) + }, + } + return ctx as unknown as CanvasRenderingContext2D & typeof ctx +} + +const exhibit: Exhibit = { + id: 'project-x', + kind: 'poster', + title: 'Terminal Portfolio', + subtitle: 'tomasari.is', + lines: ['Personal site with a 3D terminal and a chat agent.'], + tags: ['React', 'Three.js'], + links: [ + { label: 'GitHub', href: 'https://github.com/x/y' }, + { label: 'Email', href: 'mailto:me@example.com' }, + ], + position: [0, 0, 0], + rotationY: 0, + size: [0.7, 0.95], +} + +describe('wrapText', () => { + it('wraps on word boundaries by measured width', () => { + const ctx = makeCtx() + expect(wrapText(ctx, 'one two three four', 80)).toEqual(['one two', 'three four']) + }) + + it('keeps an over-long single word on its own line', () => { + const ctx = makeCtx() + expect(wrapText(ctx, 'supercalifragilistic ok', 40)).toEqual(['supercalifragilistic', 'ok']) + }) +}) + +describe('painters', () => { + it('poster paints title, subtitle, bullets and tags', () => { + const ctx = makeCtx() + paintPoster(exhibit)(ctx, 512, 695) + expect(ctx.texts).toContain('TERMINAL PORTFOLIO') + expect(ctx.texts).toContain('tomasari.is') + expect(ctx.texts).toContain('React') + expect(ctx.texts).toContain('Three.js') + expect(ctx.texts.some((t) => t.startsWith('Personal site'))).toBe(true) + }) + + it('board paints each timeline entry as date + description', () => { + const ctx = makeCtx() + paintBoard({ ...exhibit, kind: 'board', lines: ['2023 - 2026 — BSc, RU', 'Dec 2025 - Present — Engineer @ Apró'] })(ctx, 768, 474) + expect(ctx.texts).toContain('2023 - 2026') + expect(ctx.texts).toContain('Dec 2025 - Present') + expect(ctx.texts.some((t) => t.includes('Engineer @ Apró'))).toBe(true) + }) + + it('sign paints the name and role', () => { + const ctx = makeCtx() + paintSign({ ...exhibit, kind: 'sign', title: 'Tómas', subtitle: 'Engineer' })(ctx, 1536, 320) + expect(ctx.texts).toEqual(['Tómas', 'Engineer']) + }) + + it('corkboard paints a card per link without the scheme', () => { + const ctx = makeCtx() + paintCorkboard({ ...exhibit, kind: 'corkboard' })(ctx, 768, 624) + expect(ctx.texts).toContain('GitHub') + expect(ctx.texts).toContain('github.com/x/y') + expect(ctx.texts).toContain('me@example.com') + }) + + it('spine paints the label, shortening it to fit', () => { + const ctx = makeCtx() + paintSpine('TypeScript', '#333')(ctx, 64, 384) + expect(ctx.texts).toEqual(['TypeScript']) + const long = makeCtx() + paintSpine('Claude API & Agent SDK and quite a few more words than fit', '#333')(long, 64, 384) + expect(long.texts[0].endsWith('…')).toBe(true) + }) +}) + +describe('clock', () => { + it('reads Reykjavík time as UTC', () => { + expect(reykjavikTime(new Date('2026-07-01T13:45:10Z'))).toEqual({ hours: 13, minutes: 45, seconds: 10 }) + }) + + it('paints the face with its badge', () => { + const ctx = makeCtx() + paintClock(ctx, 512, 512, new Date('2026-07-01T13:45:10Z')) + expect(ctx.texts).toEqual(['RVK', 'UTC±0']) + expect(ctx.arc).toHaveBeenCalled() + }) +}) diff --git a/src/components/terminal/roomTextures.ts b/src/components/terminal/roomTextures.ts new file mode 100644 index 0000000..a6b41de --- /dev/null +++ b/src/components/terminal/roomTextures.ts @@ -0,0 +1,276 @@ +import * as THREE from 'three' +import type { Exhibit } from './exhibits' + +export type Painter = (ctx: CanvasRenderingContext2D, width: number, height: number) => void + +const MONO = '"JetBrains Mono", "Courier New", monospace' +const HAND = '"Winky Sans", "Comic Sans MS", cursive' +const GREEN = '#22c55e' + +/** Wraps `text` to `maxWidth` using the context's current font */ +export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { + const words = text.split(' ') + const lines: string[] = [] + let line = '' + words.forEach((word) => { + const test = line ? `${line} ${word}` : word + if (ctx.measureText(test).width > maxWidth && line) { + lines.push(line) + line = word + } else { + line = test + } + }) + if (line) lines.push(line) + return lines +} + +/** Draws wrapped lines from (x, y) and returns the next free y */ +function paragraph(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number, lineHeight: number, maxLines = Infinity) { + const lines = wrapText(ctx, text, maxWidth).slice(0, maxLines) + lines.forEach((line, i) => ctx.fillText(line, x, y + i * lineHeight)) + return y + lines.length * lineHeight +} + +/** Rounded pill with a label; returns the pill's right edge */ +function pill(ctx: CanvasRenderingContext2D, label: string, x: number, y: number, height: number) { + const padX = height * 0.5 + const width = ctx.measureText(label).width + padX * 2 + ctx.beginPath() + ctx.roundRect(x, y, width, height, height / 2) + ctx.fillStyle = 'rgba(34, 197, 94, 0.14)' + ctx.fill() + ctx.strokeStyle = 'rgba(34, 197, 94, 0.5)' + ctx.lineWidth = 2 + ctx.stroke() + ctx.fillStyle = GREEN + ctx.fillText(label, x + padX, y + height * 0.68) + return x + width +} + +/** Terminal-styled project poster: title, tagline, bullets, stack pills */ +export const paintPoster = + (exhibit: Exhibit): Painter => + (ctx, w, h) => { + ctx.fillStyle = '#0a0d0b' + ctx.fillRect(0, 0, w, h) + ctx.strokeStyle = 'rgba(34, 197, 94, 0.35)' + ctx.lineWidth = 6 + ctx.strokeRect(14, 14, w - 28, h - 28) + + const pad = w * 0.08 + ctx.textBaseline = 'alphabetic' + ctx.fillStyle = GREEN + ctx.font = `700 ${w * 0.075}px ${MONO}` + let y = paragraph(ctx, exhibit.title.toUpperCase(), pad, pad + w * 0.075, w - pad * 2, w * 0.09, 2) + + if (exhibit.subtitle) { + ctx.fillStyle = '#9ca3af' + ctx.font = `${w * 0.042}px ${MONO}` + y = paragraph(ctx, exhibit.subtitle, pad, y + w * 0.01, w - pad * 2, w * 0.055, 2) + } + + ctx.strokeStyle = 'rgba(34, 197, 94, 0.3)' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(pad, y + w * 0.02) + ctx.lineTo(w - pad, y + w * 0.02) + ctx.stroke() + y += w * 0.09 + + ctx.fillStyle = '#d1d5db' + ctx.font = `${w * 0.04}px ${MONO}` + exhibit.lines.forEach((line) => { + if (y > h - w * 0.3) return + ctx.fillStyle = GREEN + ctx.fillText('›', pad, y) + ctx.fillStyle = '#d1d5db' + y = paragraph(ctx, line, pad + w * 0.05, y, w - pad * 2 - w * 0.05, w * 0.052, 4) + w * 0.03 + }) + + if (exhibit.tags?.length) { + ctx.font = `${w * 0.036}px ${MONO}` + let x = pad + const py = h - pad - w * 0.075 + exhibit.tags.forEach((tag) => { + const width = ctx.measureText(tag).width + w * 0.075 + if (x + width > w - pad) return + x = pill(ctx, tag, x, py, w * 0.075) + w * 0.02 + }) + } + } + +/** Whiteboard with a hand-drawn timeline of jobs and schools */ +export const paintBoard = + (exhibit: Exhibit): Painter => + (ctx, w, h) => { + ctx.fillStyle = '#e8ebe6' + ctx.fillRect(0, 0, w, h) + ctx.strokeStyle = '#b8bcb5' + ctx.lineWidth = 10 + ctx.strokeRect(5, 5, w - 10, h - 10) + + const pad = w * 0.06 + ctx.textBaseline = 'alphabetic' + ctx.fillStyle = '#1f2937' + ctx.font = `700 ${w * 0.055}px ${HAND}` + ctx.fillText(exhibit.title, pad, pad + w * 0.05) + ctx.strokeStyle = '#2563eb' + ctx.lineWidth = 5 + ctx.beginPath() + ctx.moveTo(pad, pad + w * 0.075) + ctx.lineTo(pad + ctx.measureText(exhibit.title).width, pad + w * 0.08) + ctx.stroke() + + // Timeline spine with a dot per entry + const x0 = pad + w * 0.03 + let y = pad + w * 0.15 + ctx.font = `${w * 0.03}px ${HAND}` + exhibit.lines.forEach((line, i) => { + if (y > h - pad) return + const [when, what] = line.split(' — ') + ctx.fillStyle = i % 2 ? '#dc2626' : '#2563eb' + ctx.beginPath() + ctx.arc(x0, y - w * 0.01, w * 0.011, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#4b5563' + ctx.fillText(when, x0 + w * 0.04, y) + ctx.fillStyle = '#111827' + y = paragraph(ctx, what ?? '', x0 + w * 0.04, y + w * 0.038, w - x0 - pad - w * 0.04, w * 0.038, 2) + w * 0.022 + }) + ctx.strokeStyle = '#9ca3af' + ctx.lineWidth = 3 + ctx.beginPath() + ctx.moveTo(x0, pad + w * 0.13) + ctx.lineTo(x0, Math.min(y, h - pad) - w * 0.02) + ctx.stroke() + } + +/** Glowing neon name sign; painted bright so bloom picks it up */ +export const paintSign = + (exhibit: Exhibit): Painter => + (ctx, w, h) => { + ctx.clearRect(0, 0, w, h) + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.shadowColor = '#4ade80' + ctx.shadowBlur = w * 0.02 + ctx.fillStyle = '#c8ffd9' + ctx.font = `700 ${h * 0.4}px ${MONO}` + ctx.fillText(exhibit.title, w / 2, h * 0.38) + ctx.shadowBlur = w * 0.012 + ctx.fillStyle = '#86efac' + ctx.font = `${h * 0.2}px ${MONO}` + ctx.fillText(exhibit.subtitle ?? '', w / 2, h * 0.76) + } + +const CARD_COLORS = ['#fde68a', '#fca5a5', '#a5f3fc', '#bbf7d0'] + +/** Corkboard with a pinned card per contact link */ +export const paintCorkboard = + (exhibit: Exhibit): Painter => + (ctx, w, h) => { + ctx.fillStyle = '#8b5e3c' + ctx.fillRect(0, 0, w, h) + for (let i = 0; i < 400; i++) { + ctx.fillStyle = i % 2 ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.05)' + ctx.fillRect((i * 97) % w, (i * 57) % h, 3, 3) + } + ctx.strokeStyle = '#3f2a1a' + ctx.lineWidth = 14 + ctx.strokeRect(7, 7, w - 14, h - 14) + + ctx.textBaseline = 'alphabetic' + const links = exhibit.links ?? [] + const cardW = w * 0.36 + const cardH = h * 0.32 + links.slice(0, 4).forEach((link, i) => { + const x = w * 0.1 + (i % 2) * (w * 0.44) + const y = h * 0.12 + Math.floor(i / 2) * (h * 0.42) + ctx.save() + ctx.translate(x + cardW / 2, y + cardH / 2) + ctx.rotate(((i * 37) % 7 - 3) * 0.03) + ctx.fillStyle = CARD_COLORS[i % CARD_COLORS.length] + ctx.fillRect(-cardW / 2, -cardH / 2, cardW, cardH) + ctx.fillStyle = '#dc2626' + ctx.beginPath() + ctx.arc(0, -cardH / 2 + h * 0.03, h * 0.02, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#1f2937' + ctx.font = `700 ${h * 0.075}px ${HAND}` + ctx.textAlign = 'center' + ctx.fillText(link.label, 0, h * 0.02) + ctx.font = `${h * 0.045}px ${MONO}` + ctx.fillStyle = '#374151' + const shown = link.href.replace(/^(https?:\/\/|mailto:)/, '').replace(/\/$/, '') + ctx.fillText(shown.length > 26 ? shown.slice(0, 24) + '…' : shown, 0, h * 0.1) + ctx.restore() + }) + } + +/** Book spine: vertical title on a coloured cloth cover */ +export const paintSpine = + (label: string, color: string): Painter => + (ctx, w, h) => { + ctx.fillStyle = color + ctx.fillRect(0, 0, w, h) + ctx.fillStyle = 'rgba(0,0,0,0.25)' + ctx.fillRect(0, 0, w, h * 0.03) + ctx.fillRect(0, h * 0.97, w, h * 0.03) + ctx.save() + ctx.translate(w / 2, h / 2) + ctx.rotate(-Math.PI / 2) + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillStyle = '#f5f5f4' + ctx.font = `700 ${w * 0.5}px ${MONO}` + let text = label + while (ctx.measureText(text).width > h * 0.86 && text.length > 3) text = text.slice(0, -2) + '…' + ctx.fillText(text, 0, 0) + ctx.restore() + } + +/** Canvas-backed texture that can be repainted (e.g. once web fonts arrive) */ +export class PaintedSurface { + readonly texture: THREE.CanvasTexture + private readonly ctx: CanvasRenderingContext2D + private readonly width: number + private readonly height: number + + constructor(width: number, height: number) { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + this.width = width + this.height = height + this.ctx = canvas.getContext('2d')! + this.texture = new THREE.CanvasTexture(canvas) + this.texture.colorSpace = THREE.SRGBColorSpace + this.texture.anisotropy = 4 + } + + paint(painter: Painter) { + painter(this.ctx, this.width, this.height) + this.texture.needsUpdate = true + } +} + +/** Paints onto a fresh canvas and wraps it as an sRGB three texture */ +export function makeCanvasTexture(width: number, height: number, paint: Painter): THREE.CanvasTexture { + const surface = new PaintedSurface(width, height) + surface.paint(paint) + return surface.texture +} + +/** Calls `onReady` when the document's web fonts have finished loading; returns an unsubscribe */ +export function whenFontsReady(onReady: () => void): () => void { + let live = true + if (typeof document !== 'undefined' && 'fonts' in document) { + document.fonts.ready.then(() => { + if (live) onReady() + }) + } + return () => { + live = false + } +} diff --git a/src/components/terminal/sceneStore.test.ts b/src/components/terminal/sceneStore.test.ts index 7eab205..9829651 100644 --- a/src/components/terminal/sceneStore.test.ts +++ b/src/components/terminal/sceneStore.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' -import { sceneStore, useSceneStore, DEFAULT_SCENE_STATE, CAMERA_PRESET_NAMES } from './sceneStore' +import { sceneStore, useSceneStore, DEFAULT_SCENE_STATE, CAMERA_PRESET_NAMES, type SceneState } from './sceneStore' describe('sceneStore', () => { beforeEach(() => { @@ -28,6 +28,7 @@ describe('sceneStore', () => { effects: true, sound: true, props: true, + discovered: [], }) }) @@ -79,6 +80,59 @@ describe('sceneStore', () => { expect(CAMERA_PRESET_NAMES).toEqual(['default', 'close', 'side', 'top', 'wide']) }) + it('flyTo frames a custom view and clears the preset', () => { + sceneStore.flyTo({ position: [1, 1, 1], target: [0, 0, 0] }) + const state = sceneStore.getState() + expect(state.cameraPreset).toBeNull() + expect(state.cameraGoal.position).toEqual([1, 1, 1]) + expect(state.walk).toBe(false) + }) + + it('setCamera leaves walk mode', () => { + sceneStore.setState({ walk: true }) + sceneStore.setCamera('close') + expect(sceneStore.getState().walk).toBe(false) + }) + + it('inspect opens the card, records discovery and persists it', () => { + sceneStore.setState({ exhibits: [{ id: 'a' }, { id: 'b' }] as SceneState['exhibits'] }) + sceneStore.inspect('a') + expect(sceneStore.getState().focusedExhibit).toBe('a') + expect(sceneStore.getState().discovered).toEqual(['a']) + sceneStore.inspect('a') + expect(sceneStore.getState().discovered).toEqual(['a']) + expect(JSON.parse(localStorage.getItem('terminal3d-scene')!).discovered).toEqual(['a']) + sceneStore.inspect(null) + expect(sceneStore.getState().focusedExhibit).toBeNull() + }) + + it('celebrates exactly once when the last exhibit is found', () => { + sceneStore.setState({ exhibits: [{ id: 'a' }, { id: 'b' }] as SceneState['exhibits'] }) + sceneStore.inspect('a') + expect(sceneStore.getState().celebrateNonce).toBe(0) + sceneStore.inspect('b') + expect(sceneStore.getState().celebrateNonce).toBe(1) + sceneStore.inspect('b') + expect(sceneStore.getState().celebrateNonce).toBe(1) + }) + + it('hydrates the discovered list and ignores junk in it', () => { + localStorage.setItem('terminal3d-scene', JSON.stringify({ discovered: ['x', 3, null] })) + sceneStore.resetForTests() + expect(sceneStore.getState().discovered).toEqual(['x']) + }) + + it('reset keeps exhibits and discoveries', () => { + sceneStore.setState({ exhibits: [{ id: 'a' }] as SceneState['exhibits'], party: true, walk: true }) + sceneStore.inspect('a') + sceneStore.reset() + const state = sceneStore.getState() + expect(state.party).toBe(false) + expect(state.walk).toBe(false) + expect(state.exhibits).toHaveLength(1) + expect(state.discovered).toEqual(['a']) + }) + it('useSceneStore re-renders on the selected slice', () => { const { result } = renderHook(() => useSceneStore((s) => s.lampOn)) expect(result.current).toBe(true) diff --git a/src/components/terminal/sceneStore.ts b/src/components/terminal/sceneStore.ts index aa7cfbd..2233c1c 100644 --- a/src/components/terminal/sceneStore.ts +++ b/src/components/terminal/sceneStore.ts @@ -1,4 +1,5 @@ import { useSyncExternalStore } from 'react' +import type { Exhibit } from './exhibits' /** Named camera views; each is a position + look-at target in world units */ export type CameraPreset = 'default' | 'close' | 'side' | 'top' | 'wide' @@ -13,10 +14,38 @@ export const CAMERA_PRESETS: Record void +export type SceneToggle = 'power' | 'lampOn' | 'effects' | 'sound' | 'spin' | 'props' | 'walk' | 'roomLights' | 'party' | 'gravity' | 'xray' + let state: SceneState = DEFAULT_SCENE_STATE let hydrated = false const listeners = new Set() @@ -64,6 +108,9 @@ const readPersisted = (): Partial => { PERSISTED_KEYS.forEach((key) => { if (typeof parsed[key] === 'boolean') out[key] = parsed[key] }) + PERSISTED_LISTS.forEach((key) => { + if (Array.isArray(parsed[key])) out[key] = parsed[key].filter((v): v is string => typeof v === 'string') + }) return out } const legacy = localStorage.getItem(LEGACY_PERFORMANCE_KEY) @@ -80,6 +127,9 @@ const writePersisted = () => { PERSISTED_KEYS.forEach((key) => { out[key] = state[key] }) + PERSISTED_LISTS.forEach((key) => { + out[key] = state[key] + }) localStorage.setItem(STORAGE_KEY, JSON.stringify(out)) } catch { // Ignore storage errors @@ -104,7 +154,7 @@ export const sceneStore = { const changed = (Object.keys(partial) as (keyof SceneState)[]).some((key) => next[key] !== state[key]) if (!changed) return state = next - if (PERSISTED_KEYS.some((key) => key in partial)) writePersisted() + if ([...PERSISTED_KEYS, ...PERSISTED_LISTS].some((key) => key in partial)) writePersisted() listeners.forEach((listener) => listener()) }, subscribe(listener: Listener) { @@ -115,13 +165,42 @@ export const sceneStore = { }, /** Flies the camera to a preset, even if it is already selected */ setCamera(preset: CameraPreset) { - sceneStore.setState({ cameraPreset: preset, cameraNonce: state.cameraNonce + 1 }) + sceneStore.setState({ cameraPreset: preset, cameraGoal: CAMERA_PRESETS[preset], cameraNonce: state.cameraNonce + 1, walk: false }) }, - toggle(key: 'power' | 'lampOn' | 'effects' | 'sound' | 'spin' | 'props') { + /** Flies the camera to an arbitrary view (used to frame exhibits) */ + flyTo(goal: CameraGoal) { + sceneStore.setState({ cameraPreset: null, cameraGoal: goal, cameraNonce: state.cameraNonce + 1, walk: false }) + }, + toggle(key: SceneToggle) { sceneStore.setState({ [key]: !sceneStore.getState()[key] }) }, + /** Opens an exhibit's card and records it as discovered; fires confetti when the last one is found */ + inspect(id: string | null) { + if (!id) { + sceneStore.setState({ focusedExhibit: null }) + return + } + const current = sceneStore.getState() + const discovered = current.discovered.includes(id) ? current.discovered : [...current.discovered, id] + const total = current.exhibits.length + const completed = total > 0 && discovered.length >= total && current.discovered.length < total + sceneStore.setState({ + focusedExhibit: id, + discovered, + celebrateNonce: completed ? current.celebrateNonce + 1 : current.celebrateNonce, + }) + }, + celebrate() { + sceneStore.setState({ celebrateNonce: state.celebrateNonce + 1 }) + }, reset() { - sceneStore.setState({ ...DEFAULT_SCENE_STATE, cameraNonce: state.cameraNonce + 1, fps: state.fps }) + sceneStore.setState({ + ...DEFAULT_SCENE_STATE, + cameraNonce: state.cameraNonce + 1, + fps: state.fps, + exhibits: state.exhibits, + discovered: state.discovered, + }) }, /** Test hook: drops state and persistence back to defaults */ resetForTests() { diff --git a/src/components/terminal/screenTexture.test.ts b/src/components/terminal/screenTexture.test.ts index 2b8eb69..93578d0 100644 --- a/src/components/terminal/screenTexture.test.ts +++ b/src/components/terminal/screenTexture.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -import { wrapLines, drawScreen, phosphorColor, SCREEN, SCREEN_BG_ON, SCREEN_BG_OFF } from './screenTexture' +import { wrapLines, drawScreen, phosphorColor, SCREEN, SCREEN_BG_ON, SCREEN_BG_OFF, createMatrixState, drawMatrixFrame, createDvdState, drawDvdFrame } from './screenTexture' /** Minimal 2D context stub that records what was painted */ const makeCtx = (charWidth = 10) => { @@ -81,3 +81,30 @@ describe('drawScreen', () => { expect(ctx.texts).toHaveLength(0) }) }) + +describe('screensavers', () => { + it('matrix rain advances every column and recycles heads that fall off', () => { + const ctx = makeCtx() + const state = createMatrixState(() => 0.5) + const before = [...state.heads] + drawMatrixFrame(ctx, state, '#39ff6e', () => 0.5) + expect(state.started).toBe(true) + state.heads.forEach((h, i) => expect(h).toBeGreaterThan(before[i])) + expect(ctx.fillRects[0].style).toBe('#000') + state.heads[0] = 10_000 + drawMatrixFrame(ctx, state, '#39ff6e', () => 0.5) + expect(state.heads[0]).toBeLessThan(0) + }) + + it('dvd logo bounces off the edges and changes hue', () => { + const ctx = Object.assign(makeCtx(), { strokeStyle: '', lineWidth: 0, textAlign: '', beginPath: vi.fn(), roundRect: vi.fn(), stroke: vi.fn() }) + const state = createDvdState() + state.x = SCREEN.width - 262 + state.vx = 5 + const hue = state.hue + drawDvdFrame(ctx, state) + expect(state.vx).toBe(-5) + expect(state.hue).not.toBe(hue) + expect(ctx.texts.map((t) => t.text)).toContain('tomasari.is') + }) +}) diff --git a/src/components/terminal/screenTexture.ts b/src/components/terminal/screenTexture.ts index 31d1adc..508abd0 100644 --- a/src/components/terminal/screenTexture.ts +++ b/src/components/terminal/screenTexture.ts @@ -90,3 +90,94 @@ export function drawScreen({ ctx, lines, color, cursorVisible, power }: DrawScre ctx.fillRect(0, 0, width, height) ctx.restore() } + + +/* ---------- Screensavers ---------- */ + +const MATRIX_GLYPHS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノ0123456789ABCDEFXYZ<>/\\=+*#' +const MATRIX_CELL = 22 + +export type MatrixState = { heads: number[]; speeds: number[]; started: boolean } + +export const createMatrixState = (random = Math.random): MatrixState => { + const cols = Math.ceil(SCREEN.width / MATRIX_CELL) + return { + heads: Array.from({ length: cols }, () => -random() * 40), + speeds: Array.from({ length: cols }, () => 0.4 + random() * 0.9), + started: false, + } +} + +/** One frame of digital rain: fade the previous frame, then advance every column head */ +export function drawMatrixFrame(ctx: CanvasRenderingContext2D, state: MatrixState, color: string, random = Math.random) { + const { width, height } = SCREEN + if (!state.started) { + ctx.fillStyle = '#000' + ctx.fillRect(0, 0, width, height) + state.started = true + } + ctx.fillStyle = 'rgba(0, 0, 0, 0.12)' + ctx.fillRect(0, 0, width, height) + ctx.font = `${MATRIX_CELL - 2}px ${SCREEN.font}` + ctx.textBaseline = 'top' + state.heads.forEach((head, col) => { + const y = Math.floor(head) * MATRIX_CELL + if (y >= 0 && y < height) { + const glyph = MATRIX_GLYPHS[Math.floor(random() * MATRIX_GLYPHS.length)] + ctx.fillStyle = '#eafff0' + ctx.fillText(glyph, col * MATRIX_CELL, y) + ctx.fillStyle = color + ctx.fillText(MATRIX_GLYPHS[Math.floor(random() * MATRIX_GLYPHS.length)], col * MATRIX_CELL, y - MATRIX_CELL) + } + state.heads[col] = head + state.speeds[col] + if (y > height + random() * 600) { + state.heads[col] = -random() * 20 + state.speeds[col] = 0.4 + random() * 0.9 + } + }) +} + +export type DvdState = { x: number; y: number; vx: number; vy: number; hue: number } + +const DVD_W = 260 +const DVD_H = 120 + +export const createDvdState = (): DvdState => ({ x: 120, y: 200, vx: 3.2, vy: 2.4, hue: 140 }) + +/** Bouncing logo; changes colour on every wall hit like the real thing */ +export function drawDvdFrame(ctx: CanvasRenderingContext2D, state: DvdState, label = 'tomasari.is') { + const { width, height } = SCREEN + state.x += state.vx + state.y += state.vy + let bounced = false + if (state.x <= 0 || state.x + DVD_W >= width) { + state.vx *= -1 + state.x = Math.max(0, Math.min(width - DVD_W, state.x)) + bounced = true + } + if (state.y <= 0 || state.y + DVD_H >= height) { + state.vy *= -1 + state.y = Math.max(0, Math.min(height - DVD_H, state.y)) + bounced = true + } + if (bounced) state.hue = (state.hue + 67) % 360 + + ctx.fillStyle = '#000' + ctx.fillRect(0, 0, width, height) + const color = `hsl(${state.hue} 90% 60%)` + ctx.strokeStyle = color + ctx.lineWidth = 6 + ctx.beginPath() + ctx.roundRect(state.x + 3, state.y + 3, DVD_W - 6, DVD_H - 6, 24) + ctx.stroke() + ctx.fillStyle = color + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.font = `700 40px ${SCREEN.font}` + ctx.fillText(label, state.x + DVD_W / 2, state.y + DVD_H / 2 - 12) + ctx.font = `18px ${SCREEN.font}` + ctx.fillText('· TERMINAL ·', state.x + DVD_W / 2, state.y + DVD_H / 2 + 28) + ctx.textAlign = 'left' + ctx.fillStyle = 'rgba(0, 0, 0, 0.28)' + for (let y = 0; y < height; y += 3) ctx.fillRect(0, y, width, 1) +} diff --git a/src/utils/audio.test.ts b/src/utils/audio.test.ts index d463413..9e60e0b 100644 --- a/src/utils/audio.test.ts +++ b/src/utils/audio.test.ts @@ -4,6 +4,12 @@ import { clickRateFor, playClick, playPowerOn, + playQuack, + playMeow, + playFanfare, + startParty, + stopParty, + isPartyPlaying, isAudioSupported, resetAudioForTests, } from './audio' @@ -189,6 +195,38 @@ describe('audio', () => { }) }) + describe('room sounds', () => { + it('quack, meow and fanfare each schedule oscillators', () => { + playQuack() + playMeow() + playFanfare() + const ctx = MockAudioContext.instances[0] + // 2 + 2 + 4 notes + expect(ctx.createOscillator).toHaveBeenCalledTimes(8) + expect(ctx.resume).toHaveBeenCalled() + }) + + it('party loop schedules notes ahead and stops cleanly', () => { + vi.useFakeTimers() + startParty() + expect(isPartyPlaying()).toBe(true) + const ctx = MockAudioContext.instances[0] + const scheduled = ctx.createOscillator.mock.calls.length + expect(scheduled).toBeGreaterThan(0) + startParty() + expect(MockAudioContext.instances).toHaveLength(1) + ctx.currentTime = 0.5 + vi.advanceTimersByTime(200) + expect(ctx.createOscillator.mock.calls.length).toBeGreaterThan(scheduled) + stopParty() + expect(isPartyPlaying()).toBe(false) + const after = ctx.createOscillator.mock.calls.length + vi.advanceTimersByTime(500) + expect(ctx.createOscillator.mock.calls.length).toBe(after) + vi.useRealTimers() + }) + }) + describe('isAudioSupported', () => { it('returns true when AudioContext exists', () => { expect(isAudioSupported()).toBe(true) diff --git a/src/utils/audio.ts b/src/utils/audio.ts index e245b39..b5dd3c5 100644 --- a/src/utils/audio.ts +++ b/src/utils/audio.ts @@ -123,7 +123,112 @@ export function playPowerOn(): void { } } +/** Short synthesized note: oscillator → gain envelope → destination */ +function tone(ctx: AudioContext, opts: { type: OscillatorType; from: number; to?: number; at: number; duration: number; peak: number }) { + const osc = ctx.createOscillator() + const gain = ctx.createGain() + osc.type = opts.type + osc.frequency.setValueAtTime(opts.from, opts.at) + if (opts.to) osc.frequency.exponentialRampToValueAtTime(opts.to, opts.at + opts.duration) + gain.gain.setValueAtTime(0.0001, opts.at) + gain.gain.exponentialRampToValueAtTime(opts.peak, opts.at + 0.02) + gain.gain.exponentialRampToValueAtTime(0.0001, opts.at + opts.duration) + osc.connect(gain) + gain.connect(ctx.destination) + osc.start(opts.at) + osc.stop(opts.at + opts.duration + 0.05) +} + +/** Resumes a suspended context (must follow a user gesture) and returns it */ +function liveContext(): AudioContext | null { + const ctx = getAudioContext() + if (!ctx) return null + if (ctx.state === 'suspended') ctx.resume().catch(() => {}) + return ctx +} + +/** Rubber duck: two nasal squawks */ +export function playQuack(): void { + try { + const ctx = liveContext() + if (!ctx) return + const t = ctx.currentTime + tone(ctx, { type: 'sawtooth', from: 520, to: 380, at: t, duration: 0.16, peak: 0.12 }) + tone(ctx, { type: 'sawtooth', from: 480, to: 340, at: t + 0.2, duration: 0.2, peak: 0.1 }) + } catch { + // ignore + } +} + +/** Sleepy cat: a soft rising-falling mew */ +export function playMeow(): void { + try { + const ctx = liveContext() + if (!ctx) return + const t = ctx.currentTime + tone(ctx, { type: 'triangle', from: 600, to: 900, at: t, duration: 0.22, peak: 0.09 }) + tone(ctx, { type: 'triangle', from: 900, to: 520, at: t + 0.22, duration: 0.3, peak: 0.08 }) + } catch { + // ignore + } +} + +/** "You found everything" fanfare */ +export function playFanfare(): void { + try { + const ctx = liveContext() + if (!ctx) return + const t = ctx.currentTime + ;[523, 659, 784, 1047].forEach((freq, i) => { + tone(ctx, { type: 'square', from: freq, at: t + i * 0.12, duration: i === 3 ? 0.5 : 0.14, peak: 0.06 }) + }) + } catch { + // ignore + } +} + +/** Four-bar chiptune loop in C minor-ish, scheduled ahead in small chunks */ +const PARTY_MELODY = [262, 311, 392, 466, 392, 311, 262, 233, 262, 311, 392, 523, 466, 392, 311, 262] +const PARTY_BASS = [131, 131, 98, 98, 117, 117, 131, 131] +const PARTY_STEP = 0.16 + +let partyTimer: ReturnType | null = null + +/** Starts the party loop; a no-op if already playing */ +export function startParty(): void { + if (partyTimer) return + const ctx = liveContext() + if (!ctx) return + let step = 0 + let nextAt = ctx.currentTime + 0.05 + const schedule = () => { + while (nextAt < ctx.currentTime + 0.4) { + const lead = PARTY_MELODY[step % PARTY_MELODY.length] + tone(ctx, { type: 'square', from: lead, at: nextAt, duration: PARTY_STEP * 0.8, peak: 0.045 }) + if (step % 2 === 0) { + tone(ctx, { type: 'triangle', from: PARTY_BASS[(step / 2) % PARTY_BASS.length], at: nextAt, duration: PARTY_STEP * 1.6, peak: 0.07 }) + } + if (step % 4 === 0) tone(ctx, { type: 'sawtooth', from: 90, to: 40, at: nextAt, duration: 0.1, peak: 0.08 }) + nextAt += PARTY_STEP + step += 1 + } + } + schedule() + partyTimer = setInterval(schedule, 150) +} + +export function stopParty(): void { + if (!partyTimer) return + clearInterval(partyTimer) + partyTimer = null +} + +export function isPartyPlaying(): boolean { + return partyTimer !== null +} + export function resetAudioForTests(): void { + stopParty() context = null clickBuffer = null } From dab20a965eb27c5d183525c781436cc0382e71d0 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Thu, 17 Sep 2026 12:41:36 +0000 Subject: [PATCH 3/8] =?UTF-8?q?feat(3d):=20review=20fixes=20=E2=80=94=20ri?= =?UTF-8?q?cher=20CRT,=20usable=20cards,=20no=20cat,=20LAN-safe=20ids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRT experience - Lines are colour-classified: bright command echo with a dim `$`, plain output, red-ish errors, lighter headings; the raster tint follows the theme - `scene theme green|amber|ice|white|pink|purple` phosphor presets (persisted through the existing colour mechanism) - Output rolls in a row at a time (time-based, so slow frames catch up), the screen glow surges when a command prints, and a faint band rolls down the tube while effects are on - Commands can supply a compact plain rendering for the 3D screen (`Command.plain`); `help`, `help `, `scene`, `scene look` and `scene help` now fit the 60×19 tube instead of scrolling for pages. `help` also gained the missing Code category (node, python) Cards & camera - Closing a card (Esc, ×, click away) after `scene goto` flies back to the desk; starting to type does too — no more being stranded at a poster - In walk mode a card releases the mouse so its links can be clicked; E/Esc closes it and re-grabs the mouse; 1-4 open its links - Q always leaves walk mode (the browser owns Esc while the pointer is locked); walk mode turns itself off if the lock is refused - Walk targeting needs the exhibit within ~35° of centre, not 56° Fixes - `crypto.randomUUID` is undefined on plain-http LAN URLs and broke every command; replaced with a `uid()` helper (also in ChatMe/IdeaGenerator) - A command that throws no longer leaves the prompt stuck with its newline - X-ray is a glowing green wireframe with the CRT face kept solid (previously everything went black) - Orbit zoom is 0.35× as aggressive; rotate slightly slower - Desk props float independently when gravity is off - Removed the cat (owner is allergic) Tests: 375 passing. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/TerminalHandler.test.tsx | 10 +++ src/components/TerminalHandler.tsx | 29 ++++-- src/components/commands/Command.tsx | 6 +- src/components/commands/HelpCommand.test.ts | 39 ++++++++ src/components/commands/HelpCommand.tsx | 63 +++++++++---- src/components/commands/SceneCommand.test.tsx | 23 +++++ src/components/commands/SceneCommand.tsx | 79 +++++++++++++++- src/components/terminal/AmbientEffects.tsx | 13 ++- src/components/terminal/ExhibitOverlay.tsx | 27 +++--- src/components/terminal/RoomCritters.tsx | 80 +---------------- src/components/terminal/RoomEffects.tsx | 33 +++++-- src/components/terminal/TerminalCanvas.tsx | 11 ++- src/components/terminal/TerminalScene.tsx | 72 +++++++++++++-- src/components/terminal/WalkControls.test.ts | 13 ++- src/components/terminal/WalkControls.tsx | 41 +++++++-- .../terminal/exhibitActions.test.ts | 63 +++++++++++++ src/components/terminal/exhibitActions.ts | 25 ++++-- src/components/terminal/exhibits.test.ts | 4 +- src/components/terminal/exhibits.ts | 14 +-- src/components/terminal/reactions.ts | 6 ++ src/components/terminal/screenTexture.test.ts | 38 +++++++- src/components/terminal/screenTexture.ts | 90 ++++++++++++++++--- src/screens/ChatMe.tsx | 3 +- src/screens/IdeaGenerator.tsx | 3 +- src/utils/audio.test.ts | 8 +- src/utils/audio.ts | 13 --- src/utils/uid.ts | 9 ++ 27 files changed, 610 insertions(+), 205 deletions(-) create mode 100644 src/components/commands/HelpCommand.test.ts create mode 100644 src/components/terminal/exhibitActions.test.ts create mode 100644 src/components/terminal/reactions.ts create mode 100644 src/utils/uid.ts diff --git a/src/components/TerminalHandler.test.tsx b/src/components/TerminalHandler.test.tsx index 901f89e..fcfacea 100644 --- a/src/components/TerminalHandler.test.tsx +++ b/src/components/TerminalHandler.test.tsx @@ -109,6 +109,16 @@ describe('TerminalHandler input handling', () => { expect(screen.getByText('clear')).toBeInTheDocument() }) + it("uses a command's compact plain rendering for the 3D buffer", async () => { + const { buffer } = renderTerminal() + await runCommand('help', buffer) + const lines = lastBuffer(buffer) + expect(lines).toContain('$ help') + expect(lines.some((l) => l.startsWith('Commands'))).toBe(true) + // The rich version lists every command on its own line; the compact one does not + expect(lines).not.toContain('Copy files or directories') + }) + it('keeps the spaces between prompt, command and arguments in the 3D buffer', async () => { const { buffer } = renderTerminal() await runCommand('echo hello world', buffer) diff --git a/src/components/TerminalHandler.tsx b/src/components/TerminalHandler.tsx index 0da3b89..1594705 100644 --- a/src/components/TerminalHandler.tsx +++ b/src/components/TerminalHandler.tsx @@ -12,6 +12,7 @@ import { KeyPressContext, type TerminalShortcut } from "../context/KeypressedCon import { commandMap } from "./commands/CommandMap"; import { extractText } from "../utils/textExtraction"; import { loadPersistedColor } from "../utils/colorPersistence"; +import { uid } from "../utils/uid"; import { fileSystem } from "../services/filesystem"; import { aliasService } from "../services/alias"; import { envService } from "../services/env"; @@ -63,10 +64,10 @@ const TerminalHandler = ({ onBufferChange, headless = false }: Props) => { }, []); /* helper to push a React line + its text twin */ - const pushLine = (node: ReactNode) => { + const pushLine = (node: ReactNode, plain?: string) => { setOutput((prev) => [...prev, node]); - const extracted = extractText(node); + const extracted = plain ?? extractText(node); const split = extracted .split(/\r?\n/) // break on \n .filter(Boolean); // toss empties @@ -257,7 +258,7 @@ const TerminalHandler = ({ onBufferChange, headless = false }: Props) => { if (expansionError) { pushLine( -
+
❯{" "} {cmd} @@ -292,12 +293,23 @@ const TerminalHandler = ({ onBufferChange, headless = false }: Props) => { } if (command) { - - const result = await command.run(args, context); + let result: React.ReactNode | null = null; + try { + result = await command.run(args, context); + } catch (err) { + // A crashing command must not leave the input line stuck with its trailing newline + result = ( +

+ {command.name} failed: {err instanceof Error ? err.message : String(err)} +

+ ); + } if (command.name === "exit") setTimeout(() => router.push("/"), 1000); + // The CRT is ~60x19; commands may supply a compact rendering for it + const plain = headless ? command.plain?.(args, context) ?? undefined : undefined; if (result) { pushLine( -
+
❯{" "} {base} @@ -308,7 +320,8 @@ const TerminalHandler = ({ onBufferChange, headless = false }: Props) => {
{result}
-
+
, + plain === undefined ? undefined : `${PROMPT}${cmd}\n${plain}` ); } setLastCommandTokens(finalTokens); @@ -318,7 +331,7 @@ const TerminalHandler = ({ onBufferChange, headless = false }: Props) => { }); } else { pushLine( -
+
❯{" "} {base} diff --git a/src/components/commands/Command.tsx b/src/components/commands/Command.tsx index c92f17f..b1ec3ee 100644 --- a/src/components/commands/Command.tsx +++ b/src/components/commands/Command.tsx @@ -7,5 +7,9 @@ export default interface Command { usage?: React.ReactNode; args: string[]; run: (args: string[], context: KeyPressContextType) => Promise; - + /** + * Optional compact plain-text rendering for the 3D CRT (~60 columns, 19 rows). + * Return null to fall back to flattening the rich output. + */ + plain?: (args: string[], context: KeyPressContextType) => string | null; } diff --git a/src/components/commands/HelpCommand.test.ts b/src/components/commands/HelpCommand.test.ts new file mode 100644 index 0000000..f8266de --- /dev/null +++ b/src/components/commands/HelpCommand.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +// CommandMap first: it registers every command and HelpCommand reads it back (circular import) +import { commandMap } from './CommandMap' +import { HelpCommand, plainHelp } from './HelpCommand' +import type { KeyPressContextType } from '../../context/KeypressedContext' + +const context = { headless: true } as unknown as KeyPressContextType +const registered = commandMap.size + +describe('plainHelp', () => { + it('lists one category per line and wraps long ones to the screen width', () => { + expect(registered).toBeGreaterThan(0) + const lines = plainHelp(58).split('\n') + expect(lines[0]).toContain('help ') + expect(lines.some((l) => l.startsWith('System') && l.includes('scene'))).toBe(true) + expect(lines.some((l) => l.startsWith('Code') && l.includes('python'))).toBe(true) + lines.forEach((l) => expect(l.length).toBeLessThanOrEqual(58)) + // Filesystem is too wide for one row and continues indented + const fs = lines.findIndex((l) => l.startsWith('Filesystem')) + expect(lines[fs + 1].startsWith(' '.repeat(12))).toBe(true) + expect(lines.length).toBeLessThan(15) + }) +}) + +describe('HelpCommand.plain', () => { + it('renders the listing without arguments', () => { + expect(HelpCommand.plain!([], context)).toBe(plainHelp()) + }) + + it('renders a command summary plus flattened usage', () => { + const text = HelpCommand.plain!(['echo'], context)! + expect(text.startsWith('echo — ')).toBe(true) + expect(text).toContain('Usage:') + }) + + it('reports unknown commands', () => { + expect(HelpCommand.plain!(['nope'], context)).toBe('Command not found: nope') + }) +}) diff --git a/src/components/commands/HelpCommand.tsx b/src/components/commands/HelpCommand.tsx index 184abb6..925f1e0 100644 --- a/src/components/commands/HelpCommand.tsx +++ b/src/components/commands/HelpCommand.tsx @@ -2,6 +2,7 @@ import React from 'react' import Command from './Command' import { commandMap } from './CommandMap' import { HelpCircle, Terminal, ChevronRight, AlertCircle } from 'lucide-react' +import { extractText } from '../../utils/textExtraction' // Group commands by category const commandCategories: Record = { @@ -12,6 +13,42 @@ const commandCategories: Record = { 'Filesystem': ['pwd', 'cd', 'ls', 'touch', 'cat', 'mkdir', 'rm', 'cp', 'mv', 'find', 'grep', 'clearfs'], 'Git': ['git'], 'Editors': ['vim'], + 'Code': ['node', 'python'], +} + +/** Every command with its description, grouped by category (clear is built into the shell) */ +function categorize() { + const allCommands = new Map() + allCommands.set('clear', { desc: 'Clear the terminal screen' }) + for (const [name, cmd] of commandMap) { + allCommands.set(name, { desc: cmd.description }) + } + return Object.entries(commandCategories).map(([category, commands]) => ({ + category, + commands: commands + .filter((name) => allCommands.has(name)) + .map((name) => ({ name, desc: allCommands.get(name)?.desc || '' })), + })) +} + +/** Compact listing for the 3D CRT: one category per line, names wrapped to the screen width */ +export function plainHelp(width = 58): string { + const label = 12 + const lines = ['Commands — help for usage', ''] + categorize().forEach(({ category, commands }) => { + let line = category.padEnd(label) + commands.forEach(({ name }, i) => { + const next = line + (i ? ' ' : '') + name + if (next.length > width) { + lines.push(line) + line = ' '.repeat(label) + name + } else { + line = next + } + }) + lines.push(line) + }) + return lines.join('\n') } export const HelpCommand: Command = { @@ -29,6 +66,13 @@ export const HelpCommand: Command = {
), args: [], + plain: (args: string[]) => { + if (args.length === 0) return plainHelp() + const command = commandMap.get(args[0]) + if (!command) return `Command not found: ${args[0]}` + const usage = command.usage ? extractText(command.usage).trim() : '' + return [`${command.name} — ${command.description}`, ...(usage ? ['', usage] : [])].join('\n') + }, run: async (args: string[]) => { // Show specific command help if (args.length > 0) { @@ -66,24 +110,7 @@ export const HelpCommand: Command = { ) } - // Build command list with descriptions - const allCommands = new Map() - allCommands.set('clear', { desc: 'Clear the terminal screen' }) - - for (const [name, cmd] of commandMap) { - allCommands.set(name, { desc: cmd.description }) - } - - // Get categorized commands - const categorizedCommands = Object.entries(commandCategories).map(([category, commands]) => ({ - category, - commands: commands - .filter(name => allCommands.has(name)) - .map(name => ({ - name, - desc: allCommands.get(name)?.desc || '', - })), - })) + const categorizedCommands = categorize() return (
diff --git a/src/components/commands/SceneCommand.test.tsx b/src/components/commands/SceneCommand.test.tsx index 8b0db9c..5b8a68c 100644 --- a/src/components/commands/SceneCommand.test.tsx +++ b/src/components/commands/SceneCommand.test.tsx @@ -150,6 +150,29 @@ describe('SceneCommand', () => { expect(sceneStore.getState().screensaver).toBe('off') }) + it('renders compact plain text for the CRT', () => { + const plain = SceneCommand.plain! + const status = plain([], context(true))! + expect(status.split('\n').length).toBeLessThanOrEqual(7) + expect(status).toContain('camera default') + expect(plain(['help'], context(true))).toContain('goto ') + expect(plain(['look'], context(true))).toContain('still loading') + sceneStore.setState({ exhibits: buildExhibits(null) }) + sceneStore.inspect('duck') + const look = plain(['look'], context(true))! + expect(look).toContain('* duck') + expect(look).toContain(' sign') + // Everything else falls back to the rich output + expect(plain(['lamp', 'on'], context(true))).toBeNull() + }) + + it('applies a phosphor theme', async () => { + expect(await textOf(['theme'])).toContain('amber') + expect(await textOf(['theme', 'amber'])).toContain('#ffb000') + expect(localStorage.getItem('terminal-color')).toBe('#ffb000') + expect(await textOf(['theme', 'mauve'])).toContain('Unknown theme') + }) + it('shows usage for help and errors for unknown subcommands', async () => { expect(await textOf(['help'])).toContain('Usage') expect(await textOf(['dance'])).toContain('Unknown scene command') diff --git a/src/components/commands/SceneCommand.tsx b/src/components/commands/SceneCommand.tsx index 41b498a..2f7be38 100644 --- a/src/components/commands/SceneCommand.tsx +++ b/src/components/commands/SceneCommand.tsx @@ -13,6 +13,17 @@ import { } from '../terminal/sceneStore' import { exhibitView } from '../terminal/exhibits' import { activateExhibit } from '../terminal/exhibitActions' +import { persistColor } from '../../utils/colorPersistence' + +/** Phosphor presets for `scene theme` */ +export const THEMES: Record = { + green: '#22c55e', + amber: '#ffb000', + ice: '#7dd3fc', + white: '#e5e7eb', + pink: '#f472b6', + purple: '#c084fc', +} /** Subcommand → store key for the on/off toggles */ const TOGGLES: Record = { @@ -130,7 +141,7 @@ const usage = (

look — List everything in the room

goto <id> — Fly to a thing and open its card

-

walk on|off — First-person mode (WASD, mouse, E, Esc)

+

walk on|off — First-person mode (WASD, mouse, E, Q to leave)

cam {CAMERA_PRESET_NAMES.join('|')} — Fly the camera to a view

Room:

@@ -140,6 +151,7 @@ const usage = (

gravity on|off — Off, and the desk floats away

xray on|off — Wireframe everything

saver {SCREENSAVERS.join('|')} — Screensaver on the CRT

+

theme {Object.keys(THEMES).join('|')} — Phosphor colour

reset — Back to defaults

Omit the value to toggle. Run scene alone for the current state.

@@ -163,6 +175,14 @@ const runGoto = (value: string | undefined, state: SceneState) => { return looking at {exhibit.title} } +const runTheme = (value: string | undefined) => { + if (!value) return themes: {Object.keys(THEMES).join(', ')} — or color set #hex + const hex = THEMES[value] + if (!hex) return Unknown theme "{value}" — try {Object.keys(THEMES).join(', ')} + persistColor(hex) + return theme {value} ({hex}) +} + const runSaver = (value: string | undefined) => { const saver = (value ?? 'matrix') as Screensaver if (!SCREENSAVERS.includes(saver)) return Unknown screensaver "{value}" — try {SCREENSAVERS.join(', ')} @@ -170,11 +190,67 @@ const runSaver = (value: string | undefined) => { return screensaver {saver} } +/** Two-column key/value rows for the CRT */ +const columns = (rows: [string, string][], width = 29) => + rows + .reduce((lines, [key, value], i) => { + const cell = `${key.padEnd(8)}${value}` + if (i % 2 === 0) lines.push(cell.padEnd(width)) + else lines[lines.length - 1] += cell + return lines + }, []) + .map((l) => l.trimEnd()) + +const PLAIN_HELP = [ + 'scene [value]', + '', + 'look list the room', + 'goto fly to it, open its card', + 'walk on|off first person: WASD, mouse, E, Q', + `cam ${CAMERA_PRESET_NAMES.join('|')}`, + 'power lamp lights fx sound spin props', + 'party gravity xray …on|off (omit to toggle)', + `saver ${SCREENSAVERS.join('|')}`, + `theme ${Object.keys(THEMES).join('|')}`, + 'reset back to defaults', +].join('\n') + +const plainStatus = (state: SceneState) => + columns([ + ['camera', state.walk ? 'walking' : (state.cameraPreset ?? 'exhibit')], + ['power', onOff(state.power)], + ['lamp', onOff(state.lampOn)], + ['lights', onOff(state.roomLights)], + ['fx', onOff(state.effects)], + ['sound', onOff(state.sound)], + ['spin', onOff(state.spin)], + ['props', onOff(state.props)], + ['gravity', onOff(state.gravity)], + ['party', onOff(state.party)], + ['xray', onOff(state.xray)], + ['saver', state.screensaver], + ['found', `${state.exhibits.filter((e) => state.discovered.includes(e.id)).length}/${state.exhibits.length}`], + ['fps', state.fps ? String(state.fps) : '-'], + ]).join('\n') + +const plainLook = (state: SceneState) => + state.exhibits.length === 0 + ? 'The room is still loading — try again in a second.' + : [...state.exhibits.map((e) => `${state.discovered.includes(e.id) ? '*' : ' '} ${e.id.padEnd(26)}${e.title}`), '', 'scene goto flies there'].join('\n') + export const SceneCommand: Command = { name: 'scene', description: 'Control the 3D room: walk around, fly to exhibits, lights, party mode…', usage, args: [], + plain: (args: string[]) => { + const [sub] = args + const state = sceneStore.getState() + if (!sub || sub === 'status') return plainStatus(state) + if (sub === 'help') return PLAIN_HELP + if (sub === 'look' || sub === 'ls') return plainLook(state) + return null + }, run: async (args: string[], context: KeyPressContextType) => { const headless = !!context.headless const [sub, value] = args @@ -186,6 +262,7 @@ export const SceneCommand: Command = { if (CAMERA_ALIASES.includes(sub)) return runCamera(value) if (GOTO_ALIASES.includes(sub)) return runGoto(value, state) if (sub === 'saver' || sub === 'screensaver') return runSaver(value) + if (sub === 'theme' || sub === 'phosphor') return runTheme(value) if (sub === 'reset') { sceneStore.reset() diff --git a/src/components/terminal/AmbientEffects.tsx b/src/components/terminal/AmbientEffects.tsx index 9169650..55605e3 100644 --- a/src/components/terminal/AmbientEffects.tsx +++ b/src/components/terminal/AmbientEffects.tsx @@ -4,6 +4,7 @@ import React, { useRef, useMemo } from 'react' import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import type { AmbientEffectsProps } from '../../types/terminal3d' +import { reactions } from './reactions' /** * Ambient visual effects for the 3D terminal scene @@ -29,11 +30,19 @@ export default function AmbientEffects({ /** Screen glow emanating from the monitor, with the faint flicker of a real tube */ function ScreenGlow() { const mainRef = useRef(null) + const seen = useRef(reactions.screen) + const pulse = useRef(0) - useFrame(({ clock }) => { + useFrame(({ clock }, delta) => { if (!mainRef.current) return + if (reactions.screen !== seen.current) { + seen.current = reactions.screen + pulse.current = 1 + } + pulse.current = Math.max(0, pulse.current - delta * 2.5) const t = clock.getElapsedTime() - mainRef.current.intensity = 0.55 + Math.sin(t * 17) * 0.03 + Math.sin(t * 3.1) * 0.05 + // Idle flicker plus a brief surge whenever the terminal prints something + mainRef.current.intensity = 0.55 + Math.sin(t * 17) * 0.03 + Math.sin(t * 3.1) * 0.05 + pulse.current * 0.9 }) return ( diff --git a/src/components/terminal/ExhibitOverlay.tsx b/src/components/terminal/ExhibitOverlay.tsx index b10f520..f70e1bb 100644 --- a/src/components/terminal/ExhibitOverlay.tsx +++ b/src/components/terminal/ExhibitOverlay.tsx @@ -3,10 +3,11 @@ import React, { useEffect, useMemo } from 'react' import { X, ExternalLink, Footprints, Trophy } from 'lucide-react' import { duckTip, type ExhibitLink } from './exhibits' -import { sceneStore, useSceneStore } from './sceneStore' +import { useSceneStore } from './sceneStore' +import { closeCard } from './exhibitActions' /** Tag pills and link buttons under the card body */ -function CardFooter({ tags, links }: { tags?: string[]; links?: ExhibitLink[] }) { +function CardFooter({ tags, links, numbered }: { tags?: string[]; links?: ExhibitLink[]; numbered?: boolean }) { return ( <> {tags && tags.length > 0 && ( @@ -20,7 +21,7 @@ function CardFooter({ tags, links }: { tags?: string[]; links?: ExhibitLink[] }) )} {links && links.length > 0 && (
- {links.map((link) => ( + {links.map((link, i) => ( + {numbered && [{i + 1}]} {link.label} ))} @@ -47,21 +49,21 @@ export function ExhibitCard() { const tip = useMemo(() => (focused === 'duck' ? duckTip() : null), [focused]) useEffect(() => { - if (!focused) return + if (!focused || walk) return const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') sceneStore.inspect(null) + if (e.key === 'Escape') closeCard() } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [focused]) + }, [focused, walk]) if (!exhibit) return null const lines = tip ? [tip] : exhibit.lines return (
sceneStore.inspect(null)} + className={`absolute z-30 ${walk ? 'right-4 bottom-14 max-w-sm' : 'inset-0 flex items-end justify-end p-4 sm:p-6'}`} + onClick={walk ? undefined : closeCard} >
{exhibit.title} {exhibit.subtitle &&

{exhibit.subtitle}

}
-
@@ -86,7 +88,10 @@ export function ExhibitCard() { ))} )} - + +

+ {walk ? 'E or Esc — back to walking · 1-4 open links' : 'Esc or click away — back to the desk'} +

) @@ -109,7 +114,7 @@ export function WalkHud() { )}
- WASD move · mouse look · shift run · E inspect · esc leave + WASD move · mouse look · shift run · E inspect · Q or esc leave
) diff --git a/src/components/terminal/RoomCritters.tsx b/src/components/terminal/RoomCritters.tsx index 4e04f3d..242c3a5 100644 --- a/src/components/terminal/RoomCritters.tsx +++ b/src/components/terminal/RoomCritters.tsx @@ -5,7 +5,7 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import type { Exhibit } from './exhibits' import { Interactive } from './RoomExhibits' -import { reactions } from './exhibitActions' +import { reactions } from './reactions' const YELLOW = '#ffd60a' const ORANGE = '#fb923c' @@ -61,81 +61,3 @@ export function Duck({ exhibit }: { exhibit: Exhibit }) { ) } - -const FUR = '#d98a3d' -const FUR_DARK = '#b86f2a' - -/** Head resting on paws: ears, closed eyes, pink nose */ -function CatHead({ highlighted }: { highlighted: boolean }) { - return ( - - - - - - {[-1, 1].map((side) => ( - - - - - ))} - {[-1, 1].map((side) => ( - - - - - ))} - - - - - - ) -} - -/** Low-poly tabby curled up asleep; breathes, and flicks its tail when activated */ -export function Cat({ exhibit }: { exhibit: Exhibit }) { - const bodyRef = useRef(null) - const tailRef = useRef(null) - const seen = useRef(reactions.cat) - const flick = useRef(0) - - useFrame(({ clock }, delta) => { - const t = clock.getElapsedTime() - if (bodyRef.current) bodyRef.current.scale.set(1, 1 + Math.sin(t * 1.6) * 0.03, 1) - if (reactions.cat !== seen.current) { - seen.current = reactions.cat - flick.current = 1 - } - if (tailRef.current) { - flick.current = Math.max(0, flick.current - delta * 1.2) - tailRef.current.rotation.y = Math.sin(t * 0.7) * 0.05 + Math.sin(flick.current * Math.PI * 4) * 0.5 * flick.current - } - }) - - return ( - - {(highlighted) => ( - - - - - - {/* Stripes */} - {[-0.05, 0, 0.05].map((x) => ( - - - - - ))} - - {/* Tail wrapped around */} - - - - - - )} - - ) -} diff --git a/src/components/terminal/RoomEffects.tsx b/src/components/terminal/RoomEffects.tsx index 7808623..487c16f 100644 --- a/src/components/terminal/RoomEffects.tsx +++ b/src/components/terminal/RoomEffects.tsx @@ -165,25 +165,40 @@ export function Confetti() { ) } -/** Sets every material in the subtree to wireframe while x-ray is on */ +const XRAY_COLOR = new THREE.Color('#22c55e') +type XrayMaterial = THREE.MeshStandardMaterial & { userData: { xray?: { emissive: number; intensity: number } } } + +/** Green wireframe view of every lit mesh (the CRT face stays solid so you can still read it) */ export function XRay({ enabled, children }: { enabled: boolean; children: React.ReactNode }) { const group = useRef(null) useEffect(() => { const root = group.current - if (!root) return - const touched: THREE.Material[] = [] + if (!root || !enabled) return + const touched: XrayMaterial[] = [] root.traverse((obj) => { const mesh = obj as THREE.Mesh - if (!mesh.isMesh) return + if (!mesh.isMesh || mesh.name === 'PC_M_Screen_0') return const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] materials.forEach((m) => { - if ('wireframe' in m) { - ;(m as THREE.MeshStandardMaterial).wireframe = enabled - touched.push(m) - } + const mat = m as XrayMaterial + if (!('wireframe' in mat) || !mat.emissive) return + mat.userData.xray = { emissive: mat.emissive.getHex(), intensity: mat.emissiveIntensity } + mat.wireframe = true + mat.emissive.copy(XRAY_COLOR) + mat.emissiveIntensity = 0.9 + touched.push(mat) }) }) - return () => touched.forEach((m) => ('wireframe' in m ? ((m as THREE.MeshStandardMaterial).wireframe = false) : undefined)) + return () => + touched.forEach((mat) => { + const saved = mat.userData.xray + mat.wireframe = false + if (saved) { + mat.emissive.setHex(saved.emissive) + mat.emissiveIntensity = saved.intensity + } + delete mat.userData.xray + }) }, [enabled]) return {children} } diff --git a/src/components/terminal/TerminalCanvas.tsx b/src/components/terminal/TerminalCanvas.tsx index 934a2e6..0535720 100644 --- a/src/components/terminal/TerminalCanvas.tsx +++ b/src/components/terminal/TerminalCanvas.tsx @@ -82,6 +82,13 @@ export default function TerminalCanvas() { const modelLoading = progress < 100 && !loaderTimedOut + // Typing while the camera is parked at an exhibit brings the CRT back into view + const liveLine = buffer[buffer.length - 1] ?? '' + useEffect(() => { + const state = sceneStore.getState() + if (liveLine.length > 2 && !state.walk && state.cameraPreset === null) sceneStore.setCamera('default') + }, [liveLine]) + // Mobile fallback with link to 2D terminal if (isMobile) { return ( @@ -111,7 +118,7 @@ export default function TerminalCanvas() {
- @@ -179,7 +186,7 @@ export default function TerminalCanvas() { 3D Mode
- {walk ? 'WASD to walk • mouse to look • E to inspect • Esc to leave' : 'drag to orbit • scroll to zoom • click things to inspect them'} + {walk ? 'WASD to walk • mouse to look • E to inspect • Q or Esc to leave' : 'drag to orbit • scroll to zoom • click things to inspect them'}
diff --git a/src/components/terminal/TerminalScene.tsx b/src/components/terminal/TerminalScene.tsx index a3a423f..8dd0c3d 100644 --- a/src/components/terminal/TerminalScene.tsx +++ b/src/components/terminal/TerminalScene.tsx @@ -23,11 +23,12 @@ import { } from './screenTexture' import { buildExhibits, ROOM, type Exhibit } from './exhibits' import { CertExhibit, PaintedExhibit, ShelfExhibit, SignExhibit } from './RoomExhibits' -import { Cat, Duck } from './RoomCritters' +import { Duck } from './RoomCritters' import { AuroraWindow, LavaLamp, Plant, WallClock } from './RoomGadgets' import { Confetti, Disco, FloatGroup, RoomLights, XRay } from './RoomEffects' import WalkControls from './WalkControls' import { useProfile } from '../../hooks/useProfile' +import { reactions } from './reactions' import { DEFAULT_CAMERA_CONFIG } from '../../types/terminal3d' import type { TerminalSceneProps, CameraConfig } from '../../types/terminal3d' @@ -114,10 +115,14 @@ export function useTerminalTexture(lines: string[], power: boolean, screensaver: const cursorVisibleRef = useRef(true) const linesRef = useRef(lines) const fontReadyRef = useRef(false) + // How many committed lines are on screen; new output is revealed a row at a time like a real teletype + const revealedRef = useRef(Math.max(0, lines.length - 1)) const redraw = useCallback(() => { if (saverActive) return - surface.paint({ lines: linesRef.current, color, cursorVisible: cursorVisibleRef.current, power }) + const all = linesRef.current + const shown = [...all.slice(0, revealedRef.current), ...all.slice(-1)] + surface.paint({ lines: shown, color, cursorVisible: cursorVisibleRef.current, power }) }, [surface, color, power, saverActive]) // Screensaver: ~30 fps animation loop that owns the canvas while active @@ -132,7 +137,23 @@ export function useTerminalTexture(lines: string[], power: boolean, screensaver: useEffect(() => { linesRef.current = lines - redraw() + const committed = Math.max(0, lines.length - 1) + if (committed < revealedRef.current) revealedRef.current = committed // `clear` + if (committed === revealedRef.current) { + redraw() + return + } + // A command just produced output: pulse the glow and roll the rows in at ~45 rows/s + // (time-based so a slow frame rate catches up instead of crawling) + reactions.screen += 1 + const from = revealedRef.current + const startedAt = performance.now() + const timer = setInterval(() => { + revealedRef.current = Math.min(committed, from + Math.floor((performance.now() - startedAt) / 22)) + redraw() + if (revealedRef.current >= committed) clearInterval(timer) + }, 22) + return () => clearInterval(timer) }, [lines, redraw]) // Repaint once the web font arrives so the first frames are not in the fallback face @@ -182,6 +203,45 @@ function RetroComputer({ screenTexture }: { screenTexture: THREE.Texture }) { return } +const SCREEN_CENTER: [number, number, number] = [0, 0.411, 0.2165] +const SCREEN_SIZE: [number, number] = [0.352, 0.288] + +/** Translucent bright band that slowly rolls down the tube, like a CRT filmed on video */ +function ScreenRoll({ enabled }: { enabled: boolean }) { + const material = useRef(null) + const texture = useMemo(() => { + const canvas = document.createElement('canvas') + canvas.width = 4 + canvas.height = 256 + const ctx = canvas.getContext('2d')! + const g = ctx.createLinearGradient(0, 0, 0, 256) + g.addColorStop(0, 'rgba(255,255,255,0)') + g.addColorStop(0.46, 'rgba(255,255,255,0)') + g.addColorStop(0.5, 'rgba(255,255,255,1)') + g.addColorStop(0.54, 'rgba(255,255,255,0)') + g.addColorStop(1, 'rgba(255,255,255,0)') + ctx.fillStyle = g + ctx.fillRect(0, 0, 4, 256) + const t = new THREE.CanvasTexture(canvas) + t.wrapS = THREE.RepeatWrapping + t.wrapT = THREE.RepeatWrapping + return t + }, []) + + useFrame((_, delta) => { + if (!material.current?.map) return + material.current.map.offset.y = (material.current.map.offset.y + delta * 0.12) % 1 + }) + + if (!enabled) return null + return ( + + + + + ) +} + /** Orbit controls plus smooth fly-to for `scene cam …` / exhibits, double-click reset and optional auto-spin */ function CameraRig({ config }: { config: CameraConfig }) { const { camera, gl } = useThree() @@ -246,6 +306,8 @@ function CameraRig({ config }: { config: CameraConfig }) { maxPolarAngle={config.maxPolarAngle} autoRotate={spin && !walk} autoRotateSpeed={0.6} + zoomSpeed={0.35} + rotateSpeed={0.7} enableDamping dampingFactor={0.08} enablePan={false} @@ -284,7 +346,6 @@ function ExhibitMesh({ exhibit }: { exhibit: Exhibit }) { case 'clock': return case 'duck': - case 'cat': case 'lava': // Desk residents live inside the FloatGroup so they can drift when gravity is off return null @@ -322,7 +383,6 @@ function SceneContent({ const exhibits = useRoomExhibits() const byId = (id: string) => exhibits.find((e) => e.id === id) const duck = byId('duck') - const cat = byId('cat') const lava = byId('lava') return ( @@ -332,11 +392,11 @@ function SceneContent({ + {props && ( {duck && } - {cat && } {lava && } )} diff --git a/src/components/terminal/WalkControls.test.ts b/src/components/terminal/WalkControls.test.ts index ce0a206..f3eb1af 100644 --- a/src/components/terminal/WalkControls.test.ts +++ b/src/components/terminal/WalkControls.test.ts @@ -42,6 +42,14 @@ describe('findNearExhibit', () => { expect(findNearExhibit(exhibits, position, new THREE.Vector3(0, 0, 1))).toBeNull() }) + it('ignores exhibits that are only in the corner of the eye', () => { + const position = new THREE.Vector3(sign.position[0] + 1.2, 0.7, sign.position[2] + 1.2) + // Looking straight ahead (-z) while the sign sits 45° off to the left + expect(findNearExhibit(exhibits, position, new THREE.Vector3(0, 0, -1))).toBeNull() + // Turn toward it and it is picked up + expect(findNearExhibit(exhibits, position, new THREE.Vector3(-0.7071, 0, -0.7071))).toBe('sign') + }) + it('ignores exhibits that are too far away', () => { const position = new THREE.Vector3(sign.position[0], 0.7, sign.position[2] + 4) expect(findNearExhibit(exhibits, position, new THREE.Vector3(0, 0, -1))).toBeNull() @@ -49,10 +57,11 @@ describe('findNearExhibit', () => { }) describe('findNearExhibit with desk residents', () => { - it('prefers the closer thing when two are in front', () => { + it('picks the centred thing over a closer one off to the side', () => { const exhibits = buildExhibits(null) const sign = exhibits.find((e) => e.id === 'sign')! const position = new THREE.Vector3(sign.position[0], 0.7, sign.position[2] + 1.5) - expect(findNearExhibit(exhibits, position, new THREE.Vector3(0, 0, -1))).toBe('duck') + // The duck is nearer but ~40° off-axis; the sign is dead ahead + expect(findNearExhibit(exhibits, position, new THREE.Vector3(0, 0, -1))).toBe('sign') }) }) diff --git a/src/components/terminal/WalkControls.tsx b/src/components/terminal/WalkControls.tsx index e1d9ce4..9c9bcd8 100644 --- a/src/components/terminal/WalkControls.tsx +++ b/src/components/terminal/WalkControls.tsx @@ -7,13 +7,14 @@ import * as THREE from 'three' import { DESK_BOUNDS, ROOM, exhibitNormal } from './exhibits' import type { Exhibit } from './exhibits' import { sceneStore, useSceneStore } from './sceneStore' -import { activateExhibit } from './exhibitActions' +import { activateExhibit, closeCard, openCardLink } from './exhibitActions' /** Eye height above the floor and how close you must be to an exhibit to inspect it */ const EYE_Y = ROOM.floorY + 1.55 const WALK_SPEED = 1.7 const SPRINT = 1.8 const REACH = 2.1 +const FACING_MIN = 0.82 const SPAWN = new THREE.Vector3(0, EYE_Y, 2.9) const MARGIN = 0.25 @@ -56,10 +57,11 @@ export function findNearExhibit(exhibits: Exhibit[], position: THREE.Vector3, fo if (dist > REACH) return const facing = (dx * forward.x + dz * forward.z) / (dist || 1) const [nx, nz] = exhibitNormal(e) - // Must be in front of the exhibit's face and looking toward it + // Must be in front of the exhibit's face and looking fairly straight at it (≈35° cone) const inFront = (dx * nx + dz * nz) < 0.1 - if (facing < 0.55 || !inFront) return - const score = dist - facing * 0.5 + if (facing < FACING_MIN || !inFront) return + // Prefer what is centred over what is merely close + const score = dist * (1.6 - facing) if (!best || score < best.score) best = { id: e.id, score } }) return best ? (best as { id: string }).id : null @@ -75,9 +77,17 @@ function useWalkKeys(walk: boolean, moves: React.MutableRefObject) { const move = MOVE_KEYS[e.code] if (move) moves.current[move] = true if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') moves.current.sprint = true + const { nearExhibit, focusedExhibit } = sceneStore.getState() if (e.code === 'KeyE' || e.code === 'Enter') { - const near = sceneStore.getState().nearExhibit - if (near) activateExhibit(near) + if (focusedExhibit) closeCard() + else if (nearExhibit) activateExhibit(nearExhibit) + } + if (focusedExhibit && /^Digit[1-4]$/.test(e.code)) openCardLink(Number(e.code.slice(5))) + // Always a way out: Esc (when the browser passes it on) or Q, even without pointer lock. + // With a card open they just close the card. + if (e.code === 'Escape' || e.code === 'KeyQ') { + if (focusedExhibit) closeCard() + else sceneStore.setState({ walk: false }) } e.stopImmediatePropagation() e.preventDefault() @@ -104,6 +114,7 @@ function useWalkKeys(walk: boolean, moves: React.MutableRefObject) { */ export default function WalkControls() { const walk = useSceneStore((s) => s.walk) + const cardOpen = useSceneStore((s) => s.focusedExhibit !== null) const { camera } = useThree() const controls = useRef>(null) const moves = useRef({ ...IDLE }) @@ -119,12 +130,26 @@ export default function WalkControls() { sceneStore.setState({ roomLights: true }) const lock = controls.current lock?.lock() + // If the browser refuses the lock (no user gesture, iframe policy…) do not stay stuck swallowing keys + const check = setTimeout(() => { + if (sceneStore.getState().walk && !document.pointerLockElement && !sceneStore.getState().focusedExhibit) { + sceneStore.setState({ walk: false }) + } + }, 800) return () => { + clearTimeout(check) lock?.unlock() sceneStore.setState({ nearExhibit: null }) } }, [walk, camera]) + // A card releases the mouse so its links can be clicked; closing it grabs the mouse again + useEffect(() => { + if (!walk) return + if (cardOpen) controls.current?.unlock() + else controls.current?.lock() + }, [walk, cardOpen]) + useWalkKeys(walk, moves) useFrame((_, delta) => { @@ -155,7 +180,9 @@ export default function WalkControls() { enabled={walk} selector="#walk-lock-target" onUnlock={() => { - if (sceneStore.getState().walk) sceneStore.setState({ walk: false, focusedExhibit: null }) + // Browser-initiated unlock (Esc, tab switch) ends the walk unless a card deliberately released it + const { walk: walking, focusedExhibit } = sceneStore.getState() + if (walking && !focusedExhibit) sceneStore.setState({ walk: false }) }} /> ) diff --git a/src/components/terminal/exhibitActions.test.ts b/src/components/terminal/exhibitActions.test.ts new file mode 100644 index 0000000..7e4065c --- /dev/null +++ b/src/components/terminal/exhibitActions.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { sceneStore } from './sceneStore' +import { buildExhibits } from './exhibits' +import { activateExhibit, closeCard, openCardLink } from './exhibitActions' +import { reactions } from './reactions' + +describe('exhibit actions', () => { + beforeEach(() => { + localStorage.clear() + sceneStore.resetForTests() + sceneStore.setState({ exhibits: buildExhibits(null), sound: false }) + }) + + it('activating the duck opens its card and makes it hop', () => { + const hops = reactions.duck + activateExhibit('duck') + expect(sceneStore.getState().focusedExhibit).toBe('duck') + expect(reactions.duck).toBe(hops + 1) + }) + + it('closing a card flies back to the desk when the camera was parked at an exhibit', () => { + sceneStore.flyTo({ position: [1, 1, 1], target: [0, 0, 0] }) + activateExhibit('sign') + closeCard() + expect(sceneStore.getState().focusedExhibit).toBeNull() + expect(sceneStore.getState().cameraPreset).toBe('default') + }) + + it('closing a card while walking keeps walking', () => { + sceneStore.setState({ walk: true }) + activateExhibit('sign') + closeCard() + expect(sceneStore.getState().walk).toBe(true) + expect(sceneStore.getState().focusedExhibit).toBeNull() + }) + + it('opens the numbered link of the open card', () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + sceneStore.setState({ + exhibits: [ + { + id: 'x', + kind: 'poster', + title: 'X', + lines: [], + links: [ + { label: 'A', href: 'https://a' }, + { label: 'B', href: 'https://b' }, + ], + position: [0, 0, 0], + rotationY: 0, + size: [1, 1], + }, + ], + }) + activateExhibit('x') + openCardLink(2) + expect(open).toHaveBeenCalledWith('https://b', '_blank', 'noopener') + openCardLink(3) + expect(open).toHaveBeenCalledTimes(1) + open.mockRestore() + }) +}) diff --git a/src/components/terminal/exhibitActions.ts b/src/components/terminal/exhibitActions.ts index db73754..8d0e173 100644 --- a/src/components/terminal/exhibitActions.ts +++ b/src/components/terminal/exhibitActions.ts @@ -1,11 +1,9 @@ -import { playFanfare, playMeow, playQuack } from '../../utils/audio' +import { playFanfare, playQuack } from '../../utils/audio' import { sceneStore } from './sceneStore' - -/** Bumps that other components animate on (duck bounce, cat tail flick) */ -export const reactions = { duck: 0, cat: 0 } +import { reactions } from './reactions' /** - * Opens an exhibit's card (and marks it discovered); living-room residents react with a sound. + * Opens an exhibit's card (and marks it discovered); the duck reacts with a sound. * Shared by clicks in orbit mode, the E key in walk mode and `scene inspect`. */ export function activateExhibit(id: string) { @@ -15,9 +13,20 @@ export function activateExhibit(id: string) { if (id === 'duck') { reactions.duck += 1 if (sound) playQuack() - } else if (id === 'cat') { - reactions.cat += 1 - if (sound) playMeow() } if (sound && sceneStore.getState().celebrateNonce !== before) playFanfare() } + +/** Closes the card; if the camera was parked at an exhibit (scene goto / click), flies back to the desk */ +export function closeCard() { + const { walk, cameraPreset } = sceneStore.getState() + sceneStore.inspect(null) + if (!walk && cameraPreset === null) sceneStore.setCamera('default') +} + +/** Opens the n-th link (1-based) of the open card in a new tab; used by number keys in walk mode */ +export function openCardLink(index: number) { + const { focusedExhibit, exhibits } = sceneStore.getState() + const link = exhibits.find((e) => e.id === focusedExhibit)?.links?.[index - 1] + if (link) window.open(link.href, '_blank', 'noopener') +} diff --git a/src/components/terminal/exhibits.test.ts b/src/components/terminal/exhibits.test.ts index af610c8..a94adc5 100644 --- a/src/components/terminal/exhibits.test.ts +++ b/src/components/terminal/exhibits.test.ts @@ -46,9 +46,9 @@ describe('skillBooks', () => { }) describe('buildExhibits', () => { - it('always includes the sign, window, duck, cat, clock and lava lamp', () => { + it('always includes the sign, window, duck, clock and lava lamp', () => { const ids = buildExhibits(null).map((e) => e.id) - expect(ids).toEqual(['sign', 'window', 'duck', 'cat', 'clock', 'lava']) + expect(ids).toEqual(['sign', 'window', 'duck', 'clock', 'lava']) }) it('falls back to a default name without a profile', () => { diff --git a/src/components/terminal/exhibits.ts b/src/components/terminal/exhibits.ts index ae39da5..e6c28f7 100644 --- a/src/components/terminal/exhibits.ts +++ b/src/components/terminal/exhibits.ts @@ -2,7 +2,7 @@ import type { Profile } from '@/types/profile' import { formatMonth, formatPeriod, visible } from '@/services/profile' /** What kind of thing hangs in the room; drives both the 3D rendering and the info card */ -export type ExhibitKind = 'poster' | 'cert' | 'board' | 'shelf' | 'sign' | 'corkboard' | 'duck' | 'window' | 'cat' | 'clock' | 'lava' +export type ExhibitKind = 'poster' | 'cert' | 'board' | 'shelf' | 'sign' | 'corkboard' | 'duck' | 'window' | 'clock' | 'lava' export type ExhibitLink = { label: string; href: string } @@ -87,16 +87,6 @@ const staticExhibits = (name: string, role: string): Exhibit[] => [ rotationY: 0.4, size: [0.1, 0.1], }, - { - id: 'cat', - kind: 'cat', - title: 'Kisa', - subtitle: 'Office cat · Head of Naps', - lines: ['Sleeps 22 hours a day. Reviews pull requests the other two.', 'Approves anything with fish in the commit message.'], - position: [-1.0, -0.062, 0.55], - rotationY: -0.6, - size: [0.3, 0.15], - }, { id: 'clock', kind: 'clock', @@ -258,7 +248,7 @@ export const exhibitNormal = (e: Exhibit): [number, number] => [Math.sin(e.rotat export function exhibitView(e: Exhibit): { position: [number, number, number]; target: [number, number, number] } { const [nx, nz] = exhibitNormal(e) const distance = Math.min(2.2, Math.max(0.7, Math.max(...e.size) * 1.4)) - const eyeY = e.kind === 'duck' || e.kind === 'cat' || e.kind === 'lava' ? e.position[1] + 0.3 : e.position[1] + 0.05 + const eyeY = e.kind === 'duck' || e.kind === 'lava' ? e.position[1] + 0.3 : e.position[1] + 0.05 return { position: [e.position[0] + nx * distance, eyeY, e.position[2] + nz * distance], target: [e.position[0], e.position[1], e.position[2]], diff --git a/src/components/terminal/reactions.ts b/src/components/terminal/reactions.ts new file mode 100644 index 0000000..45e0f9c --- /dev/null +++ b/src/components/terminal/reactions.ts @@ -0,0 +1,6 @@ +/** + * Counters that animated components watch from useFrame: bump one and the matching + * animation plays once (duck hop, screen glow pulse). Kept outside React state so + * a keypress never re-renders the scene tree. + */ +export const reactions = { duck: 0, screen: 0 } diff --git a/src/components/terminal/screenTexture.test.ts b/src/components/terminal/screenTexture.test.ts index 93578d0..711f27e 100644 --- a/src/components/terminal/screenTexture.test.ts +++ b/src/components/terminal/screenTexture.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -import { wrapLines, drawScreen, phosphorColor, SCREEN, SCREEN_BG_ON, SCREEN_BG_OFF, createMatrixState, drawMatrixFrame, createDvdState, drawDvdFrame } from './screenTexture' +import { wrapLines, wrapRows, drawScreen, phosphorColor, SCREEN, SCREEN_BG_OFF, screenBackground, shade, classifyLine, inkFor, createMatrixState, drawMatrixFrame, createDvdState, drawDvdFrame } from './screenTexture' /** Minimal 2D context stub that records what was painted */ const makeCtx = (charWidth = 10) => { @@ -44,6 +44,36 @@ describe('wrapLines', () => { }) }) +describe('line colouring', () => { + it('classifies prompt, error, heading and plain output', () => { + expect(classifyLine('$ help')).toBe('prompt') + expect(classifyLine('Command not found: foo')).toBe('error') + expect(classifyLine('Usage:')).toBe('heading') + expect(classifyLine('Filesystem')).toBe('heading') + expect(classifyLine('Copy files or directories')).toBe('output') + }) + + it('derives inks from the phosphor colour', () => { + expect(inkFor('output', '#39ff6e')).toBe('#39ff6e') + expect(inkFor('error', '#39ff6e')).toBe('#ff7b72') + expect(inkFor('prompt', '#39ff6e')).not.toBe('#39ff6e') + }) + + it('shade darkens below 1 and lightens above 1', () => { + expect(shade('#ffb000', 0.5)).toBe('#805800') + expect(shade('#000000', 1.5)).toBe('#808080') + expect(shade('#0f0', 1)).toBe('#00ff00') + expect(shade('nope', 0.5)).toBe('nope') + expect(screenBackground('#ffb000')).toBe('#130d00') + }) + + it('wrapped continuation rows keep the source line kind', () => { + const rows = wrapRows(['$ a very long command line typed here'], 12) + expect(rows.length).toBeGreaterThan(1) + rows.forEach((r) => expect(r.kind).toBe('prompt')) + }) +}) + describe('phosphorColor', () => { it('brightens the default accent and passes custom colors through', () => { expect(phosphorColor('#22c55e')).toBe('#39ff6e') @@ -57,13 +87,13 @@ describe('drawScreen', () => { const lines = Array.from({ length: 40 }, (_, i) => `line ${i}`) drawScreen({ ctx, lines, color: '#39ff6e', cursorVisible: true, power: true }) - expect(ctx.fillRects[0]).toMatchObject({ x: 0, y: 0, w: SCREEN.width, h: SCREEN.height, style: SCREEN_BG_ON }) + expect(ctx.fillRects[0]).toMatchObject({ x: 0, y: 0, w: SCREEN.width, h: SCREEN.height, style: screenBackground('#39ff6e') }) const maxRows = Math.floor((SCREEN.height - SCREEN.margin * 2) / SCREEN.lineHeight) expect(ctx.texts).toHaveLength(maxRows) expect(ctx.texts[ctx.texts.length - 1].text).toBe('line 39') expect(ctx.texts[0].y).toBe(SCREEN.margin) - const cursor = ctx.fillRects.find((r) => r.style === '#39ff6e') + const cursor = ctx.fillRects.find((r) => r.style === shade('#39ff6e', 1.35)) expect(cursor).toBeDefined() expect(cursor!.x).toBe(SCREEN.margin + 'line 39'.length * 10 + 2) }) @@ -71,7 +101,7 @@ describe('drawScreen', () => { it('draws no cursor when it is blinked off', () => { const ctx = makeCtx() drawScreen({ ctx, lines: ['$ '], color: '#39ff6e', cursorVisible: false, power: true }) - expect(ctx.fillRects.some((r) => r.style === '#39ff6e')).toBe(false) + expect(ctx.fillRects.some((r) => r.style === shade('#39ff6e', 1.35))).toBe(false) }) it('paints only dead glass when powered off', () => { diff --git a/src/components/terminal/screenTexture.ts b/src/components/terminal/screenTexture.ts index 508abd0..e3aede6 100644 --- a/src/components/terminal/screenTexture.ts +++ b/src/components/terminal/screenTexture.ts @@ -18,23 +18,78 @@ export const SCREEN_BG_OFF = '#050705' /** The site's default accent reads dull on a CRT; nudge it toward phosphor green */ export const phosphorColor = (cssColor: string) => (cssColor === '#22c55e' ? '#39ff6e' : cssColor) -/** Hard-wraps each line to `cols` characters, breaking at the last space when there is one */ -export function wrapLines(lines: string[], cols: number): string[] { - const out: string[] = [] +const hexToRgb = (hex: string): [number, number, number] | null => { + const m = hex.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i) + if (!m) return null + const h = m[1].length === 3 ? m[1].split('').map((c) => c + c).join('') : m[1] + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] +} + +const rgbToHex = (r: number, g: number, b: number) => + '#' + [r, g, b].map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('') + +/** Scales a hex colour toward black (k<1) or white (k>1 mixes toward white by k-1) */ +export function shade(hex: string, k: number): string { + const rgb = hexToRgb(hex) + if (!rgb) return hex + if (k <= 1) return rgbToHex(rgb[0] * k, rgb[1] * k, rgb[2] * k) + const t = Math.min(1, k - 1) + return rgbToHex(rgb[0] + (255 - rgb[0]) * t, rgb[1] + (255 - rgb[1]) * t, rgb[2] + (255 - rgb[2]) * t) +} + +/** Unlit raster tint for the current phosphor colour */ +export const screenBackground = (color: string) => shade(color, 0.075) + +export type LineKind = 'prompt' | 'output' | 'error' | 'heading' + +const ERROR_RE = /not found|nothing called|failed|error|unknown|expected|missing|no such|cannot|invalid|permission denied/i + +/** Rough syntax colouring for the buffer: what the shell typed, what went wrong, section headers */ +export function classifyLine(text: string): LineKind { + if (text.startsWith('$ ')) return 'prompt' + if (ERROR_RE.test(text)) return 'error' + // "Usage:" style labels, or a lone capitalised word such as a category name + if (/^[^\s].{0,40}:$/.test(text) || /^[A-Z][A-Za-z]{1,13}$/.test(text)) return 'heading' + return 'output' +} + +/** Ink colour per line kind, derived from the phosphor colour */ +export function inkFor(kind: LineKind, color: string): string { + switch (kind) { + case 'prompt': + return shade(color, 1.35) + case 'error': + return '#ff7b72' + case 'heading': + return shade(color, 1.6) + default: + return color + } +} + +export type WrappedRow = { text: string; kind: LineKind } + +/** Hard-wraps each line to `cols` characters (breaking at the last space); every row keeps its source line's kind */ +export function wrapRows(lines: string[], cols: number): WrappedRow[] { + const out: WrappedRow[] = [] const width = Math.max(1, cols) lines.forEach((line) => { + const kind = classifyLine(line) let rest = line while (rest.length > width) { const cut = rest.lastIndexOf(' ', width) const at = cut > 0 ? cut : width - out.push(rest.slice(0, at)) + out.push({ text: rest.slice(0, at), kind }) rest = rest.slice(at).replace(/^ /, '') } - out.push(rest) + out.push({ text: rest, kind }) }) return out } +/** Hard-wraps each line to `cols` characters, breaking at the last space when there is one */ +export const wrapLines = (lines: string[], cols: number): string[] => wrapRows(lines, cols).map((r) => r.text) + export type DrawScreenOptions = { ctx: CanvasRenderingContext2D lines: string[] @@ -49,7 +104,7 @@ export function drawScreen({ ctx, lines, color, cursorVisible, power }: DrawScre ctx.save() ctx.shadowBlur = 0 - ctx.fillStyle = power ? SCREEN_BG_ON : SCREEN_BG_OFF + ctx.fillStyle = power ? screenBackground(color) : SCREEN_BG_OFF ctx.fillRect(0, 0, width, height) if (power) { @@ -59,18 +114,27 @@ export function drawScreen({ ctx, lines, color, cursorVisible, power }: DrawScre const cols = Math.floor((width - margin * 2) / charWidth) const maxRows = Math.floor((height - margin * 2) / lineHeight) - const wrapped = wrapLines(lines, cols) - const visible = wrapped.slice(-maxRows) + const visible = wrapRows(lines, cols).slice(-maxRows) - ctx.fillStyle = color - ctx.shadowColor = color ctx.shadowBlur = 12 - visible.forEach((text, row) => { - ctx.fillText(text, margin, margin + row * lineHeight) + visible.forEach(({ text, kind }, row) => { + const ink = inkFor(kind, color) + ctx.fillStyle = ink + ctx.shadowColor = ink + if (kind === 'prompt') { + // Dim prompt glyph, bright command + ctx.fillStyle = shade(color, 0.7) + ctx.fillText('$', margin, margin + row * lineHeight) + ctx.fillStyle = ink + ctx.fillText(text.slice(1), margin + charWidth, margin + row * lineHeight) + } else { + ctx.fillText(text, margin, margin + row * lineHeight) + } }) + ctx.fillStyle = shade(color, 1.35) if (cursorVisible && visible.length > 0) { - const last = visible[visible.length - 1] + const last = visible[visible.length - 1].text const x = margin + last.length * charWidth const y = margin + (visible.length - 1) * lineHeight ctx.fillRect(x + 2, y + 2, charWidth - 2, lineHeight - 8) diff --git a/src/screens/ChatMe.tsx b/src/screens/ChatMe.tsx index d57f3ca..4f15870 100644 --- a/src/screens/ChatMe.tsx +++ b/src/screens/ChatMe.tsx @@ -11,6 +11,7 @@ import { initialChatStreamState, ChatStreamState, } from '../services/aguiStream' +import { uid } from '../utils/uid' export type Msg = { role: 'user' | 'assistant' @@ -71,7 +72,7 @@ export default function ChatMe() { setIsTyping(true) try { if (!sessionRef.current) { - sessionRef.current = crypto.randomUUID() + sessionRef.current = uid() } const start = performance.now() const res = await fetch(API_ENDPOINT, { diff --git a/src/screens/IdeaGenerator.tsx b/src/screens/IdeaGenerator.tsx index 81b7ef4..e25f5b4 100644 --- a/src/screens/IdeaGenerator.tsx +++ b/src/screens/IdeaGenerator.tsx @@ -6,6 +6,7 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { Lightbulb, Sparkles, Star, Zap, ArrowRight, Command, Copy, Check, ChevronDown, Brain, MessageSquare, Wand2 } from 'lucide-react' import { markdownComponents } from '../components/MarkdownComponents' +import { uid } from '../utils/uid' export type SavedIdea = { id: string; idea: string; description: string; savedAt: number } @@ -528,7 +529,7 @@ export default function IdeaGenerator() { return } - const id = crypto.randomUUID() + const id = uid() const next: SavedIdea = { id, idea: generatedIdea, diff --git a/src/utils/audio.test.ts b/src/utils/audio.test.ts index 9e60e0b..16918d0 100644 --- a/src/utils/audio.test.ts +++ b/src/utils/audio.test.ts @@ -5,7 +5,6 @@ import { playClick, playPowerOn, playQuack, - playMeow, playFanfare, startParty, stopParty, @@ -196,13 +195,12 @@ describe('audio', () => { }) describe('room sounds', () => { - it('quack, meow and fanfare each schedule oscillators', () => { + it('quack and fanfare each schedule oscillators', () => { playQuack() - playMeow() playFanfare() const ctx = MockAudioContext.instances[0] - // 2 + 2 + 4 notes - expect(ctx.createOscillator).toHaveBeenCalledTimes(8) + // 2 + 4 notes + expect(ctx.createOscillator).toHaveBeenCalledTimes(6) expect(ctx.resume).toHaveBeenCalled() }) diff --git a/src/utils/audio.ts b/src/utils/audio.ts index b5dd3c5..7abe32f 100644 --- a/src/utils/audio.ts +++ b/src/utils/audio.ts @@ -160,19 +160,6 @@ export function playQuack(): void { } } -/** Sleepy cat: a soft rising-falling mew */ -export function playMeow(): void { - try { - const ctx = liveContext() - if (!ctx) return - const t = ctx.currentTime - tone(ctx, { type: 'triangle', from: 600, to: 900, at: t, duration: 0.22, peak: 0.09 }) - tone(ctx, { type: 'triangle', from: 900, to: 520, at: t + 0.22, duration: 0.3, peak: 0.08 }) - } catch { - // ignore - } -} - /** "You found everything" fanfare */ export function playFanfare(): void { try { diff --git a/src/utils/uid.ts b/src/utils/uid.ts new file mode 100644 index 0000000..e0534ee --- /dev/null +++ b/src/utils/uid.ts @@ -0,0 +1,9 @@ +/** + * Random id that also works outside secure contexts (plain http on a LAN), + * where `crypto.randomUUID` is undefined. + */ +export function uid(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID() + const hex = (n: number) => Math.floor(Math.random() * 16 ** n).toString(16).padStart(n, '0') + return `${hex(8)}-${hex(4)}-4${hex(3)}-${hex(4)}-${hex(12)}` +} From 1902273638e8e4da05102656c928c866862f176b Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Thu, 17 Sep 2026 15:20:18 +0000 Subject: [PATCH 4/8] feat(3d): synthesised key clicks, WebGL-loss fallback, roomier corkboard wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Key clicks are now synthesised in Web Audio (bandpassed noise snap + tuned triangle body + high tick) instead of the mp3: no asset, no first-click latency, and each key has character — Enter is a deep thunk, Space a wide one, letters a crisp tap, with slight per-key pitch variation. The unused sample is removed. - Losing the WebGL context (or the browser refusing one) shows a "Reload scene" fallback instead of the Next error overlay. The earlier `webglcontextlost` preventDefault is gone: it kept dev-reload contexts alive until Firefox hit its context cap and killed the fresh one. - Room is deeper (front wall at z 5.2) so the contact corkboard no longer sits on the last project poster; post-its are larger and show `@handle` / `in/name` instead of overflowing URLs. Co-Authored-By: Claude Opus 5 (1M context) --- public/spacebar-click-keyboard-199448.mp3 | Bin 48384 -> 0 bytes src/components/terminal/TerminalScene.tsx | 82 +++++++++--- src/components/terminal/exhibits.ts | 6 +- src/components/terminal/roomTextures.test.ts | 14 +- src/components/terminal/roomTextures.ts | 23 +++- src/hooks/useKeyClick.test.ts | 43 +++--- src/utils/audio.test.ts | 81 +++++++----- src/utils/audio.ts | 132 +++++++++++++------ 8 files changed, 260 insertions(+), 121 deletions(-) delete mode 100644 public/spacebar-click-keyboard-199448.mp3 diff --git a/public/spacebar-click-keyboard-199448.mp3 b/public/spacebar-click-keyboard-199448.mp3 deleted file mode 100644 index 9fe26440ecd729887ca5524ab89c03ac20765343..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48384 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 diff --git a/src/components/terminal/TerminalScene.tsx b/src/components/terminal/TerminalScene.tsx index 8dd0c3d..9b251b8 100644 --- a/src/components/terminal/TerminalScene.tsx +++ b/src/components/terminal/TerminalScene.tsx @@ -2,7 +2,7 @@ import { OrbitControls, useGLTF } from '@react-three/drei' import { Canvas, useFrame, useThree } from '@react-three/fiber' -import React, { Suspense, useCallback, useMemo, useEffect, useRef, useSyncExternalStore } from 'react' +import React, { Suspense, useCallback, useMemo, useEffect, useRef, useState, useSyncExternalStore } from 'react' import * as THREE from 'three' import { EffectComposer } from '@react-three/postprocessing' import CRTEffects from './CRTEffects' @@ -423,9 +423,41 @@ function SceneContent({ ) } -/** Keeps a lost WebGL context from tearing the page down; three restores it on `webglcontextrestored` */ -const handleCreated = ({ gl }: { gl: THREE.WebGLRenderer }) => { - gl.domElement.addEventListener('webglcontextlost', (event) => event.preventDefault()) +/** Shown when the browser refuses or drops the WebGL context; the button remounts the canvas */ +function SceneFallback({ onRetry }: { onRetry: () => void }) { + return ( +
+ The 3D scene lost its WebGL context. + + + or use the 2D terminal + +
+ ) +} + +type BoundaryProps = { children: React.ReactNode; onError: () => void } + +/** Catches renderer construction errors (e.g. a context the browser has already lost) */ +class SceneErrorBoundary extends React.Component { + state = { failed: false } + + static getDerivedStateFromError() { + return { failed: true } + } + + componentDidCatch() { + this.props.onError() + } + + render() { + return this.state.failed ? null : this.props.children + } } /** @@ -438,6 +470,20 @@ export default function TerminalScene({ buffer, enableEffects, cameraConfig: cus const screensaver = useSceneStore((s) => s.screensaver) const screenTexture = useTerminalTexture(buffer, power, screensaver) const cameraConfig = { ...DEFAULT_CAMERA_CONFIG, ...customConfig } + // Bumping the generation remounts the Canvas with a fresh context after a loss + const [generation, setGeneration] = useState(0) + const [lost, setLost] = useState(false) + const retry = useCallback(() => { + setLost(false) + setGeneration((g) => g + 1) + }, []) + const markLost = useCallback(() => setLost(true), []) + const handleCreated = useCallback( + ({ gl }: { gl: THREE.WebGLRenderer }) => { + gl.domElement.addEventListener('webglcontextlost', markLost) + }, + [markLost] + ) if (!mounted) { return ( @@ -447,18 +493,22 @@ export default function TerminalScene({ buffer, enableEffects, cameraConfig: cus ) } + if (lost) return + return ( - - - - - + + + + + + + ) } diff --git a/src/components/terminal/exhibits.ts b/src/components/terminal/exhibits.ts index e6c28f7..642a496 100644 --- a/src/components/terminal/exhibits.ts +++ b/src/components/terminal/exhibits.ts @@ -32,7 +32,7 @@ export const ROOM = { minX: -3.2, maxX: 3.2, minZ: -0.8, - maxZ: 4.4, + maxZ: 5.2, } as const /** Footprint of the desk (with a little margin) that the player cannot walk into */ @@ -215,9 +215,9 @@ const contactExhibit = ({ contact }: Profile): Exhibit => ({ { label: 'Email', href: `mailto:${contact.email}` }, { label: 'Website', href: contact.website }, ], - position: [LEFT_X, 1.0, 4.0], + position: [LEFT_X, 1.0, 4.45], rotationY: Math.PI / 2, - size: [0.8, 0.65], + size: [0.95, 0.72], }) /** diff --git a/src/components/terminal/roomTextures.test.ts b/src/components/terminal/roomTextures.test.ts index 6166ff0..b045e77 100644 --- a/src/components/terminal/roomTextures.test.ts +++ b/src/components/terminal/roomTextures.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -import { wrapText, paintPoster, paintBoard, paintSign, paintCorkboard, paintSpine } from './roomTextures' +import { wrapText, paintPoster, paintBoard, paintSign, paintCorkboard, paintSpine, shortLink } from './roomTextures' import { paintClock, reykjavikTime } from './clockTexture' import type { Exhibit } from './exhibits' @@ -91,14 +91,22 @@ describe('painters', () => { expect(ctx.texts).toEqual(['Tómas', 'Engineer']) }) - it('corkboard paints a card per link without the scheme', () => { + it('corkboard paints a card per link with a short handle', () => { const ctx = makeCtx() paintCorkboard({ ...exhibit, kind: 'corkboard' })(ctx, 768, 624) expect(ctx.texts).toContain('GitHub') - expect(ctx.texts).toContain('github.com/x/y') + expect(ctx.texts).toContain('@x') expect(ctx.texts).toContain('me@example.com') }) + it('shortLink fits handles and hosts on a post-it', () => { + expect(shortLink('https://github.com/TomasPalsson')).toBe('@TomasPalsson') + expect(shortLink('https://www.linkedin.com/in/t%C3%B3mas-p%C3%A1lsson-2278613a7/')).toBe('in/tómas') + expect(shortLink('mailto:tomas@p5.is')).toBe('tomas@p5.is') + expect(shortLink('https://tomasari.is')).toBe('tomasari.is') + expect(shortLink('https://example.com/a/very/long/path/indeed')).toHaveLength(20) + }) + it('spine paints the label, shortening it to fit', () => { const ctx = makeCtx() paintSpine('TypeScript', '#333')(ctx, 64, 384) diff --git a/src/components/terminal/roomTextures.ts b/src/components/terminal/roomTextures.ts index a6b41de..ce61e14 100644 --- a/src/components/terminal/roomTextures.ts +++ b/src/components/terminal/roomTextures.ts @@ -166,6 +166,16 @@ export const paintSign = const CARD_COLORS = ['#fde68a', '#fca5a5', '#a5f3fc', '#bbf7d0'] +/** What fits on a post-it: "@handle" for GitHub/LinkedIn, otherwise the bare host/path, clipped */ +export function shortLink(href: string, max = 20): string { + const bare = href.replace(/^(https?:\/\/|mailto:)/, '').replace(/^www\./, '').replace(/\/$/, '') + const gh = bare.match(/^github\.com\/([^/]+)/) + if (gh) return `@${gh[1]}` + const li = bare.match(/^linkedin\.com\/in\/([^/]+)/) + if (li) return `in/${decodeURIComponent(li[1]).split('-')[0]}` + return bare.length > max ? bare.slice(0, max - 1) + '…' : bare +} + /** Corkboard with a pinned card per contact link */ export const paintCorkboard = (exhibit: Exhibit): Painter => @@ -182,11 +192,11 @@ export const paintCorkboard = ctx.textBaseline = 'alphabetic' const links = exhibit.links ?? [] - const cardW = w * 0.36 - const cardH = h * 0.32 + const cardW = w * 0.42 + const cardH = h * 0.34 links.slice(0, 4).forEach((link, i) => { - const x = w * 0.1 + (i % 2) * (w * 0.44) - const y = h * 0.12 + Math.floor(i / 2) * (h * 0.42) + const x = w * 0.06 + (i % 2) * (w * 0.46) + const y = h * 0.1 + Math.floor(i / 2) * (h * 0.44) ctx.save() ctx.translate(x + cardW / 2, y + cardH / 2) ctx.rotate(((i * 37) % 7 - 3) * 0.03) @@ -200,10 +210,9 @@ export const paintCorkboard = ctx.font = `700 ${h * 0.075}px ${HAND}` ctx.textAlign = 'center' ctx.fillText(link.label, 0, h * 0.02) - ctx.font = `${h * 0.045}px ${MONO}` + ctx.font = `${h * 0.036}px ${MONO}` ctx.fillStyle = '#374151' - const shown = link.href.replace(/^(https?:\/\/|mailto:)/, '').replace(/\/$/, '') - ctx.fillText(shown.length > 26 ? shown.slice(0, 24) + '…' : shown, 0, h * 0.1) + ctx.fillText(shortLink(link.href), 0, h * 0.1) ctx.restore() }) } diff --git a/src/hooks/useKeyClick.test.ts b/src/hooks/useKeyClick.test.ts index 15084fe..0303d18 100644 --- a/src/hooks/useKeyClick.test.ts +++ b/src/hooks/useKeyClick.test.ts @@ -4,11 +4,16 @@ import useKeyClick from './useKeyClick' import { resetAudioForTests } from '../utils/audio' class MockAudioContext { + static instances: MockAudioContext[] = [] + state = 'suspended' currentTime = 0 destination = {} resume = vi.fn().mockResolvedValue(undefined) - decodeAudioData = vi.fn().mockResolvedValue({ duration: 1.5 }) + + constructor() { + MockAudioContext.instances.push(this) + } createBufferSource = vi.fn(() => ({ buffer: null, @@ -26,6 +31,19 @@ class MockAudioContext { connect: vi.fn(), })) + sampleRate = 48000 + + createBuffer = vi.fn((_channels: number, length: number) => ({ + getChannelData: () => new Float32Array(length), + })) + + createBiquadFilter = vi.fn(() => ({ + type: 'lowpass', + frequency: { value: 0 }, + Q: { value: 1 }, + connect: vi.fn(), + })) + createOscillator = vi.fn(() => ({ type: 'sine', frequency: { @@ -43,11 +61,9 @@ describe('useKeyClick', () => { beforeEach(() => { originalAudioContext = window.AudioContext + MockAudioContext.instances = [] // @ts-expect-error - Mocking AudioContext constructor global.AudioContext = MockAudioContext - global.fetch = vi.fn().mockResolvedValue({ - arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), - }) as unknown as typeof fetch resetAudioForTests() }) @@ -74,34 +90,29 @@ describe('useKeyClick', () => { expect(firstRef).toBe(secondRef) }) - it('warms up on mount, fetching the sample before any keypress', async () => { + it('warms up on mount, building the audio context before any keypress', () => { renderHook(() => useKeyClick()) - - await vi.waitFor(() => { - expect(global.fetch).toHaveBeenCalledTimes(1) - }) + expect(MockAudioContext.instances).toHaveLength(1) + expect(MockAudioContext.instances[0].createBuffer).toHaveBeenCalledTimes(1) }) - it('plays a click when called', async () => { + it('plays a click when called', () => { const { result } = renderHook(() => useKeyClick()) act(() => { result.current('a') }) - await vi.waitFor(() => { - expect(global.fetch).toHaveBeenCalled() - }) + expect(MockAudioContext.instances[0].createBufferSource).toHaveBeenCalledTimes(1) }) - it('does not warm up or play when disabled', async () => { + it('does not warm up or play when disabled', () => { const { result } = renderHook(() => useKeyClick(false)) act(() => { result.current('a') }) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(global.fetch).not.toHaveBeenCalled() + expect(MockAudioContext.instances).toHaveLength(0) }) }) diff --git a/src/utils/audio.test.ts b/src/utils/audio.test.ts index 16918d0..7fd57e3 100644 --- a/src/utils/audio.test.ts +++ b/src/utils/audio.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { shouldClick, clickRateFor, + clickVoiceFor, + warmUp, playClick, playPowerOn, playQuack, @@ -27,9 +29,21 @@ class MockAudioContext { currentTime = 0 destination = {} resume = vi.fn().mockResolvedValue(undefined) - decodeAudioData = vi.fn().mockResolvedValue({ duration: 1.5 }) sources: MockBufferSource[] = [] + sampleRate = 48000 + + createBuffer = vi.fn((_channels: number, length: number) => ({ + getChannelData: () => new Float32Array(length), + })) + + createBiquadFilter = vi.fn(() => ({ + type: 'lowpass', + frequency: { value: 0 }, + Q: { value: 1 }, + connect: vi.fn(), + })) + constructor() { MockAudioContext.instances.push(this) } @@ -74,9 +88,6 @@ describe('audio', () => { MockAudioContext.instances = [] // @ts-expect-error - Mocking AudioContext constructor global.AudioContext = MockAudioContext - global.fetch = vi.fn().mockResolvedValue({ - arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), - }) as unknown as typeof fetch resetAudioForTests() }) @@ -129,57 +140,55 @@ describe('audio', () => { }) describe('playClick', () => { - it('fetches the sample once, creates a source per call, and resumes a suspended context', async () => { + it('synthesises snap, body and tick per call and resumes a suspended context', () => { playClick('a') playClick('a') playClick('a') - await vi.waitFor(() => { - const ctx = MockAudioContext.instances[0] - expect(ctx.sources).toHaveLength(3) - }) - const ctx = MockAudioContext.instances[0] - expect(global.fetch).toHaveBeenCalledTimes(1) expect(ctx.resume).toHaveBeenCalled() - for (const source of ctx.sources) { - expect(source.start).toHaveBeenCalledWith(0, 0, 0.3) - } + // One noise source per click, the noise buffer built once + expect(ctx.sources).toHaveLength(3) + expect(ctx.createBuffer).toHaveBeenCalledTimes(1) + // Body + tick oscillators per click + expect(ctx.createOscillator).toHaveBeenCalledTimes(6) + expect(ctx.createBiquadFilter).toHaveBeenCalledTimes(6) + for (const source of ctx.sources) expect(source.start).toHaveBeenCalled() }) - it('creates no source for modifier keys', async () => { + it('creates nothing for modifier keys', () => { playClick('Shift') - await new Promise(resolve => setTimeout(resolve, 0)) - expect(global.fetch).not.toHaveBeenCalled() + expect(MockAudioContext.instances).toHaveLength(0) }) - it('retries fetch after a decode failure', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + it('gives Enter a deeper, longer voice than a letter', () => { + expect(clickVoiceFor('Enter').body).toBeLessThan(clickVoiceFor('a').body) + expect(clickVoiceFor('Enter').ring).toBeGreaterThan(clickVoiceFor('a').ring) + expect(clickVoiceFor(' ').snap).toBeGreaterThan(clickVoiceFor('a').snap) + }) - class FailingOnceContext extends MockAudioContext { + it('logs and survives a broken node graph', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + class BrokenContext extends MockAudioContext { constructor() { super() - this.decodeAudioData = vi - .fn() - .mockRejectedValueOnce(new Error('decode failed')) - .mockResolvedValue({ duration: 1.5 }) + this.createBiquadFilter = vi.fn(() => { + throw new Error('no filters here') + }) } } // @ts-expect-error - Mocking AudioContext constructor - global.AudioContext = FailingOnceContext - - playClick('a') - await vi.waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith('Error playing sound:', expect.any(Error)) - }) - + global.AudioContext = BrokenContext playClick('a') - await vi.waitFor(() => { - expect(global.fetch).toHaveBeenCalledTimes(2) - }) - + expect(consoleSpy).toHaveBeenCalledWith('Error playing sound:', expect.any(Error)) consoleSpy.mockRestore() }) + + it('warmUp builds the context and noise before any keypress', () => { + warmUp() + expect(MockAudioContext.instances).toHaveLength(1) + expect(MockAudioContext.instances[0].createBuffer).toHaveBeenCalledTimes(1) + }) }) describe('playPowerOn', () => { @@ -240,7 +249,7 @@ describe('audio', () => { playClick('a') playPowerOn() await new Promise(resolve => setTimeout(resolve, 0)) - expect(global.fetch).not.toHaveBeenCalled() + expect(MockAudioContext.instances).toHaveLength(0) }) }) }) diff --git a/src/utils/audio.ts b/src/utils/audio.ts index 7abe32f..4e6dfc0 100644 --- a/src/utils/audio.ts +++ b/src/utils/audio.ts @@ -1,6 +1,3 @@ -export const CLICK_SAMPLE_URL = '/spacebar-click-keyboard-199448.mp3' -export const CLICK_DURATION = 0.3 // seconds of the sample to play - /** Keys longer than one character that still make a click (modifiers do not) */ export const SOUNDED_SPECIAL_KEYS: ReadonlySet = new Set([ 'Enter', @@ -11,7 +8,7 @@ export const SOUNDED_SPECIAL_KEYS: ReadonlySet = new Set([ ]) let context: AudioContext | null = null -let clickBuffer: Promise | null = null +let noiseBuffer: AudioBuffer | null = null export function isAudioSupported(): boolean { return typeof window !== 'undefined' && !!(window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext) @@ -28,25 +25,22 @@ export function getAudioContext(): AudioContext | null { return context } -function loadClickBuffer(ctx: AudioContext): Promise { - if (!clickBuffer) { - clickBuffer = fetch(CLICK_SAMPLE_URL) - .then(response => response.arrayBuffer()) - .then(data => ctx.decodeAudioData(data)) - .catch(err => { - clickBuffer = null - throw err - }) +/** 60 ms of white noise, built once; the raw material for every click transient */ +function getNoise(ctx: AudioContext): AudioBuffer { + if (!noiseBuffer) { + const length = Math.floor(ctx.sampleRate * 0.06) + noiseBuffer = ctx.createBuffer(1, length, ctx.sampleRate) + const data = noiseBuffer.getChannelData(0) + for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1 } - - return clickBuffer + return noiseBuffer } +/** Creates the context and the noise buffer ahead of the first keypress; swallows all errors */ export function warmUp(): void { try { const ctx = getAudioContext() - if (!ctx) return - loadClickBuffer(ctx).catch(() => {}) + if (ctx) getNoise(ctx) } catch { // ignore } @@ -58,6 +52,7 @@ export function shouldClick(key?: string): boolean { return SOUNDED_SPECIAL_KEYS.has(key) } +/** Per-key pitch variation so a run of keystrokes does not sound like one sample on repeat */ export function clickRateFor(key?: string): number { if (key === 'Enter') return 0.8 if (key === 'Backspace') return 1.15 @@ -65,32 +60,89 @@ export function clickRateFor(key?: string): number { return 0.95 + Math.random() * 0.15 } +type ClickVoice = { + /** Fundamental of the "thock" body in Hz */ + body: number + /** How long the body rings, seconds */ + ring: number + /** How long the noise transient lasts, seconds */ + snap: number + /** Overall loudness */ + level: number +} + +/** Mechanical keyboard character per key: Enter is a deep stabiliser thunk, Space a wide one, letters a crisp tap */ +export function clickVoiceFor(key?: string): ClickVoice { + if (key === 'Enter') return { body: 150, ring: 0.1, snap: 0.035, level: 1 } + if (key === ' ') return { body: 175, ring: 0.085, snap: 0.045, level: 0.9 } + if (key === 'Backspace') return { body: 250, ring: 0.05, snap: 0.02, level: 0.7 } + return { body: 215, ring: 0.055, snap: 0.02, level: 0.75 } +} + +/** + * Synthesised key click: a filtered noise snap (the switch), a short tuned body (the case + * resonance) and a tiny bright tick (the keycap). Every call is its own set of nodes, so + * fast typing overlaps naturally. + */ export function playClick(key?: string): void { if (!shouldClick(key)) return - - const ctx = getAudioContext() + const ctx = liveContext() if (!ctx) return - - if (ctx.state === 'suspended') { - ctx.resume().catch(() => {}) + try { + const t = ctx.currentTime + const rate = clickRateFor(key) + const voice = clickVoiceFor(key) + const master = ctx.createGain() + master.gain.value = 0.32 * voice.level + master.connect(ctx.destination) + + // Switch: bandpassed noise with a very fast decay + const snap = ctx.createBufferSource() + snap.buffer = getNoise(ctx) + const snapFilter = ctx.createBiquadFilter() + snapFilter.type = 'bandpass' + snapFilter.frequency.value = 2600 * rate + snapFilter.Q.value = 0.9 + const snapGain = ctx.createGain() + snapGain.gain.setValueAtTime(1, t) + snapGain.gain.exponentialRampToValueAtTime(0.001, t + voice.snap) + snap.connect(snapFilter) + snapFilter.connect(snapGain) + snapGain.connect(master) + snap.start(t, 0, voice.snap + 0.01) + + // Body: triangle that drops in pitch as it dies, low-passed so it thumps rather than buzzes + const body = ctx.createOscillator() + body.type = 'triangle' + body.frequency.setValueAtTime(voice.body * rate, t) + body.frequency.exponentialRampToValueAtTime(voice.body * rate * 0.7, t + voice.ring) + const bodyFilter = ctx.createBiquadFilter() + bodyFilter.type = 'lowpass' + bodyFilter.frequency.value = 900 + const bodyGain = ctx.createGain() + bodyGain.gain.setValueAtTime(0.0001, t) + bodyGain.gain.exponentialRampToValueAtTime(0.9, t + 0.004) + bodyGain.gain.exponentialRampToValueAtTime(0.001, t + voice.ring) + body.connect(bodyFilter) + bodyFilter.connect(bodyGain) + bodyGain.connect(master) + body.start(t) + body.stop(t + voice.ring + 0.02) + + // Keycap: a whisper of high sine so the click reads as plastic, not wood + const tick = ctx.createOscillator() + tick.type = 'sine' + tick.frequency.setValueAtTime(4200 * rate, t) + const tickGain = ctx.createGain() + tickGain.gain.setValueAtTime(0.12, t) + tickGain.gain.exponentialRampToValueAtTime(0.001, t + 0.012) + tick.connect(tickGain) + tickGain.connect(master) + tick.start(t) + tick.stop(t + 0.02) + } catch (err) { + console.error('Error playing sound:', err) } - - loadClickBuffer(ctx) - .then(buffer => { - const source = ctx.createBufferSource() - source.buffer = buffer - source.playbackRate.value = clickRateFor(key) - - const gain = ctx.createGain() - gain.gain.value = 0.6 - - source.connect(gain) - gain.connect(ctx.destination) - source.start(0, 0, CLICK_DURATION) - }) - .catch(err => { - console.error('Error playing sound:', err) - }) } export function playPowerOn(): void { @@ -217,5 +269,5 @@ export function isPartyPlaying(): boolean { export function resetAudioForTests(): void { stopParty() context = null - clickBuffer = null + noiseBuffer = null } From b6e68438813bcc3b5592e2738a58a23c4fd46f84 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Thu, 17 Sep 2026 15:35:57 +0000 Subject: [PATCH 5/8] feat(3d): clickier synthesised switch sound; Q closes cards and leaves walk mode Esc is reserved by the browser in fullscreen and pointer lock (it exits fullscreen before the page sees it), so Q is the reliable back key everywhere: closes a card in orbit or walk mode, leaves walk mode. It is swallowed so the terminal does not type it. Esc still works where the browser passes it on. The click is brighter and sharper: 5 kHz noise leaf, high-passed square housing, 7 kHz keycap ping, plus a softer release click ~60 ms later. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/terminal/ExhibitOverlay.tsx | 17 ++++-- src/components/terminal/TerminalCanvas.tsx | 4 +- src/hooks/useKeyClick.test.ts | 3 +- src/utils/audio.test.ts | 8 +-- src/utils/audio.ts | 65 ++++++++++++++-------- 5 files changed, 60 insertions(+), 37 deletions(-) diff --git a/src/components/terminal/ExhibitOverlay.tsx b/src/components/terminal/ExhibitOverlay.tsx index f70e1bb..ff5a6fd 100644 --- a/src/components/terminal/ExhibitOverlay.tsx +++ b/src/components/terminal/ExhibitOverlay.tsx @@ -48,13 +48,18 @@ export function ExhibitCard() { const exhibit = exhibits.find((e) => e.id === focused) const tip = useMemo(() => (focused === 'duck' ? duckTip() : null), [focused]) + // Q closes the card (and is not typed into the terminal); Esc too when the browser + // passes it on — in fullscreen or pointer lock the browser keeps Esc for itself useEffect(() => { if (!focused || walk) return const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') closeCard() + if (e.code !== 'Escape' && e.code !== 'KeyQ') return + e.stopImmediatePropagation() + e.preventDefault() + closeCard() } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) }, [focused, walk]) if (!exhibit) return null @@ -74,7 +79,7 @@ export function ExhibitCard() {

{exhibit.title}

{exhibit.subtitle &&

{exhibit.subtitle}

}
-
@@ -90,7 +95,7 @@ export function ExhibitCard() { )}

- {walk ? 'E or Esc — back to walking · 1-4 open links' : 'Esc or click away — back to the desk'} + {walk ? 'E or Q — back to walking · 1-4 open links' : 'Q or click away — back to the desk'}

@@ -114,7 +119,7 @@ export function WalkHud() { )}
- WASD move · mouse look · shift run · E inspect · Q or esc leave + WASD move · mouse look · shift run · E inspect · Q leave
) diff --git a/src/components/terminal/TerminalCanvas.tsx b/src/components/terminal/TerminalCanvas.tsx index 0535720..f3de90f 100644 --- a/src/components/terminal/TerminalCanvas.tsx +++ b/src/components/terminal/TerminalCanvas.tsx @@ -118,7 +118,7 @@ export default function TerminalCanvas() {
- @@ -186,7 +186,7 @@ export default function TerminalCanvas() { 3D Mode
- {walk ? 'WASD to walk • mouse to look • E to inspect • Q or Esc to leave' : 'drag to orbit • scroll to zoom • click things to inspect them'} + {walk ? 'WASD to walk • mouse to look • E to inspect • Q to leave' : 'drag to orbit • scroll to zoom • click things to inspect them'}
diff --git a/src/hooks/useKeyClick.test.ts b/src/hooks/useKeyClick.test.ts index 0303d18..461988c 100644 --- a/src/hooks/useKeyClick.test.ts +++ b/src/hooks/useKeyClick.test.ts @@ -103,7 +103,8 @@ describe('useKeyClick', () => { result.current('a') }) - expect(MockAudioContext.instances[0].createBufferSource).toHaveBeenCalledTimes(1) + // press + release noise bursts + expect(MockAudioContext.instances[0].createBufferSource).toHaveBeenCalledTimes(2) }) it('does not warm up or play when disabled', () => { diff --git a/src/utils/audio.test.ts b/src/utils/audio.test.ts index 7fd57e3..198b554 100644 --- a/src/utils/audio.test.ts +++ b/src/utils/audio.test.ts @@ -147,12 +147,12 @@ describe('audio', () => { const ctx = MockAudioContext.instances[0] expect(ctx.resume).toHaveBeenCalled() - // One noise source per click, the noise buffer built once - expect(ctx.sources).toHaveLength(3) + // Press + release noise sources per click, the noise buffer built once + expect(ctx.sources).toHaveLength(6) expect(ctx.createBuffer).toHaveBeenCalledTimes(1) - // Body + tick oscillators per click + // Body + tick oscillators per click; snap, body and release filters per click expect(ctx.createOscillator).toHaveBeenCalledTimes(6) - expect(ctx.createBiquadFilter).toHaveBeenCalledTimes(6) + expect(ctx.createBiquadFilter).toHaveBeenCalledTimes(9) for (const source of ctx.sources) expect(source.start).toHaveBeenCalled() }) diff --git a/src/utils/audio.ts b/src/utils/audio.ts index 4e6dfc0..4f8ef1a 100644 --- a/src/utils/audio.ts +++ b/src/utils/audio.ts @@ -71,18 +71,18 @@ type ClickVoice = { level: number } -/** Mechanical keyboard character per key: Enter is a deep stabiliser thunk, Space a wide one, letters a crisp tap */ +/** Clicky-switch character per key: Enter is a heavier, lower click, Space a broader one, letters a sharp tick */ export function clickVoiceFor(key?: string): ClickVoice { - if (key === 'Enter') return { body: 150, ring: 0.1, snap: 0.035, level: 1 } - if (key === ' ') return { body: 175, ring: 0.085, snap: 0.045, level: 0.9 } - if (key === 'Backspace') return { body: 250, ring: 0.05, snap: 0.02, level: 0.7 } - return { body: 215, ring: 0.055, snap: 0.02, level: 0.75 } + if (key === 'Enter') return { body: 240, ring: 0.045, snap: 0.016, level: 1 } + if (key === ' ') return { body: 280, ring: 0.04, snap: 0.02, level: 0.9 } + if (key === 'Backspace') return { body: 380, ring: 0.025, snap: 0.011, level: 0.75 } + return { body: 330, ring: 0.028, snap: 0.012, level: 0.8 } } /** - * Synthesised key click: a filtered noise snap (the switch), a short tuned body (the case - * resonance) and a tiny bright tick (the keycap). Every call is its own set of nodes, so - * fast typing overlaps naturally. + * Synthesised clicky-switch keypress: a bright noise snap (the click leaf), a short biting + * body (the housing), a high ping (the keycap) and a softer release click. Every call is + * its own set of nodes, so fast typing overlaps naturally. */ export function playClick(key?: string): void { if (!shouldClick(key)) return @@ -93,35 +93,35 @@ export function playClick(key?: string): void { const rate = clickRateFor(key) const voice = clickVoiceFor(key) const master = ctx.createGain() - master.gain.value = 0.32 * voice.level + master.gain.value = 0.28 * voice.level master.connect(ctx.destination) - // Switch: bandpassed noise with a very fast decay + // Click leaf: a sharp, bright noise burst — the part that reads as "clicky" const snap = ctx.createBufferSource() snap.buffer = getNoise(ctx) const snapFilter = ctx.createBiquadFilter() snapFilter.type = 'bandpass' - snapFilter.frequency.value = 2600 * rate - snapFilter.Q.value = 0.9 + snapFilter.frequency.value = 5200 * rate + snapFilter.Q.value = 1.6 const snapGain = ctx.createGain() - snapGain.gain.setValueAtTime(1, t) + snapGain.gain.setValueAtTime(1.6, t) snapGain.gain.exponentialRampToValueAtTime(0.001, t + voice.snap) snap.connect(snapFilter) snapFilter.connect(snapGain) snapGain.connect(master) snap.start(t, 0, voice.snap + 0.01) - // Body: triangle that drops in pitch as it dies, low-passed so it thumps rather than buzzes + // Housing: short square body, high-passed so it adds bite instead of thud const body = ctx.createOscillator() - body.type = 'triangle' + body.type = 'square' body.frequency.setValueAtTime(voice.body * rate, t) - body.frequency.exponentialRampToValueAtTime(voice.body * rate * 0.7, t + voice.ring) + body.frequency.exponentialRampToValueAtTime(voice.body * rate * 0.6, t + voice.ring) const bodyFilter = ctx.createBiquadFilter() - bodyFilter.type = 'lowpass' - bodyFilter.frequency.value = 900 + bodyFilter.type = 'highpass' + bodyFilter.frequency.value = 700 const bodyGain = ctx.createGain() bodyGain.gain.setValueAtTime(0.0001, t) - bodyGain.gain.exponentialRampToValueAtTime(0.9, t + 0.004) + bodyGain.gain.exponentialRampToValueAtTime(0.35, t + 0.002) bodyGain.gain.exponentialRampToValueAtTime(0.001, t + voice.ring) body.connect(bodyFilter) bodyFilter.connect(bodyGain) @@ -129,17 +129,34 @@ export function playClick(key?: string): void { body.start(t) body.stop(t + voice.ring + 0.02) - // Keycap: a whisper of high sine so the click reads as plastic, not wood + // Keycap: a bright ping right at the top of the click const tick = ctx.createOscillator() tick.type = 'sine' - tick.frequency.setValueAtTime(4200 * rate, t) + tick.frequency.setValueAtTime(7000 * rate, t) const tickGain = ctx.createGain() - tickGain.gain.setValueAtTime(0.12, t) - tickGain.gain.exponentialRampToValueAtTime(0.001, t + 0.012) + tickGain.gain.setValueAtTime(0.4, t) + tickGain.gain.exponentialRampToValueAtTime(0.001, t + 0.008) tick.connect(tickGain) tickGain.connect(master) tick.start(t) - tick.stop(t + 0.02) + tick.stop(t + 0.015) + + // Release: a quieter second click a moment later, like the leaf springing back + const release = ctx.createBufferSource() + release.buffer = getNoise(ctx) + const releaseFilter = ctx.createBiquadFilter() + releaseFilter.type = 'bandpass' + releaseFilter.frequency.value = 4200 * rate + releaseFilter.Q.value = 1.4 + const releaseGain = ctx.createGain() + const tr = t + 0.055 + Math.random() * 0.02 + releaseGain.gain.setValueAtTime(0.0001, t) + releaseGain.gain.setValueAtTime(0.55, tr) + releaseGain.gain.exponentialRampToValueAtTime(0.001, tr + 0.01) + release.connect(releaseFilter) + releaseFilter.connect(releaseGain) + releaseGain.connect(master) + release.start(tr, 0, 0.02) } catch (err) { console.error('Error playing sound:', err) } From 2bd7ca13635e5c3abd96fb6939838a9c4ad1e904 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Thu, 17 Sep 2026 15:39:49 +0000 Subject: [PATCH 6/8] fix(3d): keyboard-like click from noise strikes; Firefox-safe pointer lock - The keypress is now five noise impulses ringing through resonant filters (click leaf, case, bottom-out thud, then a lighter up-stroke) with no oscillators, so it sounds like plastic and metal rather than a synth - Pointer lock: Firefox only grants it from inside a user-gesture handler, which a React effect is not. Walk mode now stays on when the automatic request is refused and shows a "Click to look around" target that requests the lock directly from its click; the lock state lives in the store (pointerLocked) via the controls' lock/unlock events. Esc/tab switch no longer ends the walk, it just offers the click again Co-Authored-By: Claude Opus 5 (1M context) --- src/components/terminal/ExhibitOverlay.tsx | 14 +++ src/components/terminal/WalkControls.tsx | 39 +++---- src/components/terminal/sceneStore.ts | 3 + src/components/terminal/walkLock.ts | 25 ++++ src/hooks/useKeyClick.test.ts | 4 +- src/utils/audio.test.ts | 19 ++-- src/utils/audio.ts | 126 +++++++++------------ 7 files changed, 124 insertions(+), 106 deletions(-) create mode 100644 src/components/terminal/walkLock.ts diff --git a/src/components/terminal/ExhibitOverlay.tsx b/src/components/terminal/ExhibitOverlay.tsx index ff5a6fd..fee5072 100644 --- a/src/components/terminal/ExhibitOverlay.tsx +++ b/src/components/terminal/ExhibitOverlay.tsx @@ -5,6 +5,8 @@ import { X, ExternalLink, Footprints, Trophy } from 'lucide-react' import { duckTip, type ExhibitLink } from './exhibits' import { useSceneStore } from './sceneStore' import { closeCard } from './exhibitActions' +import { requestWalkLock } from './walkLock' +import { MousePointerClick } from 'lucide-react' /** Tag pills and link buttons under the card body */ function CardFooter({ tags, links, numbered }: { tags?: string[]; links?: ExhibitLink[]; numbered?: boolean }) { @@ -107,10 +109,22 @@ export function WalkHud() { const walk = useSceneStore((s) => s.walk) const near = useSceneStore((s) => s.nearExhibit) const exhibits = useSceneStore((s) => s.exhibits) + const locked = useSceneStore((s) => s.pointerLocked) + const cardOpen = useSceneStore((s) => s.focusedExhibit !== null) if (!walk) return null const target = exhibits.find((e) => e.id === near) return (
+ {!locked && !cardOpen && ( + + )}
{target && (
diff --git a/src/components/terminal/WalkControls.tsx b/src/components/terminal/WalkControls.tsx index 9c9bcd8..49829ac 100644 --- a/src/components/terminal/WalkControls.tsx +++ b/src/components/terminal/WalkControls.tsx @@ -8,6 +8,7 @@ import { DESK_BOUNDS, ROOM, exhibitNormal } from './exhibits' import type { Exhibit } from './exhibits' import { sceneStore, useSceneStore } from './sceneStore' import { activateExhibit, closeCard, openCardLink } from './exhibitActions' +import { releaseWalkLock, requestWalkLock, setWalkLockTarget } from './walkLock' /** Eye height above the floor and how close you must be to an exhibit to inspect it */ const EYE_Y = ROOM.floorY + 1.55 @@ -115,39 +116,39 @@ function useWalkKeys(walk: boolean, moves: React.MutableRefObject) { export default function WalkControls() { const walk = useSceneStore((s) => s.walk) const cardOpen = useSceneStore((s) => s.focusedExhibit !== null) - const { camera } = useThree() + const { camera, gl } = useThree() const controls = useRef>(null) const moves = useRef({ ...IDLE }) const previous = useRef(new THREE.Vector3()) const forward = useRef(new THREE.Vector3()) const lastNear = useRef(null) - // Enter: spawn by the door facing the desk and grab the pointer + // The HUD's "click to look around" needs the canvas to request the lock from a real click + useEffect(() => { + setWalkLockTarget(gl.domElement) + return () => setWalkLockTarget(null) + }, [gl]) + + // Enter: spawn by the door facing the desk. The lock is *attempted* here (Chrome grants it + // within the click's activation window); Firefox only grants it from inside a handler, so + // the HUD shows a click target until the lock arrives. useEffect(() => { if (!walk) return camera.position.copy(SPAWN) camera.lookAt(0, 0.4, 0.2) sceneStore.setState({ roomLights: true }) - const lock = controls.current - lock?.lock() - // If the browser refuses the lock (no user gesture, iframe policy…) do not stay stuck swallowing keys - const check = setTimeout(() => { - if (sceneStore.getState().walk && !document.pointerLockElement && !sceneStore.getState().focusedExhibit) { - sceneStore.setState({ walk: false }) - } - }, 800) + requestWalkLock() return () => { - clearTimeout(check) - lock?.unlock() - sceneStore.setState({ nearExhibit: null }) + releaseWalkLock() + sceneStore.setState({ nearExhibit: null, pointerLocked: false }) } }, [walk, camera]) - // A card releases the mouse so its links can be clicked; closing it grabs the mouse again + // A card releases the mouse so its links can be clicked; closing it asks for the mouse again useEffect(() => { if (!walk) return - if (cardOpen) controls.current?.unlock() - else controls.current?.lock() + if (cardOpen) releaseWalkLock() + else requestWalkLock() }, [walk, cardOpen]) useWalkKeys(walk, moves) @@ -179,10 +180,10 @@ export default function WalkControls() { ref={controls} enabled={walk} selector="#walk-lock-target" + onLock={() => sceneStore.setState({ pointerLocked: true })} onUnlock={() => { - // Browser-initiated unlock (Esc, tab switch) ends the walk unless a card deliberately released it - const { walk: walking, focusedExhibit } = sceneStore.getState() - if (walking && !focusedExhibit) sceneStore.setState({ walk: false }) + // Esc / tab switch: keep walking (the HUD offers a click to re-grab the mouse) unless a card released it + sceneStore.setState({ pointerLocked: false }) }} /> ) diff --git a/src/components/terminal/sceneStore.ts b/src/components/terminal/sceneStore.ts index 2233c1c..c131c42 100644 --- a/src/components/terminal/sceneStore.ts +++ b/src/components/terminal/sceneStore.ts @@ -27,6 +27,8 @@ export type SceneState = { cameraNonce: number /** First-person walk mode (pointer lock + WASD) */ walk: boolean + /** Whether the browser currently has the mouse captured for looking around */ + pointerLocked: boolean /** Ceiling light so the room is visible while walking */ roomLights: boolean /** Disco lights + chiptune */ @@ -72,6 +74,7 @@ export const DEFAULT_SCENE_STATE: SceneState = { cameraGoal: CAMERA_PRESETS.default, cameraNonce: 0, walk: false, + pointerLocked: false, roomLights: false, party: false, gravity: true, diff --git a/src/components/terminal/walkLock.ts b/src/components/terminal/walkLock.ts new file mode 100644 index 0000000..565e249 --- /dev/null +++ b/src/components/terminal/walkLock.ts @@ -0,0 +1,25 @@ +/** + * Pointer-lock plumbing shared by the walk controls (inside the canvas) and the HUD (outside it). + * Browsers only grant pointer lock from inside a user-gesture handler, so the HUD's + * "click to look around" button needs a direct line to the canvas element. + */ +let target: HTMLElement | null = null + +export function setWalkLockTarget(el: HTMLElement | null) { + target = el +} + +/** Requests pointer lock on the canvas; silently ignores refusals (the HUD offers a click instead) */ +export function requestWalkLock() { + if (!target) return + try { + const result = (target.requestPointerLock as () => Promise | void).call(target) + if (result && typeof (result as Promise).catch === 'function') (result as Promise).catch(() => {}) + } catch { + // Not available (e.g. iframe policy); the walk still works with the keyboard + } +} + +export function releaseWalkLock() { + if (typeof document !== 'undefined' && document.pointerLockElement) document.exitPointerLock() +} diff --git a/src/hooks/useKeyClick.test.ts b/src/hooks/useKeyClick.test.ts index 461988c..9af6583 100644 --- a/src/hooks/useKeyClick.test.ts +++ b/src/hooks/useKeyClick.test.ts @@ -103,8 +103,8 @@ describe('useKeyClick', () => { result.current('a') }) - // press + release noise bursts - expect(MockAudioContext.instances[0].createBufferSource).toHaveBeenCalledTimes(2) + // five filtered noise strikes make up one keypress + expect(MockAudioContext.instances[0].createBufferSource).toHaveBeenCalledTimes(5) }) it('does not warm up or play when disabled', () => { diff --git a/src/utils/audio.test.ts b/src/utils/audio.test.ts index 198b554..3e9eda3 100644 --- a/src/utils/audio.test.ts +++ b/src/utils/audio.test.ts @@ -140,19 +140,18 @@ describe('audio', () => { }) describe('playClick', () => { - it('synthesises snap, body and tick per call and resumes a suspended context', () => { + it('synthesises five filtered noise strikes per call, with no oscillators', () => { playClick('a') playClick('a') playClick('a') const ctx = MockAudioContext.instances[0] expect(ctx.resume).toHaveBeenCalled() - // Press + release noise sources per click, the noise buffer built once - expect(ctx.sources).toHaveLength(6) + // 3 down-stroke + 2 up-stroke strikes per click, the noise buffer built once + expect(ctx.sources).toHaveLength(15) + expect(ctx.createBiquadFilter).toHaveBeenCalledTimes(15) expect(ctx.createBuffer).toHaveBeenCalledTimes(1) - // Body + tick oscillators per click; snap, body and release filters per click - expect(ctx.createOscillator).toHaveBeenCalledTimes(6) - expect(ctx.createBiquadFilter).toHaveBeenCalledTimes(9) + expect(ctx.createOscillator).not.toHaveBeenCalled() for (const source of ctx.sources) expect(source.start).toHaveBeenCalled() }) @@ -161,10 +160,10 @@ describe('audio', () => { expect(MockAudioContext.instances).toHaveLength(0) }) - it('gives Enter a deeper, longer voice than a letter', () => { - expect(clickVoiceFor('Enter').body).toBeLessThan(clickVoiceFor('a').body) - expect(clickVoiceFor('Enter').ring).toBeGreaterThan(clickVoiceFor('a').ring) - expect(clickVoiceFor(' ').snap).toBeGreaterThan(clickVoiceFor('a').snap) + it('gives Enter and Space a lower, heavier voice than a letter', () => { + expect(clickVoiceFor('Enter').click).toBeLessThan(clickVoiceFor('a').click) + expect(clickVoiceFor('Enter').level).toBeGreaterThan(clickVoiceFor('a').level) + expect(clickVoiceFor(' ').thud).toBeLessThan(clickVoiceFor('a').thud) }) it('logs and survives a broken node graph', () => { diff --git a/src/utils/audio.ts b/src/utils/audio.ts index 4f8ef1a..6652890 100644 --- a/src/utils/audio.ts +++ b/src/utils/audio.ts @@ -61,28 +61,56 @@ export function clickRateFor(key?: string): number { } type ClickVoice = { - /** Fundamental of the "thock" body in Hz */ + /** Centre of the click resonance in Hz (the "tick") */ + click: number + /** Centre of the case resonance in Hz (the "tock") */ body: number - /** How long the body rings, seconds */ - ring: number - /** How long the noise transient lasts, seconds */ - snap: number + /** Cut-off of the bottom-out thud in Hz */ + thud: number /** Overall loudness */ level: number } -/** Clicky-switch character per key: Enter is a heavier, lower click, Space a broader one, letters a sharp tick */ +/** Keyboard character per key: Enter and Space are bigger keys — lower, more thud; letters are tight ticks */ export function clickVoiceFor(key?: string): ClickVoice { - if (key === 'Enter') return { body: 240, ring: 0.045, snap: 0.016, level: 1 } - if (key === ' ') return { body: 280, ring: 0.04, snap: 0.02, level: 0.9 } - if (key === 'Backspace') return { body: 380, ring: 0.025, snap: 0.011, level: 0.75 } - return { body: 330, ring: 0.028, snap: 0.012, level: 0.8 } + if (key === 'Enter') return { click: 2600, body: 900, thud: 220, level: 1.15 } + if (key === ' ') return { click: 2400, body: 800, thud: 190, level: 1.05 } + if (key === 'Backspace') return { click: 3600, body: 1300, thud: 260, level: 0.8 } + return { click: 3300, body: 1150, thud: 250, level: 0.85 } +} + +/** ±spread random multiplier so no two presses ring identically */ +const vary = (spread: number) => 1 + (Math.random() * 2 - 1) * spread + +/** + * One impulse of noise sent through a resonant filter that rings and decays — the whole + * sound of a keypress is a few of these in parallel (plastic tick, case tock, desk thud). + */ +function strike( + ctx: AudioContext, + out: AudioNode, + at: number, + opts: { type: BiquadFilterType; freq: number; q: number; gain: number; decay: number; impulse: number } +) { + const source = ctx.createBufferSource() + source.buffer = getNoise(ctx) + const filter = ctx.createBiquadFilter() + filter.type = opts.type + filter.frequency.value = opts.freq + filter.Q.value = opts.q + const env = ctx.createGain() + env.gain.setValueAtTime(opts.gain, at) + env.gain.exponentialRampToValueAtTime(0.001, at + opts.decay) + source.connect(filter) + filter.connect(env) + env.connect(out) + source.start(at, 0, opts.impulse) } /** - * Synthesised clicky-switch keypress: a bright noise snap (the click leaf), a short biting - * body (the housing), a high ping (the keycap) and a softer release click. Every call is - * its own set of nodes, so fast typing overlaps naturally. + * Synthesised keypress built only from noise impulses ringing through resonant filters — + * no oscillators, so it reads as plastic and metal rather than a synth. Down-stroke: click + * leaf + case + bottom-out thud; up-stroke: a lighter click a few dozen ms later. */ export function playClick(key?: string): void { if (!shouldClick(key)) return @@ -93,70 +121,18 @@ export function playClick(key?: string): void { const rate = clickRateFor(key) const voice = clickVoiceFor(key) const master = ctx.createGain() - master.gain.value = 0.28 * voice.level + master.gain.value = 0.55 * voice.level master.connect(ctx.destination) - // Click leaf: a sharp, bright noise burst — the part that reads as "clicky" - const snap = ctx.createBufferSource() - snap.buffer = getNoise(ctx) - const snapFilter = ctx.createBiquadFilter() - snapFilter.type = 'bandpass' - snapFilter.frequency.value = 5200 * rate - snapFilter.Q.value = 1.6 - const snapGain = ctx.createGain() - snapGain.gain.setValueAtTime(1.6, t) - snapGain.gain.exponentialRampToValueAtTime(0.001, t + voice.snap) - snap.connect(snapFilter) - snapFilter.connect(snapGain) - snapGain.connect(master) - snap.start(t, 0, voice.snap + 0.01) - - // Housing: short square body, high-passed so it adds bite instead of thud - const body = ctx.createOscillator() - body.type = 'square' - body.frequency.setValueAtTime(voice.body * rate, t) - body.frequency.exponentialRampToValueAtTime(voice.body * rate * 0.6, t + voice.ring) - const bodyFilter = ctx.createBiquadFilter() - bodyFilter.type = 'highpass' - bodyFilter.frequency.value = 700 - const bodyGain = ctx.createGain() - bodyGain.gain.setValueAtTime(0.0001, t) - bodyGain.gain.exponentialRampToValueAtTime(0.35, t + 0.002) - bodyGain.gain.exponentialRampToValueAtTime(0.001, t + voice.ring) - body.connect(bodyFilter) - bodyFilter.connect(bodyGain) - bodyGain.connect(master) - body.start(t) - body.stop(t + voice.ring + 0.02) - - // Keycap: a bright ping right at the top of the click - const tick = ctx.createOscillator() - tick.type = 'sine' - tick.frequency.setValueAtTime(7000 * rate, t) - const tickGain = ctx.createGain() - tickGain.gain.setValueAtTime(0.4, t) - tickGain.gain.exponentialRampToValueAtTime(0.001, t + 0.008) - tick.connect(tickGain) - tickGain.connect(master) - tick.start(t) - tick.stop(t + 0.015) - - // Release: a quieter second click a moment later, like the leaf springing back - const release = ctx.createBufferSource() - release.buffer = getNoise(ctx) - const releaseFilter = ctx.createBiquadFilter() - releaseFilter.type = 'bandpass' - releaseFilter.frequency.value = 4200 * rate - releaseFilter.Q.value = 1.4 - const releaseGain = ctx.createGain() - const tr = t + 0.055 + Math.random() * 0.02 - releaseGain.gain.setValueAtTime(0.0001, t) - releaseGain.gain.setValueAtTime(0.55, tr) - releaseGain.gain.exponentialRampToValueAtTime(0.001, tr + 0.01) - release.connect(releaseFilter) - releaseFilter.connect(releaseGain) - releaseGain.connect(master) - release.start(tr, 0, 0.02) + // Down-stroke + strike(ctx, master, t, { type: 'bandpass', freq: voice.click * rate * vary(0.08), q: 6, gain: 1.4, decay: 0.035, impulse: 0.004 }) + strike(ctx, master, t, { type: 'bandpass', freq: voice.body * rate * vary(0.1), q: 3, gain: 0.9, decay: 0.05, impulse: 0.006 }) + strike(ctx, master, t + 0.003, { type: 'lowpass', freq: voice.thud, q: 1.2, gain: 1.1, decay: 0.06, impulse: 0.012 }) + + // Up-stroke: the switch springing back, quieter and a touch higher + const up = t + 0.06 + Math.random() * 0.03 + strike(ctx, master, up, { type: 'bandpass', freq: voice.click * rate * 1.15 * vary(0.08), q: 6, gain: 0.6, decay: 0.025, impulse: 0.003 }) + strike(ctx, master, up, { type: 'bandpass', freq: voice.body * rate * vary(0.1), q: 3, gain: 0.35, decay: 0.03, impulse: 0.004 }) } catch (err) { console.error('Error playing sound:', err) } From 129c0b73c62704bd4240ebb4d2c4b23310a93075 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Thu, 17 Sep 2026 15:41:32 +0000 Subject: [PATCH 7/8] =?UTF-8?q?fix(audio):=20clean=20overlapping=20clicks?= =?UTF-8?q?=20=E2=80=94=20cancel=20stale=20up-strokes,=20shared=20compress?= =?UTF-8?q?or?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the next key lands before the previous key's up-stroke, that up-stroke is dropped (a finger is already on the next key), and every click runs through one gentle compressor so fast typing sounds like a keyboard rather than a pile of clicks. Co-Authored-By: Claude Opus 5 (1M context) --- src/hooks/useKeyClick.test.ts | 10 ++++++++ src/utils/audio.test.ts | 31 +++++++++++++++++++++++ src/utils/audio.ts | 46 +++++++++++++++++++++++++++++++---- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/hooks/useKeyClick.test.ts b/src/hooks/useKeyClick.test.ts index 9af6583..ec0f97c 100644 --- a/src/hooks/useKeyClick.test.ts +++ b/src/hooks/useKeyClick.test.ts @@ -20,6 +20,7 @@ class MockAudioContext { playbackRate: { value: 1 }, connect: vi.fn(), start: vi.fn(), + stop: vi.fn(), })) createGain = vi.fn(() => ({ @@ -37,6 +38,15 @@ class MockAudioContext { getChannelData: () => new Float32Array(length), })) + createDynamicsCompressor = vi.fn(() => ({ + threshold: { value: 0 }, + knee: { value: 0 }, + ratio: { value: 1 }, + attack: { value: 0 }, + release: { value: 0 }, + connect: vi.fn(), + })) + createBiquadFilter = vi.fn(() => ({ type: 'lowpass', frequency: { value: 0 }, diff --git a/src/utils/audio.test.ts b/src/utils/audio.test.ts index 3e9eda3..3b0347d 100644 --- a/src/utils/audio.test.ts +++ b/src/utils/audio.test.ts @@ -20,6 +20,7 @@ type MockBufferSource = { playbackRate: { value: number } connect: ReturnType start: ReturnType + stop: ReturnType } class MockAudioContext { @@ -37,6 +38,15 @@ class MockAudioContext { getChannelData: () => new Float32Array(length), })) + createDynamicsCompressor = vi.fn(() => ({ + threshold: { value: 0 }, + knee: { value: 0 }, + ratio: { value: 1 }, + attack: { value: 0 }, + release: { value: 0 }, + connect: vi.fn(), + })) + createBiquadFilter = vi.fn(() => ({ type: 'lowpass', frequency: { value: 0 }, @@ -54,6 +64,7 @@ class MockAudioContext { playbackRate: { value: 1 }, connect: vi.fn(), start: vi.fn(), + stop: vi.fn(), } this.sources.push(source) return source @@ -155,6 +166,26 @@ describe('audio', () => { for (const source of ctx.sources) expect(source.start).toHaveBeenCalled() }) + it('cancels the previous up-stroke when the next key lands first', () => { + playClick('a') + const ctx = MockAudioContext.instances[0] + const firstRelease = ctx.sources.slice(3, 5) + // Still before the scheduled up-stroke + playClick('b') + firstRelease.forEach((src) => expect(src.stop).toHaveBeenCalled()) + // Let time pass beyond the up-stroke: nothing more is cancelled + ctx.currentTime = 1 + const secondRelease = ctx.sources.slice(8, 10) + playClick('c') + secondRelease.forEach((src) => expect(src.stop).not.toHaveBeenCalled()) + }) + + it('routes clicks through a shared compressor', () => { + playClick('a') + playClick('a') + expect(MockAudioContext.instances[0].createDynamicsCompressor).toHaveBeenCalledTimes(1) + }) + it('creates nothing for modifier keys', () => { playClick('Shift') expect(MockAudioContext.instances).toHaveLength(0) diff --git a/src/utils/audio.ts b/src/utils/audio.ts index 6652890..e4561b0 100644 --- a/src/utils/audio.ts +++ b/src/utils/audio.ts @@ -9,6 +9,9 @@ export const SOUNDED_SPECIAL_KEYS: ReadonlySet = new Set([ let context: AudioContext | null = null let noiseBuffer: AudioBuffer | null = null +let bus: AudioNode | null = null +/** Up-stroke strikes of the most recent press, so a fast next press can cancel them */ +let pendingRelease: { sources: AudioBufferSourceNode[]; at: number } | null = null export function isAudioSupported(): boolean { return typeof window !== 'undefined' && !!(window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext) @@ -36,6 +39,25 @@ function getNoise(ctx: AudioContext): AudioBuffer { return noiseBuffer } +/** Shared output: a gentle compressor so a burst of overlapping clicks stays one keyboard, not a pile-up */ +function getBus(ctx: AudioContext): AudioNode { + if (!bus) { + if (typeof ctx.createDynamicsCompressor === 'function') { + const comp = ctx.createDynamicsCompressor() + comp.threshold.value = -20 + comp.knee.value = 12 + comp.ratio.value = 5 + comp.attack.value = 0.002 + comp.release.value = 0.09 + comp.connect(ctx.destination) + bus = comp + } else { + bus = ctx.destination + } + } + return bus +} + /** Creates the context and the noise buffer ahead of the first keypress; swallows all errors */ export function warmUp(): void { try { @@ -91,7 +113,7 @@ function strike( out: AudioNode, at: number, opts: { type: BiquadFilterType; freq: number; q: number; gain: number; decay: number; impulse: number } -) { +): AudioBufferSourceNode { const source = ctx.createBufferSource() source.buffer = getNoise(ctx) const filter = ctx.createBiquadFilter() @@ -105,6 +127,7 @@ function strike( filter.connect(env) env.connect(out) source.start(at, 0, opts.impulse) + return source } /** @@ -122,7 +145,13 @@ export function playClick(key?: string): void { const voice = clickVoiceFor(key) const master = ctx.createGain() master.gain.value = 0.55 * voice.level - master.connect(ctx.destination) + master.connect(getBus(ctx)) + + // Typing fast: the previous key's up-stroke has not happened yet — a real finger is already + // on the next key, so drop it instead of stacking two clicks on top of each other + if (pendingRelease && pendingRelease.at > t) { + pendingRelease.sources.forEach((src) => src.stop()) + } // Down-stroke strike(ctx, master, t, { type: 'bandpass', freq: voice.click * rate * vary(0.08), q: 6, gain: 1.4, decay: 0.035, impulse: 0.004 }) @@ -130,9 +159,14 @@ export function playClick(key?: string): void { strike(ctx, master, t + 0.003, { type: 'lowpass', freq: voice.thud, q: 1.2, gain: 1.1, decay: 0.06, impulse: 0.012 }) // Up-stroke: the switch springing back, quieter and a touch higher - const up = t + 0.06 + Math.random() * 0.03 - strike(ctx, master, up, { type: 'bandpass', freq: voice.click * rate * 1.15 * vary(0.08), q: 6, gain: 0.6, decay: 0.025, impulse: 0.003 }) - strike(ctx, master, up, { type: 'bandpass', freq: voice.body * rate * vary(0.1), q: 3, gain: 0.35, decay: 0.03, impulse: 0.004 }) + const up = t + 0.07 + Math.random() * 0.02 + pendingRelease = { + at: up, + sources: [ + strike(ctx, master, up, { type: 'bandpass', freq: voice.click * rate * 1.15 * vary(0.08), q: 6, gain: 0.45, decay: 0.022, impulse: 0.003 }), + strike(ctx, master, up, { type: 'bandpass', freq: voice.body * rate * vary(0.1), q: 3, gain: 0.25, decay: 0.028, impulse: 0.004 }), + ], + } } catch (err) { console.error('Error playing sound:', err) } @@ -263,4 +297,6 @@ export function resetAudioForTests(): void { stopParty() context = null noiseBuffer = null + bus = null + pendingRelease = null } From 4ba3a656c706903c648e11bfa9785db96c370b2e Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Thu, 17 Sep 2026 15:48:05 +0000 Subject: [PATCH 8/8] fix(3d): pointer lock targets the canvas the controls watch; softer click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3F connects its events to the wrapper div, so drei's PointerLockControls watched the div while the HUD locked the canvas — the lock landed on an element the controls did not recognise, so looking never started and the "Click to look around" target never cleared. The controls are now pinned to the canvas. The click is gentler: resonances an octave lower with wider Q, more bottom-out thud, lower level, and a 4.2 kHz lowpass on the shared bus so nothing pierces. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/terminal/WalkControls.tsx | 2 ++ src/utils/audio.test.ts | 3 ++- src/utils/audio.ts | 28 ++++++++++++++---------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/components/terminal/WalkControls.tsx b/src/components/terminal/WalkControls.tsx index 49829ac..0b8ea52 100644 --- a/src/components/terminal/WalkControls.tsx +++ b/src/components/terminal/WalkControls.tsx @@ -178,6 +178,8 @@ export default function WalkControls() { return ( sceneStore.setState({ pointerLocked: true })} diff --git a/src/utils/audio.test.ts b/src/utils/audio.test.ts index 3b0347d..cbcf39d 100644 --- a/src/utils/audio.test.ts +++ b/src/utils/audio.test.ts @@ -160,7 +160,8 @@ describe('audio', () => { expect(ctx.resume).toHaveBeenCalled() // 3 down-stroke + 2 up-stroke strikes per click, the noise buffer built once expect(ctx.sources).toHaveLength(15) - expect(ctx.createBiquadFilter).toHaveBeenCalledTimes(15) + // 15 strike filters + the one lowpass on the shared bus + expect(ctx.createBiquadFilter).toHaveBeenCalledTimes(16) expect(ctx.createBuffer).toHaveBeenCalledTimes(1) expect(ctx.createOscillator).not.toHaveBeenCalled() for (const source of ctx.sources) expect(source.start).toHaveBeenCalled() diff --git a/src/utils/audio.ts b/src/utils/audio.ts index e4561b0..b879248 100644 --- a/src/utils/audio.ts +++ b/src/utils/audio.ts @@ -49,7 +49,13 @@ function getBus(ctx: AudioContext): AudioNode { comp.ratio.value = 5 comp.attack.value = 0.002 comp.release.value = 0.09 - comp.connect(ctx.destination) + // Roll off the top so nothing in the click is piercing + const soften = ctx.createBiquadFilter() + soften.type = 'lowpass' + soften.frequency.value = 4200 + soften.Q.value = 0.5 + comp.connect(soften) + soften.connect(ctx.destination) bus = comp } else { bus = ctx.destination @@ -95,10 +101,10 @@ type ClickVoice = { /** Keyboard character per key: Enter and Space are bigger keys — lower, more thud; letters are tight ticks */ export function clickVoiceFor(key?: string): ClickVoice { - if (key === 'Enter') return { click: 2600, body: 900, thud: 220, level: 1.15 } - if (key === ' ') return { click: 2400, body: 800, thud: 190, level: 1.05 } - if (key === 'Backspace') return { click: 3600, body: 1300, thud: 260, level: 0.8 } - return { click: 3300, body: 1150, thud: 250, level: 0.85 } + if (key === 'Enter') return { click: 1800, body: 560, thud: 190, level: 1.1 } + if (key === ' ') return { click: 1700, body: 520, thud: 170, level: 1 } + if (key === 'Backspace') return { click: 2500, body: 800, thud: 220, level: 0.8 } + return { click: 2300, body: 740, thud: 210, level: 0.85 } } /** ±spread random multiplier so no two presses ring identically */ @@ -144,7 +150,7 @@ export function playClick(key?: string): void { const rate = clickRateFor(key) const voice = clickVoiceFor(key) const master = ctx.createGain() - master.gain.value = 0.55 * voice.level + master.gain.value = 0.42 * voice.level master.connect(getBus(ctx)) // Typing fast: the previous key's up-stroke has not happened yet — a real finger is already @@ -154,17 +160,17 @@ export function playClick(key?: string): void { } // Down-stroke - strike(ctx, master, t, { type: 'bandpass', freq: voice.click * rate * vary(0.08), q: 6, gain: 1.4, decay: 0.035, impulse: 0.004 }) - strike(ctx, master, t, { type: 'bandpass', freq: voice.body * rate * vary(0.1), q: 3, gain: 0.9, decay: 0.05, impulse: 0.006 }) - strike(ctx, master, t + 0.003, { type: 'lowpass', freq: voice.thud, q: 1.2, gain: 1.1, decay: 0.06, impulse: 0.012 }) + strike(ctx, master, t, { type: 'bandpass', freq: voice.click * rate * vary(0.08), q: 3, gain: 0.8, decay: 0.03, impulse: 0.004 }) + strike(ctx, master, t, { type: 'bandpass', freq: voice.body * rate * vary(0.1), q: 2.2, gain: 1.0, decay: 0.05, impulse: 0.006 }) + strike(ctx, master, t + 0.003, { type: 'lowpass', freq: voice.thud, q: 1.1, gain: 1.3, decay: 0.07, impulse: 0.012 }) // Up-stroke: the switch springing back, quieter and a touch higher const up = t + 0.07 + Math.random() * 0.02 pendingRelease = { at: up, sources: [ - strike(ctx, master, up, { type: 'bandpass', freq: voice.click * rate * 1.15 * vary(0.08), q: 6, gain: 0.45, decay: 0.022, impulse: 0.003 }), - strike(ctx, master, up, { type: 'bandpass', freq: voice.body * rate * vary(0.1), q: 3, gain: 0.25, decay: 0.028, impulse: 0.004 }), + strike(ctx, master, up, { type: 'bandpass', freq: voice.click * rate * 1.1 * vary(0.08), q: 3, gain: 0.3, decay: 0.02, impulse: 0.003 }), + strike(ctx, master, up, { type: 'bandpass', freq: voice.body * rate * vary(0.1), q: 2.2, gain: 0.25, decay: 0.028, impulse: 0.004 }), ], } } catch (err) {