diff --git a/CHANGELOG.md b/CHANGELOG.md index 4721661..dee3ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes, newest first. The project follows [Conventional Commits](https://www.conventionalcommits.org/); see `git log` for every commit. +## 2026-10-13: Quick Start, onboarding, journal, spoken subtitles (Day 5) +- **Home** rebuilt: Detective Grace, Quick Start (one press to a room and its QR code), a one-minute "how it works" for first-time TVs, and a How to Play screen. +- **Case library** with filters by age group and story type, and a preview pane. +- **Spoken subtitles:** every in-game line without a recorded take is voiced in the character's voice (server `/speak`, cached clips). +- **Evidence journal:** an open case book with a painted photo per clue. Puzzles sit on a detective's desk. Notices fade by themselves. +- **Phone:** painted suspect portraits, the rainy-street backdrop, evidence as pinned paper slips, parchment replies. +- **World fixes:** corridor and signal box use their painted props; Voss and Ashby stand on the floor; no sky under the platform in any shot. +- Edge TTS client updated (msedge-tts 2.1.0) after Edge began rejecting the old one. + ## 2026-10-13: Painted art (Day 4) - The Last Train is painted: 28 world assets (sky to signal box, the train, Detective Grace, every clue object), placed to line up with what's painted. - Clue clocks keep exact procedural hands over the painted dials (new manifest `overlay`). diff --git a/README.md b/README.md index 918249c..1ace7f9 100644 --- a/README.md +++ b/README.md @@ -95,12 +95,13 @@ The AI is an **actor, not a judge**. A pure, deterministic game engine decides e ```mermaid flowchart LR - Q["📱 'Where were you
at 11:47?'"] --> B["Budget, cooldown,
length checks"] + Q["Phone: Where were you
at 11:47?"] --> B["Budget, cooldown,
length checks"] B --> F["Truth table:
only unlocked facts"] F --> M["Free LLM
(Groq gpt-oss-120b)"] - M --> G{"Guard:
spoilers? answers?
out of character?"} - G -- safe --> V["🔊 Voice on the TV
+ subtitles"] - G -- unsafe / offline --> A["Authored fallback line"] --> V + M --> G{"Guard: spoilers,
answers, out of character"} + G -->|"safe"| V["Voice on the TV
+ subtitles"] + G -->|"unsafe or offline"| A["Authored fallback line"] + A --> V ``` - **Locked facts never reach the model.** The prompt holds only the persona, the unlocked facts and the clues already found. @@ -168,11 +169,11 @@ bash apps/tv/scripts/wsl-install.sh # install and launch on the Vega Virtual ```mermaid flowchart LR - subgraph Home["Living room"] - TV["📺 Fire TV · Vega OS
React Native for Vega"] - P["📱 Phones
(browser, no install)"] + subgraph room["Living room"] + TV["Fire TV, Vega OS
React Native for Vega"] + P["Phones
(browser, no install)"] end - subgraph Server["Game server · Node.js + TypeScript"] + subgraph srv["Game server: Node.js + TypeScript"] WS["WebSocket gateway"] GS["Rooms + GameSession"] ENG["Game engine
(pure, deterministic)"] @@ -181,9 +182,11 @@ flowchart LR end TV <-->|"intents / state"| WS P <-->|"intents / state"| WS - WS --> GS --> ENG --> MD + WS --> GS + GS --> ENG + ENG --> MD GS --> AI - AI -.-> LLM["Groq · Edge TTS
(free tiers)"] + AI -.-> LLM["Groq + Edge TTS
(free tiers)"] ``` **Ground rules** @@ -225,7 +228,7 @@ The full technical reference is in **[docs/system-design.md](docs/system-design. ## ✦ Quality -- **About 325 automated tests:** 58 engine, 22 content, 52 scene, 19 client, 4 UI tokens, 78 server, 11 phone and 80 TV. +- **331 automated tests:** 58 engine, 22 content, 52 scene, 19 client, 4 UI tokens, 79 server, 12 phone and 85 TV. - **Every case is played to the end automatically.** Content validation fails the build on unreachable scenes, broken references, puzzles that need clues from later scenes, and spoilers that could never unlock. - **Spoiler-attack tests** run against the AI guard and the prompt builder. - **Scripted device QA:** the Vega Virtual Device is driven with remote key presses and screenshots, with an audio trace sink. diff --git a/apps/player/build.mjs b/apps/player/build.mjs index 1abb823..ac022aa 100644 --- a/apps/player/build.mjs +++ b/apps/player/build.mjs @@ -1,13 +1,30 @@ // Bundles the phone app into dist/ with esbuild (already in the repo via tsx). // Usage: node build.mjs [--watch] import {build, context} from 'esbuild'; -import {cpSync, mkdirSync, rmSync, writeFileSync} from 'node:fs'; +import {cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; const outdir = new URL('./dist/', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'); rmSync(outdir, {recursive: true, force: true}); mkdirSync(outdir, {recursive: true}); cpSync(new URL('./public/', import.meta.url), outdir, {recursive: true}); +// The painted art the TV ships, reused on the phone: suspect portraits (calm) +// and the rainy-street key art as a dimmed backdrop. Missing art just means +// initials, so other cases work before their portraits exist. +const tvAssets = new URL('../tv/src/assets/', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'); +const portraits = []; +for (const mystery of existsSync(`${tvAssets}mysteries`) ? readdirSync(`${tvAssets}mysteries`) : []) { + const dir = `${tvAssets}mysteries/${mystery}/portraits`; + if (!existsSync(dir)) continue; + mkdirSync(`${outdir}/portraits/${mystery}`, {recursive: true}); + for (const file of readdirSync(dir).filter(f => f.endsWith('.calm.png'))) { + const id = file.replace('.calm.png', ''); + cpSync(`${dir}/${file}`, `${outdir}/portraits/${mystery}/${id}.png`); + portraits.push(`${mystery}/${id}`); + } +} +if (existsSync(`${tvAssets}ui/home.jpg`)) cpSync(`${tvAssets}ui/home.jpg`, `${outdir}/backdrop.jpg`); + // Design tokens → tokens.css, from the same source as the TV theme. const tokens = await build({ stdin: {contents: "import {cssVariables} from '@roomriddle/ui'; export default cssVariables();", resolveDir: new URL('.', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'), loader: 'ts'}, @@ -29,6 +46,7 @@ const options = { minify: !process.argv.includes('--watch'), sourcemap: true, logLevel: 'info', + define: {__PORTRAITS__: JSON.stringify(portraits)}, }; if (process.argv.includes('--watch')) { diff --git a/apps/player/public/app.css b/apps/player/public/app.css index 4469584..2230c89 100644 --- a/apps/player/public/app.css +++ b/apps/player/public/app.css @@ -12,14 +12,15 @@ html, body { font: var(--t-body-font); -webkit-font-smoothing: antialiased; } -/* Atmosphere: two faint light pools and a paper-grain vignette, no images. */ +/* Atmosphere: the TV's rainy-street painting, dimmed far down, under a lamp-light pool. */ body::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; background: radial-gradient(120% 60% at 85% -10%, var(--c-accent-wash), transparent 60%), - radial-gradient(90% 50% at -10% 110%, var(--c-signal-wash), transparent 60%), - linear-gradient(var(--c-background), var(--c-veil)); + linear-gradient(color-mix(in srgb, var(--c-background) 86%, transparent), var(--c-background) 70%), + url("backdrop.jpg") center top / cover no-repeat, + var(--c-background); } main { max-width: 560px; margin: 0 auto; @@ -151,9 +152,14 @@ button.option.pending { opacity: .6; } .tabs button.on { color: var(--c-accent); border-bottom-color: var(--c-accent); } .tabs .mono { color: var(--c-text-muted); } -.evidence { margin-top: var(--s-sm); padding: var(--s-lg); background: var(--c-surface); border: 1px solid var(--c-border); border-top: 3px solid var(--c-accent-soft); border-radius: var(--r-sm); box-shadow: var(--shadow-card); animation: reveal var(--m-discovery) both; } -.evidence.solved { border-top-color: var(--c-success); } -.evidence header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--s-xs); font-size: 12px; color: var(--c-text-muted); } +/* Evidence is a paper slip from the case journal: ink on paper, a pin, a slight hand-placed tilt. */ +.evidence { position: relative; margin-top: var(--s-md); padding: var(--s-lg); background: var(--c-paper); color: var(--c-ink); border: 0; border-radius: 2px; box-shadow: var(--shadow-card); animation: reveal var(--m-discovery) both; } +.evidence:nth-of-type(odd) { rotate: -0.6deg; } .evidence:nth-of-type(even) { rotate: 0.5deg; } +.evidence::before { content: ""; position: absolute; top: -6px; left: 50%; width: 12px; height: 12px; margin-left: -6px; border-radius: 50%; background: #a3301f; box-shadow: 0 2px 3px rgba(0,0,0,.4); } +.evidence.solved::before { background: var(--c-success); } +.story .evidence p, .evidence p, .evidence h4 { color: var(--c-ink); } +.evidence .tag.ok, .evidence .muted { color: color-mix(in srgb, var(--c-ink) 65%, transparent); } +.evidence header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--s-xs); font-size: 12px; color: #8a2c1c; } .evidence h4 { margin-bottom: var(--s-xs); } .tag { font: var(--t-label-font); letter-spacing: 1.5px; text-transform: uppercase; } .tag.ok { color: var(--c-success); } @@ -178,15 +184,19 @@ button.option.pending { opacity: .6; } .cast-text { display: grid; gap: 2px; } .cast-text b { font: var(--t-h2-font); font-size: 18px; } .cast-text .muted { font-size: 13px; } -.avatar { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--c-accent-soft); background: var(--c-surface-sunken); color: var(--c-accent); font: var(--t-label-font); letter-spacing: 1px; } -.avatar.big { width: 64px; height: 64px; font-size: 18px; } +.avatar { position: relative; display: grid; place-items: center; width: 52px; height: 52px; overflow: hidden; border-radius: 50%; border: 2px solid var(--c-accent-soft); background: var(--c-surface-sunken); color: var(--c-accent); font: var(--t-label-font); letter-spacing: 1px; } +.avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.avatar.big { width: 88px; height: 88px; font-size: 20px; border-color: var(--c-accent); } .cast-head { display: flex; gap: var(--s-md); align-items: center; margin: var(--s-sm) 0 var(--s-lg); } .cast-head .display { margin: 0; font-size: 26px; line-height: 32px; } .thread { list-style: none; margin: 0 0 var(--s-lg); padding: 0; display: grid; gap: var(--s-sm); } .thread li { max-width: 88%; padding: var(--s-sm) var(--s-md); border-radius: var(--r-md); animation: reveal var(--m-discovery) both; } .thread .q { justify-self: end; background: var(--c-surface-elevated); border: 1px solid var(--c-border); } .thread .q .label { font-size: 10px; margin-bottom: 2px; } -.thread .a { justify-self: start; background: var(--c-surface); border-left: 3px solid var(--c-accent); font: var(--t-body-font); font-style: italic; } +/* Replies look like the TV's speech bubble: parchment, ink, a tail toward the speaker. */ +.thread .a { position: relative; justify-self: start; background: var(--c-paper); color: var(--c-ink); border: 1px solid var(--c-accent-soft); font: var(--t-narration-font); font-size: 17px; line-height: 25px; } +.thread .a::before { content: ""; position: absolute; left: 14px; top: -7px; width: 12px; height: 12px; background: var(--c-paper); border-left: 1px solid var(--c-accent-soft); border-top: 1px solid var(--c-accent-soft); transform: rotate(45deg); } +.thread .a .dots i { background: var(--c-ink); } .thread .empty-thread { justify-self: center; background: none; font-size: 14px; } .dots { display: inline-flex; gap: 5px; padding: 4px 0; } .dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--c-accent); animation: pulse 700ms infinite alternate; } diff --git a/apps/player/src/render.test.ts b/apps/player/src/render.test.ts index d743aa0..9ac7fff 100644 --- a/apps/player/src/render.test.ts +++ b/apps/player/src/render.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import {initialRoomView, type RoomView} from '@roomriddle/client'; import {createInitialGameState} from '@roomriddle/game-engine'; import type {CharacterReply, PublicMystery, Room} from '@roomriddle/shared-types'; -import {errorState, initialUi, loadingState, renderScreen, screenName} from './render'; +import {avatar, errorState, initialUi, loadingState, renderScreen, screenName} from './render'; const room = (over: Partial = {}): Room => ({ id: 'r', @@ -133,3 +133,13 @@ test('with no questions left the form is closed', () => { assert.match(html, /No questions left/); assert.match(html, /]*disabled/); }); + +test('suspects show their painted portrait when the case has one, initials otherwise', () => { + const art = new Set(['the-last-train/voss']); + const voss = avatar('the-last-train', {id: 'voss', name: 'Inspector Voss'}, true, art); + assert.match(voss, /NH w[0] ?? '').join('').slice(0, 2).toUpperCase(); } +// "mysteryId/characterId" for every painted portrait, injected by build.mjs (empty in tests). +declare const __PORTRAITS__: string[] | undefined; +const PORTRAITS = new Set(typeof __PORTRAITS__ !== 'undefined' ? __PORTRAITS__ : []); + +/** The painted portrait when the case has one, else initials on a disc. */ +export function avatar(mysteryId: string, c: {id: string; name: string}, big = false, portraits: ReadonlySet = PORTRAITS): string { + const key = `${mysteryId}/${c.id}`; + const art = portraits.has(key) ? `` : ''; + return ``; +} + /** Pick a person, ask in your own words, optionally show a found clue. Replies come from the server. */ export function suspectsView(view: RoomView, ui: UiState): string { const mystery = view.mystery!; @@ -369,7 +380,7 @@ export function suspectsView(view: RoomView, ui: UiState): string { .map(c => { const asked = view.conversation.filter(r => r.characterId === c.id).length; return `
  • `; @@ -387,7 +398,7 @@ export function suspectsView(view: RoomView, ui: UiState): string { return `
    - + ${avatar(mystery.summary.id, suspect, true)}

    ${esc(suspect.name)}

    ${esc(suspect.role)}

      ${thread diff --git a/apps/server/package.json b/apps/server/package.json index 4b65465..53c3b98 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,10 +15,10 @@ "dependencies": { "@roomriddle/client": "*", "@roomriddle/game-engine": "*", - "@roomriddle/scene": "*", "@roomriddle/mystery-data": "*", + "@roomriddle/scene": "*", "@roomriddle/shared-types": "*", - "msedge-tts": "^2.0.9", + "msedge-tts": "^2.1.0", "qrcode-generator": "^2.0.4", "ws": "^8.22.0" }, diff --git a/apps/server/scripts/ask-bot.ts b/apps/server/scripts/ask-bot.ts index a270742..91a3a6e 100644 --- a/apps/server/scripts/ask-bot.ts +++ b/apps/server/scripts/ask-bot.ts @@ -17,8 +17,8 @@ function phone(name: string, onMessage: (m: ServerMessage, ws: WebSocket) => voi } let asked = false; -phone('Mira', () => {}); -phone('Aksh', (m, ws) => { +phone(process.env.ASK_NAME2 ?? 'Mira', () => {}); +phone(process.env.ASK_NAME ?? 'Aksh', (m, ws) => { if (m.type === 'ERROR') console.log(`[bot] ERROR ${m.code}: ${m.message}`); if ((m.type === 'GAME_STARTED' || (m.type === 'ROOM_JOINED' && m.room.status === 'playing')) && !asked) { asked = true; diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index 745b807..97f0ed6 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -81,3 +81,21 @@ test('serves spoken replies by id, immutable, and 404s unknown or malformed ids' await new Promise((resolve) => server.close(() => resolve())); } }); + +test('speaks subtitle lines: validates input and redirects to the clip', async () => { + const asked: string[] = []; + const server = createAppServer({ playerDir: '/nonexistent', speak: async (text, voice) => (asked.push(`${voice.edge}|${voice.rate ?? ''}|${text}`), 'abcdef0123456789abcd') }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + const base = `http://127.0.0.1:${port}`; + try { + const ok = await fetch(`${base}/speak?text=${encodeURIComponent('The platform clock.')}&voice=en-GB-SoniaNeural&rate=-4%25`, { redirect: 'manual' }); + assert.equal(ok.status, 302); + assert.equal(ok.headers.get('location'), '/voice/abcdef0123456789abcd.m4a'); + assert.deepEqual(asked, ['en-GB-SoniaNeural|-4%|The platform clock.']); + assert.equal((await fetch(`${base}/speak?text=hi&voice=evil`)).status, 400); + assert.equal((await fetch(`${base}/speak?text=${'a'.repeat(401)}&voice=en-GB-SoniaNeural`)).status, 400); + } finally { + server.close(); + } +}); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index e9cb488..129ba23 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -15,6 +15,7 @@ const MIME: Record = { '.webmanifest': 'application/manifest+json', '.svg': 'image/svg+xml', '.png': 'image/png', + '.jpg': 'image/jpeg', '.ico': 'image/x-icon', }; @@ -27,6 +28,8 @@ export interface AppServerOptions { stats?: () => {rooms: number; connections: number}; /** Spoken replies, served at /voice/. */ voiceClip?: VoiceClipLookup; + /** Speaks an authored line (subtitles) and resolves to its clip id, or null. */ + speak?: (text: string, voice: {edge: string; rate?: string; pitch?: string}) => Promise; } /** @@ -78,6 +81,25 @@ export function createAppServer(options: AppServerOptions = {}): Server { res.end(req.method === 'HEAD' ? undefined : clip.data); return; } + // Spoken subtitles: /speak?text=…&voice=en-GB-SoniaNeural[&rate=-4%&pitch=-8%] → a 302 to its /voice clip. + if (url.pathname === '/speak' && options.speak) { + const text = (url.searchParams.get('text') ?? '').trim(); + const voice = url.searchParams.get('voice') ?? ''; + if (!text || text.length > 400 || !/^[a-z]{2}-[A-Z]{2}-[A-Za-z]+Neural$/.test(voice)) { + sendJson(res, 400, {error: 'bad_request'}); + return; + } + const tone = (v: string | null) => (v && /^[+-]\d{1,2}%$/.test(v) ? v : undefined); + options + .speak(text, {edge: voice, rate: tone(url.searchParams.get('rate')), pitch: tone(url.searchParams.get('pitch'))}) + .then(id => { + if (!id) return sendJson(res, 503, {error: 'voice_unavailable'}); + res.writeHead(302, {location: `/voice/${id}.m4a`, 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=86400'}); + res.end(); + }) + .catch(() => sendJson(res, 503, {error: 'voice_unavailable'})); + return; + } if (servePlayerApp(res, playerDir, url.pathname)) return; sendJson(res, 404, {error: 'not_found'}); }); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f4a9143..7ba0d42 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -31,7 +31,7 @@ const server = createAppServer({stats: () => ({rooms: rooms.size, connections: s const clip = voices.clip(id); log(`[voice] GET ${id} ${clip ? "200" : "404"}`); return clip; -}}); +}, speak: voices.enabled ? (text, voice) => voices.speak(text, {groq: 'diana', ...voice}) : undefined}); const sockets = attachGameSocket(server, rooms, {log, characters, voices}); socketCount = () => sockets.connections.count; diff --git a/apps/tv/scripts/art/prepare_ui.py b/apps/tv/scripts/art/prepare_ui.py index 9cc133a..05ad806 100644 --- a/apps/tv/scripts/art/prepare_ui.py +++ b/apps/tv/scripts/art/prepare_ui.py @@ -42,8 +42,31 @@ def shade(im, f): return Image.fromarray(np.clip(a * m[..., None], 0, 255).astype(np.uint8)) +def procedural_desk(w=1920, h=1080): + """Stand-in desk until painted art (shared.desk.png) arrives: dark walnut + with grain, a green felt blotter in the middle, vignette.""" + rng = np.random.default_rng(7) + ys, xs = np.mgrid[0:h, 0:w] / np.array([h, w])[:, None, None] + # Fine, irregular grain along the boards: warped high-frequency bands plus noise. + warp = np.sin(xs * 7.0 + ys * 3.0) * 0.004 + np.sin(xs * 23.0) * 0.0015 + grain = (np.sin((ys + warp) * 900) * 0.5 + np.sin((ys + warp * 2) * 2300) * 0.3 + 1) / 2 + grain = 0.75 * grain + 0.25 * rng.random((h, w)) + wood = np.stack([48 + 16 * grain, 30 + 10 * grain, 18 + 6 * grain], -1) + felt = np.array([30, 46, 38]) + rng.normal(0, 3, (h, w, 1)) + inside = (xs > 0.06) & (xs < 0.94) & (ys > 0.1) & (ys < 0.9) + a = np.where(inside[..., None], felt, wood) + edge = ((np.abs(xs - 0.06) < 0.003) | (np.abs(xs - 0.94) < 0.003)) & (ys > 0.1) & (ys < 0.9) | ((np.abs(ys - 0.1) < 0.005) | (np.abs(ys - 0.9) < 0.005)) & (xs > 0.06) & (xs < 0.94) + a[edge] = [150, 118, 64] + vign = 1 - 0.55 * (((xs - 0.5) / 0.7) ** 2 + ((ys - 0.5) / 0.7) ** 2) + return Image.fromarray(np.clip(a * vign[..., None], 0, 255).astype(np.uint8)) + + def main(): src = Path(sys.argv[1]) / 'shared' + # Puzzle desk: painted art when supplied, else the procedural stand-in. + desk = src / 'shared.desk.png' + (cover(Image.open(desk).convert('RGB'), 1920, 1080) if desk.exists() else procedural_desk()).save(OUT / 'desk.jpg', quality=86) + print('desk.jpg', 'painted' if desk.exists() else 'procedural') (OUT / 'cards').mkdir(parents=True, exist_ok=True) # Home: the menu sits in the centre, so the middle is darkened most; the street stays visible at the edges. diff --git a/apps/tv/src/assets/ui/desk.jpg b/apps/tv/src/assets/ui/desk.jpg new file mode 100644 index 0000000..e8a8d28 Binary files /dev/null and b/apps/tv/src/assets/ui/desk.jpg differ diff --git a/apps/tv/src/game/CharacterSpeech.tsx b/apps/tv/src/game/CharacterSpeech.tsx index 0d3e43b..6eaab60 100644 --- a/apps/tv/src/game/CharacterSpeech.tsx +++ b/apps/tv/src/game/CharacterSpeech.tsx @@ -128,9 +128,9 @@ export const CharacterSpeech = () => { return ( + - {thinker.name.toUpperCase()} {thinking!.askedBy ? `${thinking!.askedBy} asks…` : 'The TV asks…'} is thinking… @@ -144,9 +144,9 @@ export const CharacterSpeech = () => { return ( + - {reply.characterName.toUpperCase()} {reply.askedBy ?? 'TV'}: “{reply.question}”{evidence ? ` · shows ${evidence}` : ''} @@ -159,6 +159,13 @@ export const CharacterSpeech = () => { ); }; +/** The speaker's name on a dark brass-edged plaque over the parchment. */ +const NamePlate = ({name}: {name: string}) => ( + + {name.toUpperCase()} + +); + /** Painted portrait (pressed when shown evidence), or initials for characters without art yet. */ const Portrait = ({characterId, name, pressed}: {characterId: string; name: string; pressed: boolean}) => { const art = portraitFor(characterId, pressed); @@ -174,16 +181,17 @@ const styles = StyleSheet.create({ position: 'absolute', left: theme.spacing.screenX, right: theme.spacing.screenX, - // Clear of the world's action bar (top edge ~850) and every screen's footer row. - bottom: px(250), - // Right side: screens keep their primary buttons bottom-left, so the bubble never covers focus. - alignItems: 'flex-end', + // Top-left, under the header: the bottom belongs to Grace's narration and the action bar, + // the right to the inspect panel, so a reply never covers either. + top: px(150), + alignItems: 'flex-start', }, bubble: { flexDirection: 'row', alignItems: 'flex-start', width: px(820), - paddingVertical: px(28), + paddingTop: px(40), + paddingBottom: px(28), paddingHorizontal: px(36), columnGap: px(32), backgroundColor: theme.colors.paper, @@ -218,11 +226,22 @@ const styles = StyleSheet.create({ flex: 1, rowGap: px(6), }, - name: { + plate: { + position: 'absolute', + top: -px(24), + left: px(200), + paddingHorizontal: px(22), + paddingVertical: px(6), + backgroundColor: theme.colors.ink, + borderWidth: px(2), + borderColor: theme.colors.accent, + borderRadius: px(4), + }, + plateText: { ...theme.text.label, fontSize: px(24), - letterSpacing: px(3), - color: theme.colors.ink, + letterSpacing: px(4), + color: theme.colors.accent, }, asked: { ...theme.text.body, diff --git a/apps/tv/src/screens/Evidence/EvidenceScreen.tsx b/apps/tv/src/screens/Evidence/EvidenceScreen.tsx index 769a56c..63212ac 100644 --- a/apps/tv/src/screens/Evidence/EvidenceScreen.tsx +++ b/apps/tv/src/screens/Evidence/EvidenceScreen.tsx @@ -1,19 +1,25 @@ import React, {useState} from 'react'; -import {StyleSheet, Text, View} from 'react-native'; +import {Image, StyleSheet, Text, View} from 'react-native'; import {getEvidence} from '@roomriddle/game-engine'; -import {ClueCard} from '../../components/ClueCard/ClueCard'; +import type {Clue} from '@roomriddle/shared-types'; +import {FocusableCard} from '../../components/FocusableCard/FocusableCard'; import {GameHeader} from '../../components/GameHeader/GameHeader'; import {TVButton} from '../../components/TVButton/TVButton'; import {useGame} from '../../game/GameContext'; import {px, theme} from '../../theme/theme'; import {Transition} from '../../ui/Transition'; +import {closeupFor} from '../../world/art'; import {ScreenProps} from '../types'; +/** A slight, stable tilt per photo, so the page looks pinned by hand. */ +const TILTS = ['-2.5deg', '1.8deg', '-1deg', '2.6deg', '-1.8deg', '1deg', '-2.2deg', '1.4deg']; +const PER_PAGE = 8; + /** - * The evidence board: every clue found so far, from every scene, numbered in - * the order it was found. Focus an entry on the left to read the slip on the - * right. Slots for evidence not yet found are shown as sealed, so the players - * can see how much of the case is still out there without spoilers. + * The evidence journal, after the "journal clues" reference: an open case + * book. The right page holds a photo of every clue found (numbered in the + * order found) and sealed slots for what is still out there; the left page + * reads the photo in focus. No spoilers: sealed slots carry no titles. */ export const EvidenceScreen = ({navigator}: ScreenProps) => { const {mystery, state} = useGame(); @@ -26,117 +32,246 @@ export const EvidenceScreen = ({navigator}: ScreenProps) => { const shown = evidence.find(c => c.id === focusedId) ?? evidence[evidence.length - 1]; const sceneTitle = (sceneId: string) => mystery.scenes.find(s => s.id === sceneId)?.title ?? ''; const numberOf = (id: string) => state.discoveredClueIds.indexOf(id) + 1; - const sealed = Math.max(0, mystery.clues.length - evidence.length); + const onPage = evidence.slice(-PER_PAGE); + const sealed = Math.max(0, Math.min(PER_PAGE, mystery.clues.length) - onPage.length); + const tag = (n: number) => `#${String(n).padStart(2, '0')}`; return ( - - - - CASE FILE - - {String(evidence.length).padStart(2, '0')}/{String(mystery.clues.length).padStart(2, '0')} - - - {evidence.length === 0 && ( - - NO EVIDENCE YET - Nothing yet. Investigate the scene to find clues. + + {/* Left page: the clue in focus. */} + + CASE JOURNAL + {shown ? ( + + + EVIDENCE {tag(numberOf(shown.id))} · {sceneTitle(shown.sceneId).toUpperCase()} + {shown.major ? ' · KEY' : ''} + + {shown.title} + + {shown.description.map((line, i) => ( + + {line} + + ))} + + ) : ( + + No evidence yet + Nothing yet. Investigate the scene to find clues. )} - {evidence.map((clue, index) => ( - setFocusedId(clue.id)} - onFocus={() => setFocusedId(clue.id)} - preferredFocus={index === evidence.length - 1} - accessibilityLabel={`Evidence ${numberOf(clue.id)}: ${clue.title}, found in ${sceneTitle(clue.sceneId)}`} - testID={`evidence-${clue.id}`} - /> - ))} - {sealed > 0 && evidence.length > 0 && ( - - {'▪ '.repeat(Math.min(sealed, 12)).trim()} {sealed} SEALED - - )} - - navigator.back()} preferredFocus={evidence.length === 0} testID="evidence-back" /> + + navigator.back()} preferredFocus={evidence.length === 0} testID="evidence-back" /> + - - {shown && ( - - - - )} + + + + {/* Right page: the photos. */} + + + FOUND {String(evidence.length).padStart(2, '0')}/{String(mystery.clues.length).padStart(2, '0')} + + + {onPage.map((clue, index) => ( + setFocusedId(clue.id)} + accessibilityLabel={`Evidence ${numberOf(clue.id)}: ${clue.title}, found in ${sceneTitle(clue.sceneId)}`} + /> + ))} + {Array.from({length: sealed}, (_, i) => ( + + ? + + ))} + ); }; +const Photo = ({ + clue, + label, + tilt, + preferredFocus, + onFocus, + accessibilityLabel, +}: { + clue: Clue; + label: string; + tilt: string; + preferredFocus: boolean; + onFocus: () => void; + accessibilityLabel: string; +}) => { + const art = closeupFor(clue.id); + return ( + + {() => ( + + + {art ? : {clue.title.slice(0, 1)}} + + + {label} {clue.title} + + {clue.major && } + + )} + + ); +}; + +const PHOTO_W = px(200); +const LEATHER = '#3a2618'; + const styles = StyleSheet.create({ screen: { flex: 1, paddingHorizontal: theme.spacing.screenX, paddingTop: theme.spacing.screenY - px(24), - paddingBottom: theme.spacing.screenY - px(16), + paddingBottom: theme.spacing.screenY - px(24), + backgroundColor: theme.colors.background, }, - body: { + book: { flex: 1, flexDirection: 'row', - marginTop: theme.spacing.xl, - gap: theme.spacing.xl, + marginTop: theme.spacing.md, + borderRadius: theme.radii.md, + overflow: 'hidden', + borderWidth: px(10), + borderColor: LEATHER, }, - list: { + page: { + backgroundColor: theme.colors.paper, + paddingHorizontal: px(44), + paddingVertical: px(32), + }, + leftPage: { width: px(680), - gap: px(2), }, - listHead: { - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: theme.spacing.sm, + rightPage: { + flex: 1, }, - heading: { - ...theme.text.label, - color: theme.colors.accent, + spine: { + width: px(14), + backgroundColor: LEATHER, }, - count: { + pageKicker: { ...theme.text.label, fontFamily: theme.fonts.technical, - color: theme.colors.textMuted, + color: theme.colors.ink, + opacity: 0.6, }, - empty: { - paddingVertical: theme.spacing.lg, - paddingHorizontal: theme.spacing.md, - borderWidth: 1, - borderStyle: 'dashed', - borderColor: theme.colors.border, + reading: { + flex: 1, + marginTop: theme.spacing.md, }, - emptyTitle: { + readTag: { ...theme.text.label, fontFamily: theme.fonts.technical, - color: theme.colors.textMuted, + color: '#8a2c1c', }, - emptyText: { - ...theme.text.body, - color: theme.colors.textSecondary, - marginTop: theme.spacing.xs, + readTitle: { + ...theme.text.h1, + color: theme.colors.ink, + marginTop: px(6), }, - sealed: { - ...theme.text.label, - fontFamily: theme.fonts.technical, - color: theme.colors.textMuted, - marginTop: theme.spacing.sm, - marginLeft: theme.spacing.md, + inkRule: { + height: px(2), + width: px(120), + backgroundColor: theme.colors.ink, + opacity: 0.5, + marginVertical: theme.spacing.sm, }, - spacer: { - flex: 1, + readLine: { + ...theme.text.narration, + color: theme.colors.ink, + marginBottom: px(10), }, - detail: { + back: { + flexDirection: 'row', + }, + grid: { flex: 1, + flexDirection: 'row', + flexWrap: 'wrap', + alignContent: 'flex-start', + gap: px(28), + marginTop: theme.spacing.md, + }, + photo: { + width: PHOTO_W, + padding: px(12), + paddingBottom: px(10), + backgroundColor: '#f6f1e4', + shadowColor: '#000', + shadowOpacity: 0.35, + shadowRadius: px(8), + shadowOffset: {width: 0, height: px(4)}, + elevation: 6, + }, + photoWindow: { + height: PHOTO_W - px(24), + backgroundColor: theme.colors.ink, + alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', + }, + photoImage: { + width: '100%', + height: '100%', + }, + photoInitial: { + ...theme.text.display, + color: theme.colors.accent, + }, + photoCaption: { + ...theme.text.body, + fontSize: px(20), + lineHeight: px(28), + color: theme.colors.ink, + marginTop: px(8), + }, + pin: { + position: 'absolute', + top: -px(8), + alignSelf: 'center', + width: px(18), + height: px(18), + borderRadius: px(9), + backgroundColor: '#a3301f', + }, + sealedPhoto: { + height: PHOTO_W + px(36), + backgroundColor: 'transparent', + borderWidth: px(3), + borderStyle: 'dashed', + borderColor: 'rgba(40,30,20,0.35)', + alignItems: 'center', + justifyContent: 'center', + shadowOpacity: 0, + elevation: 0, + }, + sealedText: { + ...theme.text.display, + color: 'rgba(40,30,20,0.3)', }, }); diff --git a/apps/tv/src/screens/Puzzle/PuzzleScreen.tsx b/apps/tv/src/screens/Puzzle/PuzzleScreen.tsx index b2b421f..1a3d586 100644 --- a/apps/tv/src/screens/Puzzle/PuzzleScreen.tsx +++ b/apps/tv/src/screens/Puzzle/PuzzleScreen.tsx @@ -1,5 +1,6 @@ import React, {useState} from 'react'; -import {StyleSheet, Text, View} from 'react-native'; +import {Image, StyleSheet, Text, View} from 'react-native'; +import {deskArt} from '../../ui/keyArt'; import {combinationAnswer, getPuzzleView, getSceneView} from '@roomriddle/game-engine'; import type {GameEvent, Puzzle, ScoreBreakdown} from '@roomriddle/shared-types'; import {applyDigitKey, DigitEntry} from '../../components/DigitEntry/DigitEntry'; @@ -99,6 +100,7 @@ export const PuzzleScreen = ({navigator, route}: ScreenProps & {route: {puzzleId const byPhone = result === null; return ( + + @@ -320,7 +323,8 @@ type Notice = {kind: 'failure' | 'info' | 'hint'; title: string; text: string; a const styles = StyleSheet.create({ screen: { flex: 1, - paddingHorizontal: theme.spacing.screenX, + // Inside the desk's felt blotter (6–94 % of the width). + paddingHorizontal: px(160), paddingTop: theme.spacing.screenY - px(24), paddingBottom: theme.spacing.screenY - px(24), }, diff --git a/apps/tv/src/ui/keyArt.ts b/apps/tv/src/ui/keyArt.ts index 1de08e0..cbaffc0 100644 --- a/apps/tv/src/ui/keyArt.ts +++ b/apps/tv/src/ui/keyArt.ts @@ -6,6 +6,8 @@ import type {ImageSourcePropType} from 'react-native'; */ export const homeArt: ImageSourcePropType = require('../assets/ui/home.jpg'); export const libraryArt: ImageSourcePropType = require('../assets/ui/library.jpg'); +/** The detective's desk behind puzzles (felt blotter in the middle). */ +export const deskArt: ImageSourcePropType = require('../assets/ui/desk.jpg'); /** Detective Grace, full figure (900 px tall, transparent). */ export const graceArt: ImageSourcePropType = require('../assets/ui/grace.png'); export const GRACE_ASPECT = 0.5; diff --git a/apps/tv/src/world/Actors.tsx b/apps/tv/src/world/Actors.tsx index 28f8b9a..368ef87 100644 --- a/apps/tv/src/world/Actors.tsx +++ b/apps/tv/src/world/Actors.tsx @@ -56,7 +56,7 @@ export const TrainView = memo( )} - + ); @@ -87,7 +87,7 @@ export const TrainView = memo( )} - + ); diff --git a/apps/tv/src/world/Effects.tsx b/apps/tv/src/world/Effects.tsx index aa56009..6b30eaa 100644 --- a/apps/tv/src/world/Effects.tsx +++ b/apps/tv/src/world/Effects.tsx @@ -43,9 +43,9 @@ const rand = (i: number, salt: number) => { }; const RAIN_BANDS = [ - {count: 10, length: 26, width: 1.5, opacity: 0.18, duration: 1500, drift: 60, salt: 1}, - {count: 10, length: 46, width: 2, opacity: 0.28, duration: 1050, drift: 90, salt: 2}, - {count: 8, length: 90, width: 3, opacity: 0.4, duration: 650, drift: 140, salt: 3}, + {count: 6, length: 26, width: 1.5, opacity: 0.18, duration: 1500, drift: 60, salt: 1}, + {count: 7, length: 46, width: 2, opacity: 0.28, duration: 1050, drift: 90, salt: 2}, + {count: 6, length: 90, width: 3, opacity: 0.4, duration: 650, drift: 140, salt: 3}, ] as const; /** One band of rain: two stacked copies scrolling down forever (seamless). */ diff --git a/apps/tv/src/world/WorldScreen.tsx b/apps/tv/src/world/WorldScreen.tsx index 3a9a4aa..e3ba326 100644 --- a/apps/tv/src/world/WorldScreen.tsx +++ b/apps/tv/src/world/WorldScreen.tsx @@ -151,8 +151,9 @@ const WorldView = ({ state: GameState; hub: NonNullable>; }) => { - const {dispatch} = useGame(); + const {dispatch, serverBase} = useGame(); const session = useWorldSession(); + const voiceOf = (characterId: string | undefined) => mystery.truth?.characters.find(c => c.id === characterId)?.voice; const reduced = useReducedMotion(); const gameKey = `${mystery.summary.id}:${state.startedAt ?? 0}`; @@ -326,6 +327,11 @@ const WorldView = ({ if (line?.audioId) { worldAudio({name: 'dialogue', payload: {audioId: line.audioId, lineId: line.id}}); } + // No recorded take: the server speaks the subtitle in the character's voice (TTS), when it can. + if (line && !audio.hasVoice(line.audioId)) { + audio.speakUrl(`line.${line.id}`, speakUrlFor(serverBase, line.text, voiceOf(line.characterId))); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [line]); // ── Set transitions (push / pull / depth / fade / crossfade) ───────── @@ -572,6 +578,14 @@ const WorldView = ({ ); }; +/** The server's spoken-subtitle URL for a line in a character's voice (narrator: Sonia). */ +export function speakUrlFor(base: string, text: string, voice?: {edge: string; rate?: string; pitch?: string}): string { + const q = [`text=${encodeURIComponent(text)}`, `voice=${encodeURIComponent(voice?.edge ?? 'en-GB-SoniaNeural')}`]; + if (voice?.rate) q.push(`rate=${encodeURIComponent(voice.rate)}`); + if (voice?.pitch) q.push(`pitch=${encodeURIComponent(voice.pitch)}`); + return `${base}/speak?${q.join('&')}`; +} + /** `text` null: subtitles are off and the line is heard, so only the speaker shows. */ const DialogueBox = ({speaker, text, spoken, onNext, aside, side = 'center'}: {speaker: string; text: string | null; spoken: string; onNext: () => void; aside: boolean; side?: 'left' | 'right' | 'center'}) => ( diff --git a/apps/tv/test/App.spec.tsx b/apps/tv/test/App.spec.tsx index 8a6e222..f5864e7 100644 --- a/apps/tv/test/App.spec.tsx +++ b/apps/tv/test/App.spec.tsx @@ -285,6 +285,6 @@ describe('Phase 5 experience', () => { fireEvent.press(screen.getByTestId('details-select')); fireEvent.press(screen.getByTestId('intro-begin')); fireEvent.press(screen.getByTestId('scene-evidence')); - expect(screen.getByText('NO EVIDENCE YET')).toBeTruthy(); + expect(screen.getByText('No evidence yet')).toBeTruthy(); }); }); diff --git a/apps/tv/test/AudioJourney.spec.tsx b/apps/tv/test/AudioJourney.spec.tsx index 1541784..d19e5a7 100644 --- a/apps/tv/test/AudioJourney.spec.tsx +++ b/apps/tv/test/AudioJourney.spec.tsx @@ -365,7 +365,7 @@ describe('voice ducking through the game (Step 3)', () => { expect(traceLines.filter(l => l.kind === 'state').pop()!.msg).toContain('voice=-'); }, 60_000); - it('a voice line with no file: no duck, the subtitle text still shows, the game goes on', async () => { + it('a voice line with no file: spoken by TTS (ducked), the subtitle text still shows, the game goes on', async () => { delete (bundledAudio as Record)['last-train.vo.react-clock']; try { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); @@ -385,11 +385,15 @@ describe('voice ducking through the game (Step 3)', () => { await run(100); expect(screen.getByTestId('world-dialogue')).toBeTruthy(); expect(screen.getByText(/Eleven fifty-nine/)).toBeTruthy(); - expect(volOf('last-train.music.investigation')).toBeCloseTo(full, 5); + // No recorded take: the server speaks the subtitle (TTS), so the music ducks under it. + expect(volOf('last-train.music.investigation')!).toBeLessThan(full); await closeDialogue(screen); pressBack(); await run(800); expect(screen.getByTestId('world-object-obj-clock').props.accessibilityLabel).toBe('Platform clock. Examined'); + // Let the spoken subtitle's player finish releasing before the next test counts players. + screen.unmount(); + await run(1000); warn.mockRestore(); } finally { // afterEach restores the bundle. @@ -489,6 +493,9 @@ describe('settings during play (Step 6)', () => { describe('lifecycle (Step 4)', () => { it('a station run, then leaving the mystery: every one-shot released; only the library\'s menu bed remains', async () => { + // Earlier tests in this file may still be tearing players down; count only this run. + const before = audioStats.created - audioStats.released - audioStats.active; + const errorsBefore = audioStats.errors; const screen = render(); await run(100); await toStation(screen); @@ -514,8 +521,8 @@ describe('lifecycle (Step 4)', () => { const live = liveIds(); expect(live).toEqual(['core.music.menu']); // Counts: everything created is released except the one live bed. - expect(audioStats.created - audioStats.released).toBe(audioStats.active); - expect(audioStats.errors).toBe(0); + expect(audioStats.created - audioStats.released - audioStats.active).toBe(before); + expect(audioStats.errors).toBe(errorsBefore); console.log(`[LIFECYCLE] created=${audioStats.created} released=${audioStats.released} active=${audioStats.active} errors=${audioStats.errors} loops=${live.join(',')}`); }, 120_000); }); diff --git a/apps/tv/test/World.spec.tsx b/apps/tv/test/World.spec.tsx index 87d12f7..8881cff 100644 --- a/apps/tv/test/World.spec.tsx +++ b/apps/tv/test/World.spec.tsx @@ -171,7 +171,7 @@ describe('2.5D world: The Last Train', () => { press(screen, 'world-inspect-back'); await run(800); press(screen, 'scene-evidence'); - expect(screen.getByText('NO EVIDENCE YET')).toBeTruthy(); + expect(screen.getByText('No evidence yet')).toBeTruthy(); }, 30_000); it('Back during the cinematic skips it; Back in the world asks before leaving', async () => { diff --git a/docs/testing.md b/docs/testing.md index 15e8445..05fe61e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -24,10 +24,10 @@ Test files sit next to the code as `*.test.ts`. | `scene` | 52 | | `client` | 19 | | `ui` | 4 | -| `server` (rooms, multiplayer, AI prompt/guard/fallback/service, voices, HTTP) | 78 | -| `player` | 11 | -| **Root `npm run check`** | **244** | -| TV, Jest (`apps/tv/scripts/wsl-check.sh`) | 80 | +| `server` (rooms, multiplayer, AI prompt/guard/fallback/service, voices, spoken subtitles, HTTP) | 79 | +| `player` | 12 | +| **Root `npm run check`** | **246** | +| TV, Jest (`apps/tv/scripts/wsl-check.sh`) | 85 | CI (`.github/workflows/ci.yml`) runs `npm run check` on every push to `main` and every pull request, with `AI_DISABLED=1` and `VOICES_DISABLED=1`, so no keys or network are needed. The AI tests use fake providers. The TV suite needs the Vega SDK and runs locally. diff --git a/package-lock.json b/package-lock.json index b64bf2b..7d55ff2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "@roomriddle/mystery-data": "*", "@roomriddle/scene": "*", "@roomriddle/shared-types": "*", - "msedge-tts": "^2.0.9", + "msedge-tts": "^2.1.0", "qrcode-generator": "^2.0.4", "ws": "^8.22.0" }, @@ -1005,9 +1005,9 @@ "license": "MIT" }, "node_modules/msedge-tts": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/msedge-tts/-/msedge-tts-2.0.9.tgz", - "integrity": "sha512-Qw9pphrz/GTGee+XbXrmwXkDWsR6BVdKyDJKDZBC1RnmBMrZJ8VfMTC44j0v9dIptmLMkmLOuBtQX6LHuodcDw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msedge-tts/-/msedge-tts-2.1.0.tgz", + "integrity": "sha512-eHvM/8ymTQzYuF65NuEkWBNUm+d36t97xlx2dnY7UyRd0AuJ7yEj0oSginJZarTF3VTCzx9ux5cdf2Lj9TXZxA==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/packages/mystery-data/src/the-last-train/world/index.ts b/packages/mystery-data/src/the-last-train/world/index.ts index 8db5fbb..05cb9d1 100644 --- a/packages/mystery-data/src/the-last-train/world/index.ts +++ b/packages/mystery-data/src/the-last-train/world/index.ts @@ -46,6 +46,9 @@ const STATION_BACKDROP: WorldNode[] = [ // The camera follows the arriving train far to the left: the ground continues there too. {id: 'tracks-west', assetId: 'last-train.tracks', x: -7440, y: 790, z: -2}, {id: 'platform-west', assetId: 'last-train.station.platform', x: -7440, y: 990, z: 1}, + // Under the platform edge (bottom at y 1310), behind it: close-ups of low objects never show sky. + {id: 'ground', assetId: 'last-train.ground', x: -1840, y: 1280, z: 0}, + {id: 'ground-west', assetId: 'last-train.ground', x: -7440, y: 1280, z: 0}, {id: 'lamp-1', assetId: 'last-train.station.lights', x: 600, y: 440, z: 2}, {id: 'lamp-2', assetId: 'last-train.station.lights', x: 1500, y: 440, z: 2}, {id: 'lamp-3', assetId: 'last-train.station.lights', x: 2480, y: 440, z: 2}, diff --git a/packages/mystery-data/src/the-last-train/world/manifest.ts b/packages/mystery-data/src/the-last-train/world/manifest.ts index 09b2f6a..6aa1e67 100644 --- a/packages/mystery-data/src/the-last-train/world/manifest.ts +++ b/packages/mystery-data/src/the-last-train/world/manifest.ts @@ -124,6 +124,23 @@ const TRACKS = asset({ ], }); +/** + * Dark ground below the platform edge, on the platform's plane. Close-ups of + * low objects (the ticket, the carriage plan) zoom past the platform's lower + * edge; this keeps the night sky from ever showing there. Procedural on purpose. + */ +const GROUND = asset({ + id: 'last-train.ground', + type: 'background', + group: 'environment', + layer: 'midground', + depth: 0.75, + size: {width: 5600, height: 1400}, + animation: 'none', + description: 'Dark wet ground under the platform edge (never the focus of a shot).', + placeholder: [r(0, 0, 5600, 1400, 'coatDark')], +}); + const LAMP = asset({ id: 'last-train.station.lights', type: 'object', @@ -644,6 +661,7 @@ export const lastTrainManifest: AssetManifest = { STATION, PLATFORM, TRACKS, + GROUND, LAMP, POLE, BENCH,