diff --git a/CHANGELOG.md b/CHANGELOG.md
index 69057e3..4721661 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,14 @@
All notable changes, newest first. The project follows [Conventional Commits](https://www.conventionalcommits.org/); see `git log` for every commit.
-## [Unreleased]: public release prep
+## 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`).
+- Evidence cards show painted close-ups; the speech bubble shows painted portraits, with a "pressed" expression when shown evidence.
+- Home and library key art; case cards with painted headers.
+- Fix: a lightning flash cut short no longer leaves a grey veil.
+
+## 2026-10-13: Public release
- **Brand:** the app is now "Room Riddle", with a launcher icon on the TV and a favicon and web manifest on the phone.
- **Docs:** showcase README with screenshots; technical reference moved to `docs/system-design.md`; contributing guide, code of conduct, security policy, credits, `.env.example`, CI and issue/PR templates.
diff --git a/CREDITS.md b/CREDITS.md
index f095ae0..a6c5bb2 100644
--- a/CREDITS.md
+++ b/CREDITS.md
@@ -4,7 +4,7 @@
## Art
- App icon, banner and concept art: AI-assisted illustration, art-directed and edited by the author. Released with the project under the MIT licence.
-- TV scenes in the current build use code-drawn placeholder shapes; painted plates and character art are being added through the asset system.
+- The Last Train's scenes, objects, characters, clue close-ups and the menu key art are AI-assisted paintings, art-directed, cut out and edited by the author (`apps/tv/scripts/art/` sizes them for the TV). The other cases still use code-drawn placeholder shapes.
- Screenshots in `docs/images` were captured from the Vega Virtual Device and a phone browser.
## Audio
diff --git a/README.md b/README.md
index cb5efc3..918249c 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@
-
Asking Inspector Voss a question from a phone. He answers on the TV, in character and in his own voice.
+
Asking Inspector Voss a question from a phone. He answers on the TV, in character, in his own voice, with his painted portrait.
1. **Open a room on the TV.** It shows a 4-digit code and a QR code.
@@ -48,8 +48,21 @@
|:---:|:---:|
|
|
|
| **Home:** create or join a room with the remote | **Library:** five complete cases |
-|
|
|
-| **Lobby:** scan the QR code, see who's in | **The world:** a layered 2.5D scene you explore with the remote |
+|
|
|
+| **Lobby:** scan the QR code, see who's in | **Arrival:** the Northern Star pulls in through the rain |
+
+### Inside The Last Train
+
+Painted 2.5D scenes: depth layers move with the camera, rain and steam play over the art, and every clue object sits where it's painted.
+
+| | |
+|:---:|:---:|
+|
|
|
+| **Platform two:** focus objects with the remote | **Evidence:** every clue has its painted close-up |
+|
|
|
+| **Detective Grace** narrates and reacts to what you find | **On the platform:** a torn ticket in a puddle |
+|
|
|
+| **Carriage C:** the sleeper corridor | **The signal box:** two minutes to midnight |
### Your phone is your notebook
@@ -58,8 +71,6 @@
Join in seconds · examine evidence · question suspects · solve puzzles with three-step hints
-> **Art note:** the TV scenes in these screenshots use the built-in placeholder art. The painted art pass (illustrated plates, character portraits, video loops) goes in through the asset system without code changes; see [Status](#-status).
-
---
## ✦ The cases
@@ -141,7 +152,7 @@ npm run serve # build the phone app and start the server on :4000
**TV app** (inside Ubuntu or WSL2 with the Vega SDK):
```bash
-bash apps/tv/scripts/wsl-check.sh # typecheck + lint + 75 Jest tests
+bash apps/tv/scripts/wsl-check.sh # typecheck + lint + 80 Jest tests
bash apps/tv/scripts/wsl-build.sh # build the .vpkg
bash apps/tv/scripts/wsl-install.sh # install and launch on the Vega Virtual Device
```
@@ -214,7 +225,7 @@ The full technical reference is in **[docs/system-design.md](docs/system-design.
## ✦ Quality
-- **About 320 automated tests:** 58 engine, 22 content, 52 scene, 19 client, 4 UI tokens, 78 server, 11 phone and 75 TV.
+- **About 325 automated tests:** 58 engine, 22 content, 52 scene, 19 client, 4 UI tokens, 78 server, 11 phone and 80 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.
@@ -231,7 +242,8 @@ The full technical reference is in **[docs/system-design.md](docs/system-design.
| Phone app: Scene, Evidence and Question tabs | ✅ Done, tested |
| TV app: screens, lobby, settings, library, 2.5D world, audio | ✅ Done, verified on the Vega Virtual Device |
| AI suspect questioning with voices on the TV | ✅ Done, verified on the Vega Virtual Device |
-| Painted art, video loops and final audio | 🎨 In progress (assets in production) |
+| Painted art for The Last Train, portraits, clue close-ups, menu key art | ✅ Done, verified on the Vega Virtual Device |
+| Art for the other four cases, video loops, final audio | 🎨 In progress |
| UI redesign to the final art direction | 🎨 In progress |
| AI hints and narrator | 🔜 Next |
| Validation on Fire TV Stick hardware | 🔜 Next |
diff --git a/apps/tv/jest.config.json b/apps/tv/jest.config.json
index 250295d..0f5e8b9 100644
--- a/apps/tv/jest.config.json
+++ b/apps/tv/jest.config.json
@@ -16,7 +16,7 @@
"^~/(.*)$": "/src/$1",
"^@amazon-devices/react-native-async-storage__async-storage$": "/node_modules/@amazon-devices/react-native-async-storage__async-storage/jest/async-storage-mock",
"^@amazon-devices/react-native-w3cmedia$": "/test/support/w3cmedia.ts",
- "\\.(mp3|m4a|wav)$": "/test/support/fileStub.js"
+ "\\.(mp3|m4a|wav|png|jpg)$": "/test/support/fileStub.js"
},
"modulePaths": [
"/src"
diff --git a/apps/tv/scripts/art/prepare_last_train.py b/apps/tv/scripts/art/prepare_last_train.py
new file mode 100644
index 0000000..68c223d
--- /dev/null
+++ b/apps/tv/scripts/art/prepare_last_train.py
@@ -0,0 +1,247 @@
+#!/usr/bin/env python3
+"""
+Prepare The Last Train's painted art for the TV app.
+
+Reads the raw art (PNG, transparent where needed) from SRC and writes files
+sized exactly to each asset's manifest box into
+apps/tv/src/assets/mysteries/the-last-train/, so the TV can draw them with
+resizeMode "stretch" without distortion:
+
+ cover fill the box, crop the overflow (backgrounds)
+ contain fit inside the box, pad with transparency (objects, characters)
+ tile repeat horizontally, mirroring every other copy to hide seams
+
+Objects are stored at 2x their box (they are zoomed into when inspected);
+backgrounds at 1x. Opaque layers are JPEG, anything with alpha is PNG.
+
+It also records where the procedural clock hands go on each painted dial
+(measured by eye, see SPEC) (the hands show the clue times, so they stay procedural).
+
+Usage: python prepare_last_train.py [--only id,id]
+Requires Pillow, numpy, scipy.
+"""
+import json
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image, ImageDraw, ImageFilter
+from scipy import ndimage
+
+REPO = Path(__file__).resolve().parents[4]
+OUT = REPO / 'apps/tv/src/assets/mysteries/the-last-train'
+
+# id: (source relative to SRC, box w, box h, mode, options)
+SPEC = {
+ 'sky': ('last-train/last-train.sky.png', 2600, 1200, 'cover', {}),
+ 'city': ('last-train/last-train.city.png', 3400, 360, 'band', {'from': 0.52, 'fade': 0.45}),
+ 'station.background': ('last-train/last-train.station.background.png', 1700, 760, 'cover', {'fade': 0.22}),
+ 'station.platform': ('last-train/last-train.station.platform.png', 5600, 320, 'tile', {}),
+ 'tracks': ('last-train/last-train.tracks.png', 5600, 90, 'tile', {}),
+ 'station.lights': ('last-train/last-train.station.lights.png', 90, 560, 'contain', {'align': 'bottom'}),
+ 'foreground.pole': ('last-train/last-train.foreground.pole.png', 110, 1200, 'contain', {'align': 'bottom', 'stretch': True}),
+ 'bench': ('last-train/last-train.bench.png', 320, 120, 'contain', {'align': 'bottom'}),
+ 'train': ('last-train/last-train.train.png', 2600, 380, 'contain', {'align': 'bottom', 'stretch': True}),
+ 'train.interior': ('last-train/last-train.train.interior.png', 2400, 1180, 'cover', {}),
+ 'train.vestibule': ('last-train/last-train.train.vestibule.png', 2200, 1080, 'cover', {}),
+ 'signal-box.interior': ('last-train/last-train.signal-box.interior.png', 2300, 1180, 'cover', {}),
+ 'detective': ('shared/shared.grace.full.png', 240, 560, 'contain', {'align': 'bottom', 'scale': 2}),
+ 'clock': ('last-train/last-train.clock.png', 200, 200, 'contain', {'scale': 2, 'face': {'cx': 101, 'cy': 101, 'd': 118}}),
+ 'notice': ('last-train/last-train.notice.png', 180, 120, 'contain', {'scale': 2}),
+ 'waiting-room-clock': ('last-train/last-train.waiting-room-clock.png', 90, 90, 'disc', {'scale': 3, 'at': (0.502, 0.445, 0.27), 'face': {'cx': 45, 'cy': 45, 'd': 62}}),
+ 'suitcase': ('last-train/last-train.suitcase.png', 170, 120, 'contain', {'align': 'bottom', 'scale': 2}),
+ 'newspaper': ('last-train/last-train.newspaper.png', 150, 60, 'contain', {'align': 'bottom', 'scale': 2}),
+ 'ticket': ('last-train/closeups/last-train.ticket.closeup.png', 150, 80, 'contain', {'scale': 2}),
+ 'departure-board': ('last-train/last-train.departure-board.png', 420, 200, 'contain', {'scale': 2}),
+ 'carriage-plan': ('last-train/last-train.carriage-plan.png', 120, 150, 'contain', {'scale': 2}),
+ 'carriage.props': ('last-train/last-train.carriage.props.png', 420, 300, 'contain', {'align': 'bottom'}),
+ 'ledger': ('last-train/closeups/last-train.ledger.closeup.png', 170, 110, 'contain', {'scale': 2}),
+ 'pocket-watch': ('last-train/closeups/last-train.pocket-watch.closeup.png', 160, 90, 'contain', {'scale': 2, 'face': {'cx': 72.8, 'cy': 53.3, 'd': 44}}),
+ 'seat-card': ('last-train/last-train.seat-card.png', 110, 70, 'contain', {'scale': 2}),
+ 'brake': ('last-train/last-train.brake.png', 140, 337, 'contain', {'scale': 2}),
+ 'lever-frame': ('last-train/last-train.lever-frame.png', 900, 360, 'contain', {'align': 'bottom'}),
+ 'lamp-room-door': ('last-train/last-train.lamp-room-door.png', 240, 440, 'contain', {'align': 'bottom'}),
+}
+
+
+def trim(im: Image.Image) -> Image.Image:
+ """Crop to the visible pixels."""
+ if im.mode != 'RGBA':
+ return im
+ box = im.getchannel('A').point(lambda v: 255 if v > 12 else 0).getbbox()
+ return im.crop(box) if box else im
+
+
+def cover(im, w, h):
+ s = max(w / im.width, h / im.height)
+ im = im.resize((round(im.width * s), round(im.height * s)), Image.LANCZOS)
+ x, y = (im.width - w) // 2, (im.height - h) // 2
+ return im.crop((x, y, x + w, y + h))
+
+
+def contain(im, w, h, align='center', stretch=False):
+ im = trim(im.convert('RGBA'))
+ if stretch:
+ # Tall or long thin things (a pole, a train) fill their box exactly.
+ return im.resize((w, h), Image.LANCZOS), (0, 0, w, h)
+ s = min(w / im.width, h / im.height)
+ nw, nh = max(1, round(im.width * s)), max(1, round(im.height * s))
+ im = im.resize((nw, nh), Image.LANCZOS)
+ canvas = Image.new('RGBA', (w, h), (0, 0, 0, 0))
+ x = (w - nw) // 2
+ y = h - nh if align == 'bottom' else (h - nh) // 2
+ canvas.alpha_composite(im, (x, y))
+ return canvas, (x, y, nw, nh)
+
+
+def fade_top(im, frac):
+ im = im.convert('RGBA')
+ a = np.array(im)
+ n = int(im.height * frac)
+ ramp = np.clip(np.linspace(0, 1, n) ** 1.6, 0, 1)
+ a[:n, :, 3] = (a[:n, :, 3] * ramp[:, None]).astype(np.uint8)
+ return Image.fromarray(a)
+
+
+def tile(im, w, h):
+ im = im.convert('RGB')
+ s = h / im.height
+ t = im.resize((round(im.width * s), h), Image.LANCZOS)
+ out = Image.new('RGB', (w, h))
+ x, i = 0, 0
+ while x < w:
+ out.paste(t if i % 2 == 0 else t.transpose(Image.FLIP_LEFT_RIGHT), (x, 0))
+ x += t.width
+ i += 1
+ return out
+
+
+def disc(im, w, h, at):
+ """Cut the round clock out of an opaque square painting. `at` = (cx, cy, r) as fractions of the width, measured by eye."""
+ im = im.convert('RGBA')
+ cx, cy, r = at[0] * im.width, at[1] * im.width, at[2] * im.width
+ mask = Image.new('L', im.size, 0)
+ ImageDraw.Draw(mask).ellipse((cx - r, cy - r, cx + r, cy + r), fill=255)
+ im.putalpha(mask.filter(ImageFilter.GaussianBlur(1.5)))
+ im = im.crop((int(cx - r), int(cy - r), int(cx + r), int(cy + r)))
+ return im.resize((w, h), Image.LANCZOS)
+
+
+def face_of(im, box_w):
+ """Centre and diameter of the light clock face, in box px."""
+ a = np.array(im.convert('RGBA')).astype(float)
+ lum = a[..., :3].mean(axis=2)
+ light = (lum > 140) & (a[..., 3] > 200)
+ lab, n = ndimage.label(ndimage.binary_closing(light, iterations=3))
+ if n == 0:
+ return None
+ sizes = ndimage.sum(light, lab, range(n + 1))
+ k = int(np.argmax(sizes[1:]) + 1)
+ ys, xs = np.where(lab == k)
+ scale = box_w / im.width
+ d = ((xs.max() - xs.min()) + (ys.max() - ys.min())) / 2
+ return {'cx': round(float((xs.min() + xs.max()) / 2 * scale), 1), 'cy': round(float((ys.min() + ys.max()) / 2 * scale), 1), 'd': round(float(d * scale), 1)}
+
+
+def save(im, path_stem):
+ has_alpha = im.mode == 'RGBA' and np.array(im.getchannel('A')).min() < 250
+ if has_alpha:
+ p = path_stem.parent / (path_stem.name + '.png')
+ im.save(p, optimize=True)
+ else:
+ p = path_stem.parent / (path_stem.name + '.jpg')
+ im.convert('RGB').save(p, quality=86, optimize=True, progressive=False)
+ return p
+
+
+def extras(src: Path):
+ """Clue close-ups (shown on evidence cards) and speech-bubble portraits."""
+ closeups = {
+ 'platform-clock': 'clock', 'company-notice': 'notice', 'waiting-room-clock': 'waiting-room-clock',
+ 'torn-ticket': 'ticket', 'departure-board': 'departure-board', 'carriage-plan': 'carriage-plan',
+ 'pocket-watch': 'pocket-watch', 'conductors-ledger': 'ledger', 'seat-eight-card': 'seat-card',
+ 'wired-brake': 'brake', 'blackwater-lever': 'lever-frame', 'lamp-room-door': 'lamp-room-door',
+ }
+ d = OUT / 'closeups'
+ d.mkdir(exist_ok=True)
+ for clue, name in closeups.items():
+ im = trim(Image.open(src / f'last-train/closeups/last-train.{name}.closeup.png').convert('RGBA'))
+ im.thumbnail((560, 400), Image.LANCZOS)
+ im.save(d / f'{clue}.png', optimize=True)
+ print(f'closeup {clue:20s} {im.size}')
+ # Portraits: the head and shoulders of each expression, square.
+ portraits = {
+ 'voss': ('last-train/characters/last-train.voss.neutral.png', 'last-train/characters/last-train.voss.revealing.png'),
+ 'driver': ('last-train/characters/last-train.driver.neutral.png', 'last-train/characters/last-train.driver.uneasy.png'),
+ 'signalman': ('last-train/characters/last-train.signalman.nervous.png', 'last-train/characters/last-train.signalman.worried.png'),
+ 'caldwell': ('last-train/characters/last-train.caldwell.neutral.png', 'last-train/characters/last-train.caldwell.shaken.png'),
+ 'detective': ('shared/shared.grace.neutral.png', 'shared/shared.grace.thinking.png'),
+ }
+ d = OUT / 'portraits'
+ d.mkdir(exist_ok=True)
+ for who, files in portraits.items():
+ for mood, rel in zip(('calm', 'pressed'), files):
+ im = trim(Image.open(src / rel).convert('RGBA'))
+ a = np.array(im.getchannel('A')) > 40
+ top = int(np.argmax(a.any(axis=1)))
+ # Head: the opaque columns in the first 12% of the figure's height.
+ band = a[top:top + max(8, int(im.height * 0.12))]
+ cols = np.where(band.any(axis=0))[0]
+ cx = int((cols.min() + cols.max()) / 2)
+ # Grace's sources are half-length portraits; the others are full figures.
+ side = int(im.height * (0.5 if who == 'detective' else 0.27))
+ x0 = max(0, min(im.width - side, cx - side // 2)) if side <= im.width else cx - side // 2
+ box = (x0, max(0, top - int(side * 0.06)), x0 + side, max(0, top - int(side * 0.06)) + side)
+ sq = Image.new('RGBA', (side, side), (0, 0, 0, 0))
+ sq.alpha_composite(im.crop(box))
+ sq = sq.resize((264, 264), Image.LANCZOS)
+ sq.save(d / f'{who}.{mood}.png', optimize=True)
+ print(f'portrait {who}.{mood}')
+
+
+def main():
+ src = Path(sys.argv[1])
+ if '--extras' in sys.argv:
+ extras(src)
+ return
+ only = None
+ if '--only' in sys.argv:
+ only = set(sys.argv[sys.argv.index('--only') + 1].split(','))
+ OUT.mkdir(parents=True, exist_ok=True)
+ report_file = OUT / 'art-report.json'
+ report = json.loads(report_file.read_text()) if report_file.exists() else {}
+ for key, (rel, w, h, mode, o) in SPEC.items():
+ if only and key not in only:
+ continue
+ im = Image.open(src / rel)
+ k = o.get('scale', 1)
+ W, H = w * k, h * k
+ if mode == 'cover':
+ out = cover(im.convert('RGB'), W, H)
+ if o.get('fade'):
+ out = fade_top(out, o['fade'])
+ elif mode == 'band':
+ im = im.convert('RGB')
+ band = im.crop((0, int(im.height * o['from']), im.width, im.height))
+ out = fade_top(tile(band, W, H), o['fade'])
+ elif mode == 'tile':
+ out = tile(im, W, H)
+ elif mode == 'disc':
+ out = disc(im, W, H, o['at'])
+ else:
+ out, _ = contain(im, W, H, o.get('align', 'center'), o.get('stretch', False))
+ for ext in ('.png', '.jpg'):
+ (OUT / (key + ext)).unlink(missing_ok=True)
+ p = save(out, OUT / key)
+ entry = {'file': p.name, 'px': list(out.size), 'kb': round(p.stat().st_size / 1024)}
+ if o.get('face'):
+ # Where the procedural hands go (box px). Measured by eye on the
+ # painted dials; face_of() is the automatic first guess.
+ entry['face'] = o['face'] if isinstance(o['face'], dict) else face_of(out, w)
+ report[key] = entry
+ print(f'{key:22s} {p.name:28s} {out.size[0]}x{out.size[1]} {entry["kb"]:>5} KB {entry.get("face", "")}')
+ report_file.write_text(json.dumps(report, indent=2) + '\n')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/apps/tv/scripts/art/prepare_ui.py b/apps/tv/scripts/art/prepare_ui.py
new file mode 100644
index 0000000..e6c97e7
--- /dev/null
+++ b/apps/tv/scripts/art/prepare_ui.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""
+Prepare the shared UI key art for the TV app: Home and Library backgrounds and
+the five case-card paintings, with the darkening baked in so text on top
+stays readable without runtime gradients.
+
+Usage: python prepare_ui.py
+Writes apps/tv/src/assets/ui/. Requires Pillow and numpy.
+"""
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+REPO = Path(__file__).resolve().parents[4]
+OUT = REPO / 'apps/tv/src/assets/ui'
+
+CARDS = {
+ 'the-last-train': 'shared.card.last-train.png',
+ 'the-hollow-manor': 'shared.card.hollow-manor.png',
+ 'signal-lost': 'shared.card.signal-lost.png',
+ 'the-forgotten-temple': 'shared.card.forgotten-temple.png',
+ 'the-yakshas-questions': 'shared.card.yaksha.png',
+}
+
+
+def cover(im, w, h, focus_x=0.5):
+ s = max(w / im.width, h / im.height)
+ im = im.resize((round(im.width * s), round(im.height * s)), Image.LANCZOS)
+ x = int((im.width - w) * focus_x)
+ y = (im.height - h) // 2
+ return im.crop((x, y, x + w, y + h))
+
+
+def shade(im, f):
+ """Multiply by a per-pixel brightness map f(xs, ys) in 0..1 (coordinates 0..1)."""
+ a = np.asarray(im.convert('RGB')).astype(float)
+ h, w = a.shape[:2]
+ ys, xs = np.mgrid[0:h, 0:w]
+ m = f(xs / (w - 1), ys / (h - 1))
+ return Image.fromarray(np.clip(a * m[..., None], 0, 255).astype(np.uint8))
+
+
+def main():
+ src = Path(sys.argv[1]) / 'shared'
+ (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.
+ home = cover(Image.open(src / 'shared.home.bg.png').convert('RGB'), 1920, 1080)
+ home = shade(home, lambda x, y: 0.42 + 0.5 * np.clip(np.abs(x - 0.5) * 2, 0, 1) ** 1.5 * (1 - 0.35 * y))
+ home.save(OUT / 'home.jpg', quality=84, optimize=True)
+
+ # Library: five cards across the middle; keep the desk lamp glow at the right edge.
+ lib = cover(Image.open(src / 'shared.library.bg.png').convert('RGB'), 1920, 1080)
+ lib = shade(lib, lambda x, y: 0.35 + 0.55 * np.clip((x - 0.55) / 0.45, 0, 1) * np.clip(1.2 - y, 0, 1))
+ lib.save(OUT / 'library.jpg', quality=84, optimize=True)
+
+ # Cards: a header painting (312 x 150 design px at 2x), the text sits below it on the card.
+ for mystery_id, name in CARDS.items():
+ im = Image.open(src / name).convert('RGB')
+ # The subject sits in the upper middle of these portrait paintings.
+ top = int(im.height * 0.12)
+ im = im.crop((0, top, im.width, top + int(im.width * 150 / 312)))
+ card = cover(im, 624, 300)
+ card = shade(card, lambda x, y: 0.95 - 0.3 * np.clip((y - 0.7) / 0.3, 0, 1))
+ card.save(OUT / 'cards' / f'{mystery_id}.jpg', quality=84, optimize=True)
+ for p in sorted(OUT.rglob('*.jpg')):
+ print(p.relative_to(OUT), round(p.stat().st_size / 1024), 'KB')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/apps/tv/src/assets/mysteries/the-last-train/art-report.json b/apps/tv/src/assets/mysteries/the-last-train/art-report.json
new file mode 100644
index 0000000..6792b3b
--- /dev/null
+++ b/apps/tv/src/assets/mysteries/the-last-train/art-report.json
@@ -0,0 +1,241 @@
+{
+ "clock": {
+ "file": "clock.png",
+ "px": [
+ 400,
+ 400
+ ],
+ "kb": 183,
+ "face": {
+ "cx": 101,
+ "cy": 101,
+ "d": 118
+ }
+ },
+ "waiting-room-clock": {
+ "file": "waiting-room-clock.png",
+ "px": [
+ 270,
+ 270
+ ],
+ "kb": 106,
+ "face": {
+ "cx": 45,
+ "cy": 45,
+ "d": 62
+ }
+ },
+ "pocket-watch": {
+ "file": "pocket-watch.png",
+ "px": [
+ 320,
+ 180
+ ],
+ "kb": 81,
+ "face": {
+ "cx": 72.8,
+ "cy": 53.3,
+ "d": 44
+ }
+ },
+ "sky": {
+ "file": "sky.jpg",
+ "px": [
+ 2600,
+ 1200
+ ],
+ "kb": 235
+ },
+ "city": {
+ "file": "city.png",
+ "px": [
+ 3400,
+ 360
+ ],
+ "kb": 1370
+ },
+ "station.background": {
+ "file": "station.background.png",
+ "px": [
+ 1700,
+ 760
+ ],
+ "kb": 2000
+ },
+ "station.platform": {
+ "file": "station.platform.jpg",
+ "px": [
+ 5600,
+ 320
+ ],
+ "kb": 638
+ },
+ "tracks": {
+ "file": "tracks.jpg",
+ "px": [
+ 5600,
+ 90
+ ],
+ "kb": 178
+ },
+ "station.lights": {
+ "file": "station.lights.png",
+ "px": [
+ 90,
+ 560
+ ],
+ "kb": 48
+ },
+ "foreground.pole": {
+ "file": "foreground.pole.png",
+ "px": [
+ 110,
+ 1200
+ ],
+ "kb": 102
+ },
+ "bench": {
+ "file": "bench.png",
+ "px": [
+ 320,
+ 120
+ ],
+ "kb": 51
+ },
+ "train": {
+ "file": "train.png",
+ "px": [
+ 2600,
+ 380
+ ],
+ "kb": 1470
+ },
+ "train.interior": {
+ "file": "train.interior.jpg",
+ "px": [
+ 2400,
+ 1180
+ ],
+ "kb": 431
+ },
+ "train.vestibule": {
+ "file": "train.vestibule.jpg",
+ "px": [
+ 2200,
+ 1080
+ ],
+ "kb": 277
+ },
+ "signal-box.interior": {
+ "file": "signal-box.interior.jpg",
+ "px": [
+ 2300,
+ 1180
+ ],
+ "kb": 461
+ },
+ "detective": {
+ "file": "detective.png",
+ "px": [
+ 480,
+ 1120
+ ],
+ "kb": 632
+ },
+ "notice": {
+ "file": "notice.png",
+ "px": [
+ 360,
+ 240
+ ],
+ "kb": 154
+ },
+ "suitcase": {
+ "file": "suitcase.png",
+ "px": [
+ 340,
+ 240
+ ],
+ "kb": 119
+ },
+ "newspaper": {
+ "file": "newspaper.png",
+ "px": [
+ 300,
+ 120
+ ],
+ "kb": 64
+ },
+ "ticket": {
+ "file": "ticket.png",
+ "px": [
+ 300,
+ 160
+ ],
+ "kb": 73
+ },
+ "departure-board": {
+ "file": "departure-board.png",
+ "px": [
+ 840,
+ 400
+ ],
+ "kb": 505
+ },
+ "carriage-plan": {
+ "file": "carriage-plan.png",
+ "px": [
+ 240,
+ 300
+ ],
+ "kb": 125
+ },
+ "carriage.props": {
+ "file": "carriage.props.png",
+ "px": [
+ 420,
+ 300
+ ],
+ "kb": 137
+ },
+ "ledger": {
+ "file": "ledger.png",
+ "px": [
+ 340,
+ 220
+ ],
+ "kb": 117
+ },
+ "seat-card": {
+ "file": "seat-card.png",
+ "px": [
+ 220,
+ 140
+ ],
+ "kb": 44
+ },
+ "brake": {
+ "file": "brake.png",
+ "px": [
+ 280,
+ 674
+ ],
+ "kb": 355
+ },
+ "lever-frame": {
+ "file": "lever-frame.png",
+ "px": [
+ 900,
+ 360
+ ],
+ "kb": 349
+ },
+ "lamp-room-door": {
+ "file": "lamp-room-door.png",
+ "px": [
+ 240,
+ 440
+ ],
+ "kb": 192
+ }
+}
diff --git a/apps/tv/src/assets/mysteries/the-last-train/bench.png b/apps/tv/src/assets/mysteries/the-last-train/bench.png
new file mode 100644
index 0000000..b1dd3e0
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/bench.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/brake.png b/apps/tv/src/assets/mysteries/the-last-train/brake.png
new file mode 100644
index 0000000..e39a1ad
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/brake.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/carriage-plan.png b/apps/tv/src/assets/mysteries/the-last-train/carriage-plan.png
new file mode 100644
index 0000000..b0c034d
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/carriage-plan.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/carriage.props.png b/apps/tv/src/assets/mysteries/the-last-train/carriage.props.png
new file mode 100644
index 0000000..0f80701
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/carriage.props.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/city.png b/apps/tv/src/assets/mysteries/the-last-train/city.png
new file mode 100644
index 0000000..fed2a3e
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/city.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/clock.png b/apps/tv/src/assets/mysteries/the-last-train/clock.png
new file mode 100644
index 0000000..e658d7b
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/clock.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/closeups/blackwater-lever.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/blackwater-lever.png
new file mode 100644
index 0000000..3208721
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/closeups/blackwater-lever.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/closeups/carriage-plan.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/carriage-plan.png
new file mode 100644
index 0000000..13fd524
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/closeups/carriage-plan.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/closeups/company-notice.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/company-notice.png
new file mode 100644
index 0000000..6bfdec8
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/closeups/company-notice.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/closeups/conductors-ledger.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/conductors-ledger.png
new file mode 100644
index 0000000..15a8b4b
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/closeups/conductors-ledger.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/closeups/departure-board.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/departure-board.png
new file mode 100644
index 0000000..47b7241
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/closeups/departure-board.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/closeups/lamp-room-door.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/lamp-room-door.png
new file mode 100644
index 0000000..afb68a5
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/closeups/lamp-room-door.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/closeups/platform-clock.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/platform-clock.png
new file mode 100644
index 0000000..6e1f2cc
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/closeups/platform-clock.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/closeups/pocket-watch.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/pocket-watch.png
new file mode 100644
index 0000000..97c9b92
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/closeups/pocket-watch.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/closeups/seat-eight-card.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/seat-eight-card.png
new file mode 100644
index 0000000..f9923a3
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/closeups/seat-eight-card.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/closeups/torn-ticket.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/torn-ticket.png
new file mode 100644
index 0000000..d2d0f53
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/closeups/torn-ticket.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/closeups/waiting-room-clock.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/waiting-room-clock.png
new file mode 100644
index 0000000..2aaee6a
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/closeups/waiting-room-clock.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/closeups/wired-brake.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/wired-brake.png
new file mode 100644
index 0000000..aa8d5dd
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/closeups/wired-brake.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/departure-board.png b/apps/tv/src/assets/mysteries/the-last-train/departure-board.png
new file mode 100644
index 0000000..ba8faaf
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/departure-board.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/detective.png b/apps/tv/src/assets/mysteries/the-last-train/detective.png
new file mode 100644
index 0000000..626416c
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/detective.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/foreground.pole.png b/apps/tv/src/assets/mysteries/the-last-train/foreground.pole.png
new file mode 100644
index 0000000..15a7175
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/foreground.pole.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/lamp-room-door.png b/apps/tv/src/assets/mysteries/the-last-train/lamp-room-door.png
new file mode 100644
index 0000000..9de21b0
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/lamp-room-door.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/ledger.png b/apps/tv/src/assets/mysteries/the-last-train/ledger.png
new file mode 100644
index 0000000..ebc6bbf
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/ledger.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/lever-frame.png b/apps/tv/src/assets/mysteries/the-last-train/lever-frame.png
new file mode 100644
index 0000000..3f59a88
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/lever-frame.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/newspaper.png b/apps/tv/src/assets/mysteries/the-last-train/newspaper.png
new file mode 100644
index 0000000..5d5c5a4
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/newspaper.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/notice.png b/apps/tv/src/assets/mysteries/the-last-train/notice.png
new file mode 100644
index 0000000..b6e1bac
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/notice.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/pocket-watch.png b/apps/tv/src/assets/mysteries/the-last-train/pocket-watch.png
new file mode 100644
index 0000000..39c4ff6
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/pocket-watch.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/portraits/caldwell.calm.png b/apps/tv/src/assets/mysteries/the-last-train/portraits/caldwell.calm.png
new file mode 100644
index 0000000..ee3fcb5
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/portraits/caldwell.calm.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/portraits/caldwell.pressed.png b/apps/tv/src/assets/mysteries/the-last-train/portraits/caldwell.pressed.png
new file mode 100644
index 0000000..79c9bf2
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/portraits/caldwell.pressed.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/portraits/detective.calm.png b/apps/tv/src/assets/mysteries/the-last-train/portraits/detective.calm.png
new file mode 100644
index 0000000..084c6b5
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/portraits/detective.calm.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/portraits/detective.pressed.png b/apps/tv/src/assets/mysteries/the-last-train/portraits/detective.pressed.png
new file mode 100644
index 0000000..41c05fa
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/portraits/detective.pressed.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/portraits/driver.calm.png b/apps/tv/src/assets/mysteries/the-last-train/portraits/driver.calm.png
new file mode 100644
index 0000000..014562c
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/portraits/driver.calm.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/portraits/driver.pressed.png b/apps/tv/src/assets/mysteries/the-last-train/portraits/driver.pressed.png
new file mode 100644
index 0000000..7470091
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/portraits/driver.pressed.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/portraits/signalman.calm.png b/apps/tv/src/assets/mysteries/the-last-train/portraits/signalman.calm.png
new file mode 100644
index 0000000..71813df
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/portraits/signalman.calm.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/portraits/signalman.pressed.png b/apps/tv/src/assets/mysteries/the-last-train/portraits/signalman.pressed.png
new file mode 100644
index 0000000..dc5c330
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/portraits/signalman.pressed.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/portraits/voss.calm.png b/apps/tv/src/assets/mysteries/the-last-train/portraits/voss.calm.png
new file mode 100644
index 0000000..5946b9b
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/portraits/voss.calm.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/portraits/voss.pressed.png b/apps/tv/src/assets/mysteries/the-last-train/portraits/voss.pressed.png
new file mode 100644
index 0000000..1f0dbde
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/portraits/voss.pressed.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/seat-card.png b/apps/tv/src/assets/mysteries/the-last-train/seat-card.png
new file mode 100644
index 0000000..3f7bda3
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/seat-card.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/signal-box.interior.jpg b/apps/tv/src/assets/mysteries/the-last-train/signal-box.interior.jpg
new file mode 100644
index 0000000..dafb934
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/signal-box.interior.jpg differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/sky.jpg b/apps/tv/src/assets/mysteries/the-last-train/sky.jpg
new file mode 100644
index 0000000..7337000
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/sky.jpg differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/station.background.png b/apps/tv/src/assets/mysteries/the-last-train/station.background.png
new file mode 100644
index 0000000..4d87a71
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/station.background.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/station.lights.png b/apps/tv/src/assets/mysteries/the-last-train/station.lights.png
new file mode 100644
index 0000000..77ab348
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/station.lights.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/station.platform.jpg b/apps/tv/src/assets/mysteries/the-last-train/station.platform.jpg
new file mode 100644
index 0000000..c97b4e7
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/station.platform.jpg differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/suitcase.png b/apps/tv/src/assets/mysteries/the-last-train/suitcase.png
new file mode 100644
index 0000000..3f73004
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/suitcase.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/ticket.png b/apps/tv/src/assets/mysteries/the-last-train/ticket.png
new file mode 100644
index 0000000..c6bba23
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/ticket.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/tracks.jpg b/apps/tv/src/assets/mysteries/the-last-train/tracks.jpg
new file mode 100644
index 0000000..90a8e88
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/tracks.jpg differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/train.interior.jpg b/apps/tv/src/assets/mysteries/the-last-train/train.interior.jpg
new file mode 100644
index 0000000..a237bac
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/train.interior.jpg differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/train.png b/apps/tv/src/assets/mysteries/the-last-train/train.png
new file mode 100644
index 0000000..e9769b7
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/train.png differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/train.vestibule.jpg b/apps/tv/src/assets/mysteries/the-last-train/train.vestibule.jpg
new file mode 100644
index 0000000..4010fa0
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/train.vestibule.jpg differ
diff --git a/apps/tv/src/assets/mysteries/the-last-train/waiting-room-clock.png b/apps/tv/src/assets/mysteries/the-last-train/waiting-room-clock.png
new file mode 100644
index 0000000..9653cda
Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/waiting-room-clock.png differ
diff --git a/apps/tv/src/assets/ui/cards/signal-lost.jpg b/apps/tv/src/assets/ui/cards/signal-lost.jpg
new file mode 100644
index 0000000..a0ac8fd
Binary files /dev/null and b/apps/tv/src/assets/ui/cards/signal-lost.jpg differ
diff --git a/apps/tv/src/assets/ui/cards/the-forgotten-temple.jpg b/apps/tv/src/assets/ui/cards/the-forgotten-temple.jpg
new file mode 100644
index 0000000..b7f67c3
Binary files /dev/null and b/apps/tv/src/assets/ui/cards/the-forgotten-temple.jpg differ
diff --git a/apps/tv/src/assets/ui/cards/the-hollow-manor.jpg b/apps/tv/src/assets/ui/cards/the-hollow-manor.jpg
new file mode 100644
index 0000000..c42d9f5
Binary files /dev/null and b/apps/tv/src/assets/ui/cards/the-hollow-manor.jpg differ
diff --git a/apps/tv/src/assets/ui/cards/the-last-train.jpg b/apps/tv/src/assets/ui/cards/the-last-train.jpg
new file mode 100644
index 0000000..1350226
Binary files /dev/null and b/apps/tv/src/assets/ui/cards/the-last-train.jpg differ
diff --git a/apps/tv/src/assets/ui/cards/the-yakshas-questions.jpg b/apps/tv/src/assets/ui/cards/the-yakshas-questions.jpg
new file mode 100644
index 0000000..6f6e41f
Binary files /dev/null and b/apps/tv/src/assets/ui/cards/the-yakshas-questions.jpg differ
diff --git a/apps/tv/src/assets/ui/home.jpg b/apps/tv/src/assets/ui/home.jpg
new file mode 100644
index 0000000..d3305a8
Binary files /dev/null and b/apps/tv/src/assets/ui/home.jpg differ
diff --git a/apps/tv/src/assets/ui/library.jpg b/apps/tv/src/assets/ui/library.jpg
new file mode 100644
index 0000000..62f8be9
Binary files /dev/null and b/apps/tv/src/assets/ui/library.jpg differ
diff --git a/apps/tv/src/components/ClueCard/ClueCard.tsx b/apps/tv/src/components/ClueCard/ClueCard.tsx
index dfec567..6154e2c 100644
--- a/apps/tv/src/components/ClueCard/ClueCard.tsx
+++ b/apps/tv/src/components/ClueCard/ClueCard.tsx
@@ -1,13 +1,15 @@
import React from 'react';
-import {StyleSheet, Text, View} from 'react-native';
+import {Image, StyleSheet, Text, View} from 'react-native';
import type {Clue} from '@roomriddle/shared-types';
import {px, theme} from '../../theme/theme';
import {Transition} from '../../ui/Transition';
+import {closeupFor} from '../../world/art';
/**
* A discovered clue, presented as a found document: a paper slip with an
* evidence number tab. Key evidence carries a stamp. `fresh` plays the
* discovery reveal (scale up out of the dark) once, for a clue just found.
+ * A clue with painted close-up art shows it pinned beside the text.
*/
export const ClueCard = ({
clue,
@@ -25,8 +27,10 @@ export const ClueCard = ({
testID?: string;
}) => {
const number = index !== undefined ? `#${String(index).padStart(2, '0')}` : '';
+ const closeup = closeupFor(clue.id);
const body = (
-
+
+
EVIDENCE {number}
{clue.major && (
@@ -43,6 +47,12 @@ export const ClueCard = ({
{line}
))}
+
+ {closeup !== undefined && (
+
+
+
+ )}
);
return fresh ? (
@@ -64,6 +74,31 @@ const styles = StyleSheet.create({
compact: {
padding: theme.spacing.md,
},
+ withPhoto: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: theme.spacing.md,
+ },
+ text: {
+ flex: 1,
+ },
+ // A photograph pinned to the slip: dark mount, slight tilt.
+ photo: {
+ width: px(240),
+ height: px(190),
+ padding: px(8),
+ backgroundColor: theme.colors.ink,
+ transform: [{rotate: '2deg'}],
+ },
+ photoCompact: {
+ width: px(170),
+ height: px(136),
+ padding: px(6),
+ },
+ photoImage: {
+ width: '100%',
+ height: '100%',
+ },
tabRow: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/apps/tv/src/components/MysteryCard/MysteryCard.tsx b/apps/tv/src/components/MysteryCard/MysteryCard.tsx
index 4178179..4d47bed 100644
--- a/apps/tv/src/components/MysteryCard/MysteryCard.tsx
+++ b/apps/tv/src/components/MysteryCard/MysteryCard.tsx
@@ -1,9 +1,10 @@
import React, {useEffect, useRef} from 'react';
-import {Animated, Easing, StyleSheet, Text, View} from 'react-native';
+import {Animated, Easing, Image, StyleSheet, Text, View} from 'react-native';
import {FocusableCard} from '../FocusableCard/FocusableCard';
import {formatPlayers, TVMystery} from '../../data/mysteries';
import {px, theme} from '../../theme/theme';
import {useReducedMotion} from '../../ui/motion';
+import {cardArtFor} from '../../ui/keyArt';
export interface MysteryCardProps {
mystery: TVMystery;
@@ -15,7 +16,8 @@ export interface MysteryCardProps {
// Five cases fit across the 1664 px safe width (1920 − 2 × 128) with room for the focus scale.
export const CARD_WIDTH = px(312);
-const CARD_HEIGHT = px(500);
+// Taller since the painted header: it still ends above the footer row.
+const CARD_HEIGHT = px(540);
export const MysteryCard = ({
mystery,
@@ -25,6 +27,9 @@ export const MysteryCard = ({
testID,
}: MysteryCardProps) => {
const available = mystery.availability === 'available';
+ const art = cardArtFor(mystery.id);
+ // Under a painted header a three-line title leaves room for one line of description.
+ const descriptionLines = art === undefined ? 3 : mystery.title.length > 22 ? 1 : 2;
return (
(
<>
+ {art !== undefined && (
+
+
+
+ )}
{mystery.genre.toUpperCase()}
{selected ? (
@@ -51,7 +61,7 @@ export const MysteryCard = ({
numberOfLines={3}>
{mystery.title}
-
+
{mystery.shortDescription}
@@ -135,6 +145,24 @@ const styles = StyleSheet.create({
width: CARD_WIDTH,
height: CARD_HEIGHT,
padding: theme.spacing.lg,
+ overflow: 'hidden',
+ },
+ // The case painting as a header, bleeding to the card's edges.
+ artFrame: {
+ height: px(150),
+ marginTop: -theme.spacing.lg,
+ marginHorizontal: -theme.spacing.lg,
+ marginBottom: theme.spacing.md,
+ borderBottomWidth: px(2),
+ borderBottomColor: theme.colors.accentSoft,
+ },
+ art: {
+ width: '100%',
+ height: '100%',
+ opacity: 0.82,
+ },
+ artFocused: {
+ opacity: 1,
},
// Label tracking (6) is sized for headings; on a 382 px card two labels need tighter
// tracking and an explicit gap or they run together ("THRILLERSELECTED").
@@ -165,11 +193,11 @@ const styles = StyleSheet.create({
},
title: {
...theme.text.h1,
- fontSize: px(34),
- lineHeight: px(40),
+ fontSize: px(30),
+ lineHeight: px(36),
letterSpacing: 0,
textTransform: 'none',
- marginTop: theme.spacing.md,
+ marginTop: theme.spacing.sm,
color: theme.colors.textPrimary,
},
titleFocused: {
diff --git a/apps/tv/src/game/CharacterSpeech.tsx b/apps/tv/src/game/CharacterSpeech.tsx
index c2a265c..8766bc4 100644
--- a/apps/tv/src/game/CharacterSpeech.tsx
+++ b/apps/tv/src/game/CharacterSpeech.tsx
@@ -1,9 +1,10 @@
import React, {useEffect, useRef, useState} from 'react';
-import {Animated, StyleSheet, Text, View} from 'react-native';
+import {Animated, Image, StyleSheet, Text, View} from 'react-native';
import type {CharacterReply} from '@roomriddle/shared-types';
import {useAudio} from '../audio/AudioContext';
import {px, theme} from '../theme/theme';
import {timing, useReducedMotion} from '../ui/motion';
+import {portraitFor} from '../world/art';
import {useGame} from './GameContext';
/** How long a reply may wait for its voice before the subtitle shows anyway. */
@@ -126,7 +127,7 @@ export const CharacterSpeech = () => {
return (
-
+
{thinker.name.toUpperCase()}
{thinking!.askedBy ? `${thinking!.askedBy} asks…` : 'The TV asks…'}
@@ -142,7 +143,7 @@ export const CharacterSpeech = () => {
return (
-
+
{reply.characterName.toUpperCase()}
@@ -157,12 +158,15 @@ export const CharacterSpeech = () => {
);
};
-const Portrait = ({name}: {name: string}) => (
- // Placeholder until the painted character portraits land (Asset List §characters).
-
- {initials(name)}
-
-);
+/** 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);
+ return (
+
+ {art ? : {initials(name)}}
+
+ );
+};
const styles = StyleSheet.create({
dock: {
@@ -199,6 +203,11 @@ const styles = StyleSheet.create({
borderColor: theme.colors.accent,
alignItems: 'center',
justifyContent: 'center',
+ overflow: 'hidden',
+ },
+ portraitImage: {
+ width: '100%',
+ height: '100%',
},
portraitText: {
...theme.text.h2,
diff --git a/apps/tv/src/screens/Home/HomeScreen.tsx b/apps/tv/src/screens/Home/HomeScreen.tsx
index 3f34dd5..0322de1 100644
--- a/apps/tv/src/screens/Home/HomeScreen.tsx
+++ b/apps/tv/src/screens/Home/HomeScreen.tsx
@@ -1,11 +1,12 @@
import React, {useState} from 'react';
-import {BackHandler, StyleSheet, Text, View} from 'react-native';
+import {BackHandler, Image, StyleSheet, Text, View} from 'react-native';
import {TVButton} from '../../components/TVButton/TVButton';
import {findMystery} from '../../data/mysteries';
import {Route} from '../../navigation/TVNavigation';
import {useBackInterceptor} from '../../navigation/useBackInterceptor';
import {px, theme} from '../../theme/theme';
import {Transition} from '../../ui/Transition';
+import {homeArt} from '../../ui/keyArt';
import {ScreenProps} from '../types';
interface MenuItem {
@@ -58,6 +59,7 @@ export const HomeScreen = ({navigator, initialFocusKey, selectedMysteryId}: Scre
return (
+
INVESTIGATION SYSTEM
diff --git a/apps/tv/src/screens/Mysteries/MysteriesScreen.tsx b/apps/tv/src/screens/Mysteries/MysteriesScreen.tsx
index 50d28e2..9209fae 100644
--- a/apps/tv/src/screens/Mysteries/MysteriesScreen.tsx
+++ b/apps/tv/src/screens/Mysteries/MysteriesScreen.tsx
@@ -1,11 +1,12 @@
import React from 'react';
-import {StyleSheet, Text, View} from 'react-native';
+import {Image, StyleSheet, Text, View} from 'react-native';
import {MysteryCard} from '../../components/MysteryCard/MysteryCard';
import {ScreenHeader} from '../../components/ScreenHeader/ScreenHeader';
import {TVButton} from '../../components/TVButton/TVButton';
import {mysteries} from '../../data/mysteries';
import {px, theme} from '../../theme/theme';
import {Transition} from '../../ui/Transition';
+import {libraryArt} from '../../ui/keyArt';
import {ScreenProps} from '../types';
export const MysteriesScreen = ({
@@ -17,6 +18,7 @@ export const MysteriesScreen = ({
return (
+
diff --git a/apps/tv/src/ui/keyArt.ts b/apps/tv/src/ui/keyArt.ts
new file mode 100644
index 0000000..0819794
--- /dev/null
+++ b/apps/tv/src/ui/keyArt.ts
@@ -0,0 +1,21 @@
+import type {ImageSourcePropType} from 'react-native';
+
+/**
+ * Shared key art for the menus. Files are made by
+ * apps/tv/scripts/art/prepare_ui.py (darkening baked in for legible text).
+ */
+export const homeArt: ImageSourcePropType = require('../assets/ui/home.jpg');
+export const libraryArt: ImageSourcePropType = require('../assets/ui/library.jpg');
+
+const cards: Readonly> = {
+ 'the-last-train': require('../assets/ui/cards/the-last-train.jpg'),
+ 'the-hollow-manor': require('../assets/ui/cards/the-hollow-manor.jpg'),
+ 'signal-lost': require('../assets/ui/cards/signal-lost.jpg'),
+ 'the-forgotten-temple': require('../assets/ui/cards/the-forgotten-temple.jpg'),
+ 'the-yakshas-questions': require('../assets/ui/cards/the-yakshas-questions.jpg'),
+};
+
+/** Painted card art for a case, if it has any. */
+export function cardArtFor(mysteryId: string): ImageSourcePropType | undefined {
+ return cards[mysteryId];
+}
diff --git a/apps/tv/src/world/Actors.tsx b/apps/tv/src/world/Actors.tsx
index 01e4bd2..e0900e8 100644
--- a/apps/tv/src/world/Actors.tsx
+++ b/apps/tv/src/world/Actors.tsx
@@ -4,7 +4,7 @@ import type {CharacterState, PlaceholderShape, TrainDefinition, TrainState} from
import {headlightsOn, steaming} from '@roomriddle/scene';
import {theme as ds} from '@roomriddle/ui';
import {PX_SCALE, px, theme} from '../theme/theme';
-import {assetRegistry} from './art';
+import {assetLoader, assetRegistry} from './art';
import {AssetView, Shape} from './AssetView';
import {Glow, Puffs} from './Effects';
@@ -43,6 +43,30 @@ export const TrainView = memo(
const lights = effects.headlights ?? headlightsOn(state);
const steam = (effects.steam ?? false) && steaming(state);
const moving = state === 'approaching' || state === 'arriving' || state === 'departing' || state === 'far';
+ // Painted trains have their own windows and doors: the procedural leaves and
+ // window boxes are skipped, and the open door shows as light spilling out.
+ const painted = assetRegistry.resolve(def.assetId, assetLoader.isLoaded).kind === 'image';
+ if (painted) {
+ const door = doorShapes.find(s => s.role === 'door-left');
+ return (
+
+
+ {door && doorway && (
+
+
+
+ )}
+ {lights && (
+ <>
+
+
+ >
+ )}
+
+
+
+ );
+ }
return (
{/* Doorway light sits behind the door leaves: it shows as they part. */}
diff --git a/apps/tv/src/world/AssetView.tsx b/apps/tv/src/world/AssetView.tsx
index 569edec..77f312a 100644
--- a/apps/tv/src/world/AssetView.tsx
+++ b/apps/tv/src/world/AssetView.tsx
@@ -63,7 +63,19 @@ export const Shape = ({s, style}: {s: PlaceholderShape; style?: ViewStyle}) => {
export const AssetView = memo(({assetId, width, height, skipRoles}: {assetId: string; width: number; height: number; skipRoles?: readonly string[]}) => {
const resolved = assetRegistry.resolve(assetId, assetLoader.isLoaded);
if (resolved.kind === 'image') {
- return ;
+ const image = ;
+ if (resolved.overlay.length === 0) {
+ return image;
+ }
+ // Painted art carries the look; the overlay carries the facts (clock hands at the clue's time).
+ const ox = width / resolved.asset.size.width;
+ const oy = height / resolved.asset.size.height;
+ return (
+
+ {image}
+ {resolved.overlay.map((s, i) => (s.role !== undefined && skipRoles?.includes(s.role) ? null : ))}
+
+ );
}
if (resolved.kind === 'missing') {
// Visible in development, never a crash on the TV.
diff --git a/apps/tv/src/world/Effects.tsx b/apps/tv/src/world/Effects.tsx
index 5e72d1d..0c8aa17 100644
--- a/apps/tv/src/world/Effects.tsx
+++ b/apps/tv/src/world/Effects.tsx
@@ -197,6 +197,9 @@ export const Glow = ({x, y, radius, color = 'lampGlow', strength = 0.16, flicker
/** Full-screen lightning flash. */
export const Lightning = ({on, reduced}: {on: boolean; reduced: boolean}) => {
const [v] = useState(() => new Animated.Value(0));
+ // The flash always runs to the end: timelines switch the effect off ~200 ms
+ // later, and stopping it then left a grey veil over the scene.
+ useEffect(() => () => v.stopAnimation(), [v]);
useEffect(() => {
if (!on) {
return;
@@ -209,7 +212,6 @@ export const Lightning = ({on, reduced}: {on: boolean; reduced: boolean}) => {
Animated.timing(v, {toValue: 0, duration: 420, useNativeDriver: true}),
]);
a.start();
- return () => a.stop();
}, [on, reduced, v]);
return ;
};
diff --git a/apps/tv/src/world/art.ts b/apps/tv/src/world/art.ts
index caf82b8..6fd548b 100644
--- a/apps/tv/src/world/art.ts
+++ b/apps/tv/src/world/art.ts
@@ -3,19 +3,85 @@ import {AssetLoader, AssetRegistry} from '@roomriddle/scene';
import {lastTrainWorld} from '@roomriddle/mystery-data';
/**
- * FINAL ART MAPPING. The only file that changes when production art arrives.
+ * FINAL ART MAPPING: the bundled files behind each manifest entry marked
+ * `status: 'final'` (see FINAL_ART in the case's world/manifest.ts).
*
- * To replace a placeholder:
- * 1. put the file in src/assets/mysteries// (WebP, sized as the
- * manifest entry's `size`, transparent where the placeholder is),
+ * To add or replace art:
+ * 1. run apps/tv/scripts/art/prepare_last_train.py (sizes each
+ * file to its manifest box, writes src/assets/mysteries//),
* 2. add `'': require('../assets/mysteries//')` below,
- * 3. in the manifest entry set status: 'final', format: 'webp', source: ''.
+ * 3. add the id and file to FINAL_ART in the manifest.
* Scenes, camera, director, interactions, engine and server are untouched.
+ * test/Art.spec.ts checks every final asset has a bundled file.
*/
export const bundledArt: Readonly> = {
- // 'last-train.train': require('../assets/mysteries/the-last-train/train.webp'),
+ 'last-train.sky': require('../assets/mysteries/the-last-train/sky.jpg'),
+ 'last-train.city': require('../assets/mysteries/the-last-train/city.png'),
+ 'last-train.station.background': require('../assets/mysteries/the-last-train/station.background.png'),
+ 'last-train.station.platform': require('../assets/mysteries/the-last-train/station.platform.jpg'),
+ 'last-train.tracks': require('../assets/mysteries/the-last-train/tracks.jpg'),
+ 'last-train.station.lights': require('../assets/mysteries/the-last-train/station.lights.png'),
+ 'last-train.foreground.pole': require('../assets/mysteries/the-last-train/foreground.pole.png'),
+ 'last-train.bench': require('../assets/mysteries/the-last-train/bench.png'),
+ 'last-train.train': require('../assets/mysteries/the-last-train/train.png'),
+ 'last-train.train.interior': require('../assets/mysteries/the-last-train/train.interior.jpg'),
+ 'last-train.train.vestibule': require('../assets/mysteries/the-last-train/train.vestibule.jpg'),
+ 'last-train.signal-box.interior': require('../assets/mysteries/the-last-train/signal-box.interior.jpg'),
+ 'last-train.detective': require('../assets/mysteries/the-last-train/detective.png'),
+ 'last-train.clock': require('../assets/mysteries/the-last-train/clock.png'),
+ 'last-train.notice': require('../assets/mysteries/the-last-train/notice.png'),
+ 'last-train.waiting-room-clock': require('../assets/mysteries/the-last-train/waiting-room-clock.png'),
+ 'last-train.suitcase': require('../assets/mysteries/the-last-train/suitcase.png'),
+ 'last-train.newspaper': require('../assets/mysteries/the-last-train/newspaper.png'),
+ 'last-train.ticket': require('../assets/mysteries/the-last-train/ticket.png'),
+ 'last-train.departure-board': require('../assets/mysteries/the-last-train/departure-board.png'),
+ 'last-train.carriage-plan': require('../assets/mysteries/the-last-train/carriage-plan.png'),
+ 'last-train.carriage.props': require('../assets/mysteries/the-last-train/carriage.props.png'),
+ 'last-train.ledger': require('../assets/mysteries/the-last-train/ledger.png'),
+ 'last-train.pocket-watch': require('../assets/mysteries/the-last-train/pocket-watch.png'),
+ 'last-train.seat-card': require('../assets/mysteries/the-last-train/seat-card.png'),
+ 'last-train.brake': require('../assets/mysteries/the-last-train/brake.png'),
+ 'last-train.lever-frame': require('../assets/mysteries/the-last-train/lever-frame.png'),
+ 'last-train.lamp-room-door': require('../assets/mysteries/the-last-train/lamp-room-door.png'),
};
+/** Painted close-ups shown on evidence cards, by clue id. */
+const clueArt: Readonly> = {
+ 'platform-clock': require('../assets/mysteries/the-last-train/closeups/platform-clock.png'),
+ 'company-notice': require('../assets/mysteries/the-last-train/closeups/company-notice.png'),
+ 'waiting-room-clock': require('../assets/mysteries/the-last-train/closeups/waiting-room-clock.png'),
+ 'torn-ticket': require('../assets/mysteries/the-last-train/closeups/torn-ticket.png'),
+ 'departure-board': require('../assets/mysteries/the-last-train/closeups/departure-board.png'),
+ 'carriage-plan': require('../assets/mysteries/the-last-train/closeups/carriage-plan.png'),
+ 'pocket-watch': require('../assets/mysteries/the-last-train/closeups/pocket-watch.png'),
+ 'conductors-ledger': require('../assets/mysteries/the-last-train/closeups/conductors-ledger.png'),
+ 'seat-eight-card': require('../assets/mysteries/the-last-train/closeups/seat-eight-card.png'),
+ 'wired-brake': require('../assets/mysteries/the-last-train/closeups/wired-brake.png'),
+ 'blackwater-lever': require('../assets/mysteries/the-last-train/closeups/blackwater-lever.png'),
+ 'lamp-room-door': require('../assets/mysteries/the-last-train/closeups/lamp-room-door.png'),
+};
+
+export function closeupFor(clueId: string): ImageSourcePropType | undefined {
+ return clueArt[clueId];
+}
+
+/**
+ * Speech-bubble portraits by character id: `calm`, and `pressed` when the
+ * players have just shown them evidence.
+ */
+const portraitArt: Readonly> = {
+ voss: {calm: require('../assets/mysteries/the-last-train/portraits/voss.calm.png'), pressed: require('../assets/mysteries/the-last-train/portraits/voss.pressed.png')},
+ driver: {calm: require('../assets/mysteries/the-last-train/portraits/driver.calm.png'), pressed: require('../assets/mysteries/the-last-train/portraits/driver.pressed.png')},
+ signalman: {calm: require('../assets/mysteries/the-last-train/portraits/signalman.calm.png'), pressed: require('../assets/mysteries/the-last-train/portraits/signalman.pressed.png')},
+ caldwell: {calm: require('../assets/mysteries/the-last-train/portraits/caldwell.calm.png'), pressed: require('../assets/mysteries/the-last-train/portraits/caldwell.pressed.png')},
+ detective: {calm: require('../assets/mysteries/the-last-train/portraits/detective.calm.png'), pressed: require('../assets/mysteries/the-last-train/portraits/detective.pressed.png')},
+};
+
+export function portraitFor(characterId: string, pressed: boolean): ImageSourcePropType | undefined {
+ const p = portraitArt[characterId];
+ return p ? (pressed ? p.pressed : p.calm) : undefined;
+}
+
export const assetRegistry = new AssetRegistry([lastTrainWorld.manifest]);
/** Bundled art is ready immediately; anything remote is prefetched (and falls back if it fails). */
diff --git a/apps/tv/test/Art.spec.ts b/apps/tv/test/Art.spec.ts
new file mode 100644
index 0000000..650fb36
--- /dev/null
+++ b/apps/tv/test/Art.spec.ts
@@ -0,0 +1,45 @@
+/**
+ * Final art wiring: every manifest entry marked final has a bundled file,
+ * and every bundled file belongs to a final entry. A typo here would show a
+ * placeholder (or nothing) on the TV without any error.
+ */
+import {lastTrainWorld} from '@roomriddle/mystery-data';
+import {validateManifest} from '@roomriddle/scene';
+import {getMysteryDefinition} from '@roomriddle/mystery-data';
+import {bundledArt, closeupFor, portraitFor} from '../src/world/art';
+
+describe('final art', () => {
+ const finals = lastTrainWorld.manifest.assets.filter(a => a.status === 'final');
+
+ it('The Last Train has painted art for its sets, objects and detective', () => {
+ expect(finals.length).toBeGreaterThanOrEqual(28);
+ });
+
+ it('every final asset has a bundled file and every bundled file is used', () => {
+ expect(finals.map(a => a.id).sort()).toEqual(Object.keys(bundledArt).sort());
+ });
+
+ it('the manifest stays valid with art and overlays', () => {
+ expect(validateManifest(lastTrainWorld.manifest)).toEqual([]);
+ });
+
+ it('every clue object in the world has a painted close-up, and every character a portrait', () => {
+ const mystery = getMysteryDefinition('the-last-train')!;
+ const clueIds = lastTrainWorld.scenes.flatMap(s => s.sets.flatMap(set => set.objects.map(o => o.clueId))).filter((id): id is string => id !== undefined);
+ expect(clueIds.length).toBe(12);
+ for (const id of clueIds) {
+ expect(closeupFor(id)).toBeDefined();
+ }
+ for (const c of mystery.truth!.characters) {
+ expect(portraitFor(c.id, false)).toBeDefined();
+ expect(portraitFor(c.id, true)).toBeDefined();
+ }
+ });
+
+ it('clue clocks keep procedural hands over the painted dials', () => {
+ for (const id of ['last-train.clock', 'last-train.waiting-room-clock', 'last-train.pocket-watch']) {
+ const roles = (lastTrainWorld.manifest.assets.find(a => a.id === id)?.overlay ?? []).map(s => s.role);
+ expect(roles).toEqual(expect.arrayContaining(['hand-hour', 'hand-minute']));
+ }
+ });
+});
diff --git a/docs/images/hero-tv-phone.jpg b/docs/images/hero-tv-phone.jpg
index 2082a52..7410812 100644
Binary files a/docs/images/hero-tv-phone.jpg and b/docs/images/hero-tv-phone.jpg differ
diff --git a/docs/images/tv-arrival.jpg b/docs/images/tv-arrival.jpg
new file mode 100644
index 0000000..e5dacfe
Binary files /dev/null and b/docs/images/tv-arrival.jpg differ
diff --git a/docs/images/tv-clue.jpg b/docs/images/tv-clue.jpg
new file mode 100644
index 0000000..d3a7551
Binary files /dev/null and b/docs/images/tv-clue.jpg differ
diff --git a/docs/images/tv-corridor.jpg b/docs/images/tv-corridor.jpg
new file mode 100644
index 0000000..f05f0ea
Binary files /dev/null and b/docs/images/tv-corridor.jpg differ
diff --git a/docs/images/tv-home.jpg b/docs/images/tv-home.jpg
index 3dd277b..959de60 100644
Binary files a/docs/images/tv-home.jpg and b/docs/images/tv-home.jpg differ
diff --git a/docs/images/tv-intro.jpg b/docs/images/tv-intro.jpg
deleted file mode 100644
index e5fb0fd..0000000
Binary files a/docs/images/tv-intro.jpg and /dev/null differ
diff --git a/docs/images/tv-library.jpg b/docs/images/tv-library.jpg
index 4e7541a..e95ca9a 100644
Binary files a/docs/images/tv-library.jpg and b/docs/images/tv-library.jpg differ
diff --git a/docs/images/tv-lobby.jpg b/docs/images/tv-lobby.jpg
index e3d1361..8a1e239 100644
Binary files a/docs/images/tv-lobby.jpg and b/docs/images/tv-lobby.jpg differ
diff --git a/docs/images/tv-signal-box.jpg b/docs/images/tv-signal-box.jpg
new file mode 100644
index 0000000..f311fce
Binary files /dev/null and b/docs/images/tv-signal-box.jpg differ
diff --git a/docs/images/tv-station.jpg b/docs/images/tv-station.jpg
index 451f31b..8cabd72 100644
Binary files a/docs/images/tv-station.jpg and b/docs/images/tv-station.jpg differ
diff --git a/docs/images/tv-suspect.jpg b/docs/images/tv-suspect.jpg
index 67e1bd7..81cda2c 100644
Binary files a/docs/images/tv-suspect.jpg and b/docs/images/tv-suspect.jpg differ
diff --git a/docs/images/tv-ticket.jpg b/docs/images/tv-ticket.jpg
new file mode 100644
index 0000000..a524dfe
Binary files /dev/null and b/docs/images/tv-ticket.jpg differ
diff --git a/docs/images/tv-vestibule.jpg b/docs/images/tv-vestibule.jpg
new file mode 100644
index 0000000..c971d35
Binary files /dev/null and b/docs/images/tv-vestibule.jpg differ
diff --git a/docs/images/tv-world.jpg b/docs/images/tv-world.jpg
deleted file mode 100644
index 0677cde..0000000
Binary files a/docs/images/tv-world.jpg and /dev/null differ
diff --git a/docs/testing.md b/docs/testing.md
index 2fe7124..15e8445 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -27,7 +27,7 @@ Test files sit next to the code as `*.test.ts`.
| `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`) | 75 |
+| TV, Jest (`apps/tv/scripts/wsl-check.sh`) | 80 |
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/packages/mystery-data/src/the-last-train/world/index.ts b/packages/mystery-data/src/the-last-train/world/index.ts
index 0a0d86b..8e961e8 100644
--- a/packages/mystery-data/src/the-last-train/world/index.ts
+++ b/packages/mystery-data/src/the-last-train/world/index.ts
@@ -110,9 +110,11 @@ const stationSet: WorldSet = {
TRAIN_NODE,
DOOR_NODE,
{id: 'bench', assetId: 'last-train.bench', x: 700, y: 900, z: 1},
- {id: 'clock', assetId: 'last-train.clock', x: 220, y: 200},
+ // Sits exactly over the clock painted on the tower (station art px 338,229, ⌀145).
+ {id: 'clock', assetId: 'last-train.clock', x: 175, y: 226, width: 246, height: 246},
{id: 'notice', assetId: 'last-train.notice', x: 230, y: 430},
- {id: 'waiting-clock', assetId: 'last-train.waiting-room-clock', x: 890, y: 440},
+ // Hangs over the clock painted in the booking hall's middle window (station art px 887,565).
+ {id: 'waiting-clock', assetId: 'last-train.waiting-room-clock', x: 802, y: 640},
{id: 'suitcase', assetId: 'last-train.suitcase', x: 300, y: 880},
{id: 'newspaper', assetId: 'last-train.newspaper', x: 780, y: 868},
DETECTIVE_ON_PLATFORM,
@@ -185,13 +187,14 @@ const corridorSet: WorldSet = {
id: 'corridor',
label: 'Carriage C, the sleeper corridor',
nodes: [
- {id: 'interior', assetId: 'last-train.train.interior', x: -240, y: 0},
+ {id: 'interior', assetId: 'last-train.train.interior', x: -240, y: -20},
{id: 'seat-seven', assetId: 'last-train.carriage.props', x: 180, y: 620},
{id: 'seat-eight', assetId: 'last-train.carriage.props', x: 1260, y: 620},
{id: 'ledger', assetId: 'last-train.ledger', x: 300, y: 660, z: 2},
{id: 'pocket-watch', assetId: 'last-train.pocket-watch', x: 420, y: 870, z: 2},
{id: 'seat-card', assetId: 'last-train.seat-card', x: 1420, y: 560, z: 2},
- {id: 'brake', assetId: 'last-train.brake', x: 880, y: 380},
+ // The brake painted on the corridor wall (interior art px 922–1062 × 275–612) is the object: an invisible hotspot over it.
+ {id: 'brake', assetId: 'last-train.brake', x: 682, y: 255, depth: 0.35, visible: false},
{id: 'detective-spot', assetId: 'last-train.detective', x: 1640, y: 470, depth: 0.82, z: 4},
],
objects: [
@@ -209,7 +212,7 @@ const signalBoxSet: WorldSet = {
id: 'signal-box',
label: 'The signal box',
nodes: [
- {id: 'interior', assetId: 'last-train.signal-box.interior', x: -190, y: 0},
+ {id: 'interior', assetId: 'last-train.signal-box.interior', x: -190, y: -20},
{id: 'lamp-room-door', assetId: 'last-train.lamp-room-door', x: 1480, y: 380},
{id: 'lever-frame', assetId: 'last-train.lever-frame', x: 300, y: 620},
{id: 'detective-spot', assetId: 'last-train.detective', x: 1180, y: 480, depth: 0.82, z: 4},
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 e3b6b56..67b67c4 100644
--- a/packages/mystery-data/src/the-last-train/world/manifest.ts
+++ b/packages/mystery-data/src/the-last-train/world/manifest.ts
@@ -262,7 +262,8 @@ const TRAIN_INTERIOR = asset({
group: 'train',
layer: 'background',
depth: 0.35,
- size: {width: 2400, height: 1080},
+ // Taller than the screen so the lifted resting camera never shows its bottom edge.
+ size: {width: 2400, height: 1180},
animation: 'none',
description: 'Sleeper carriage corridor: panelled wood, compartment doors, brass fittings, windows onto the storm.',
placeholder: [
@@ -500,10 +501,10 @@ const BRAKE = asset({
group: 'object',
layer: 'interactive',
depth: 0.7,
- size: {width: 100, height: 200},
+ size: {width: 140, height: 337},
animation: 'none',
- description: 'Emergency brake handle in a red box on the corridor wall.',
- placeholder: [r(0, 0, 100, 200, 'danger', {radius: 6}), r(20, 30, 60, 30, 'paper', {text: 'PULL', textColor: 'ink', textSize: 18}), r(40, 80, 20, 90, 'iron'), r(20, 160, 60, 20, 'iron', {radius: 8})],
+ description: 'Emergency brake handle in a red box on the corridor wall. The corridor painting includes it, so in the world this is an invisible hotspot sized to the painted box.',
+ placeholder: [r(0, 0, 140, 337, 'danger', {radius: 6}), r(28, 50, 84, 50, 'paper', {text: 'PULL', textColor: 'ink', textSize: 22}), r(56, 135, 28, 150, 'iron'), r(28, 270, 84, 34, 'iron', {radius: 8})],
});
const SIGNAL_BOX = asset({
@@ -512,7 +513,7 @@ const SIGNAL_BOX = asset({
group: 'environment',
layer: 'background',
depth: 0.35,
- size: {width: 2300, height: 1080},
+ size: {width: 2300, height: 1180},
animation: 'none',
description: 'Inside the signal box: timber walls, a long window over the tracks, the lamp-room door at the back.',
placeholder: [
@@ -557,6 +558,64 @@ const LAMP_ROOM_DOOR = asset({
const effect = (id: string, desc: string, depth: number, layer: AssetDefinition['layer'] = 'effects', animation: AssetDefinition['animation'] = 'particles', note?: string): AssetDefinition =>
asset({id, type: 'effect', group: 'effect', layer, depth, size: {width: 1920, height: 1080}, animation, animationNote: note, description: desc, placeholder: []});
+// ── Final painted art (P8, Day 4) ─────────────────────────────────────
+// Files are made by apps/tv/scripts/art/prepare_last_train.py, sized to each
+// box. The placeholder stays on every entry: it is the fallback if a file
+// can't load, and the shape the camera, focus frame and tests reason about.
+
+/** Hands only (no dial): the painted dial carries the look, these carry the clue's time. */
+const hands = (face: {cx: number; cy: number; d: number}, time: {hours: number; minutes: number; seconds?: number}, secondHand = false): PlaceholderShape[] =>
+ clockFace({x: face.cx - face.d / 2, y: face.cy - face.d / 2, size: face.d, time, rimRatio: 0.02, marks: false, secondHand, hands: 'ink', rim: 'iron'}).filter(
+ s => s.role === 'hand-hour' || s.role === 'hand-minute' || s.role === 'hand-second' || s.role === 'hub',
+ );
+
+const FINAL_ART: Readonly> = {
+ 'last-train.sky': {source: 'sky.jpg'},
+ 'last-train.city': {source: 'city.png'},
+ 'last-train.station.background': {source: 'station.background.png'},
+ 'last-train.station.platform': {source: 'station.platform.jpg'},
+ 'last-train.tracks': {source: 'tracks.jpg'},
+ 'last-train.station.lights': {source: 'station.lights.png'},
+ 'last-train.foreground.pole': {source: 'foreground.pole.png'},
+ 'last-train.bench': {source: 'bench.png'},
+ 'last-train.train': {source: 'train.png'},
+ 'last-train.train.interior': {source: 'train.interior.jpg'},
+ 'last-train.train.vestibule': {source: 'train.vestibule.jpg'},
+ 'last-train.signal-box.interior': {source: 'signal-box.interior.jpg'},
+ 'last-train.detective': {source: 'detective.png'},
+ // Stopped at 11:59: twelve minutes fast, so the true moment is 11:47.
+ 'last-train.clock': {source: 'clock.png', overlay: hands({cx: 101, cy: 101, d: 118}, {hours: 11, minutes: 59})},
+ 'last-train.notice': {source: 'notice.png', overlay: row(5, i => r(34, 30 + i * 13, i === 2 ? 112 : 84 + ((i * 17) % 26), 4, 'ink', {opacity: 0.5}))},
+ // True time, still running: 11:53.
+ 'last-train.waiting-room-clock': {source: 'waiting-room-clock.png', overlay: hands({cx: 45, cy: 45, d: 62}, {hours: 11, minutes: 53, seconds: 20}, true)},
+ 'last-train.suitcase': {source: 'suitcase.png'},
+ 'last-train.newspaper': {source: 'newspaper.png'},
+ 'last-train.ticket': {source: 'ticket.png'},
+ 'last-train.departure-board': {
+ source: 'departure-board.png',
+ overlay: row(4, i => r(40, 92 + i * 24, 340, 18, 'ink', {text: ['107 DEPARTED', '207 HELD 00:00', '307 CANCELLED', '407 MONDAYS'][i], textColor: 'lamp', textSize: 14, opacity: 0.92})),
+ },
+ 'last-train.carriage-plan': {source: 'carriage-plan.png'},
+ 'last-train.carriage.props': {source: 'carriage.props.png'},
+ 'last-train.ledger': {source: 'ledger.png'},
+ // Stopped where it fell: 11:47, the moment itself.
+ 'last-train.pocket-watch': {source: 'pocket-watch.png', overlay: hands({cx: 72.8, cy: 53.3, d: 44}, {hours: 11, minutes: 47})},
+ 'last-train.seat-card': {source: 'seat-card.png'},
+ 'last-train.brake': {source: 'brake.png'},
+ 'last-train.lever-frame': {source: 'lever-frame.png'},
+ 'last-train.lamp-room-door': {source: 'lamp-room-door.png'},
+};
+
+function withFinalArt(a: AssetDefinition): AssetDefinition {
+ const art = FINAL_ART[a.id];
+ if (!art) {
+ return a;
+ }
+ // Painted lamps already glow: the procedural light pool only adds a hint on top.
+ const light = a.light ? {...a.light, strength: (a.light.strength ?? 0.16) * 0.45} : undefined;
+ return {...a, status: 'final', format: art.source.endsWith('.jpg') ? 'jpg' : 'png', source: art.source, ...(art.overlay ? {overlay: art.overlay} : {}), ...(light ? {light} : {})};
+}
+
export const lastTrainManifest: AssetManifest = {
mysteryId: 'the-last-train',
idPrefix: 'last-train.',
@@ -600,7 +659,7 @@ export const lastTrainManifest: AssetManifest = {
effect('last-train.smoke', 'Smoke from the engine chimney.', 0.6, 'effects', 'particles'),
effect('last-train.light', 'Lamp glow pools and flicker; window glow.', 0.75, 'effects', 'triggered'),
effect('last-train.lightning', 'Sky-wide lightning flash.', 0, 'effects', 'triggered'),
- ],
+ ].map(withFinalArt),
};
export const TRAIN_SIZE = {width: TRAIN_W, height: TRAIN_H};
diff --git a/packages/scene/src/assets.ts b/packages/scene/src/assets.ts
index fcb722b..f2fda69 100644
--- a/packages/scene/src/assets.ts
+++ b/packages/scene/src/assets.ts
@@ -12,7 +12,7 @@
import type {AssetDefinition, AssetManifest, LayerId, PlaceholderShape} from '@roomriddle/shared-types';
export type ResolvedAsset =
- | {kind: 'image'; asset: AssetDefinition; uri: string}
+ | {kind: 'image'; asset: AssetDefinition; uri: string; overlay: PlaceholderShape[]}
| {kind: 'placeholder'; asset: AssetDefinition; shapes: PlaceholderShape[]}
| {kind: 'missing'; id: string};
@@ -77,7 +77,7 @@ export class AssetRegistry {
}
const uri = this.uriOf(current);
if (uri !== null && isLoaded(current)) {
- return {kind: 'image', asset, uri};
+ return {kind: 'image', asset, uri, overlay: asset.overlay ?? []};
}
if (asset.fallback === undefined && asset.placeholder) {
return {kind: 'placeholder', asset, shapes: asset.placeholder};
@@ -228,11 +228,16 @@ export function validateManifest(manifest: AssetManifest): string[] {
if (a.status === 'placeholder' && !a.placeholder && a.fallback === undefined) {
errors.push(`${a.id}: a placeholder asset needs placeholder shapes or a fallback`);
}
- for (const s of a.placeholder ?? []) {
- if (s.x < -1 || s.y < -1 || s.x + s.w > a.size.width + 1 || s.y + s.h > a.size.height + 1) {
- errors.push(`${a.id}: placeholder shape${s.role ? ` "${s.role}"` : ''} falls outside the ${a.size.width}×${a.size.height} asset box`);
+ for (const [kind, shapes] of [['placeholder', a.placeholder], ['overlay', a.overlay]] as const) {
+ for (const s of shapes ?? []) {
+ if (s.x < -1 || s.y < -1 || s.x + s.w > a.size.width + 1 || s.y + s.h > a.size.height + 1) {
+ errors.push(`${a.id}: ${kind} shape${s.role ? ` "${s.role}"` : ''} falls outside the ${a.size.width}×${a.size.height} asset box`);
+ }
}
}
+ if (a.overlay && a.status !== 'final') {
+ errors.push(`${a.id}: an overlay only makes sense on final art`);
+ }
}
for (const a of manifest.assets) {
if (a.fallback !== undefined && !ids.has(a.fallback)) {
diff --git a/packages/scene/src/scene.test.ts b/packages/scene/src/scene.test.ts
index 779e2cd..85b82a9 100644
--- a/packages/scene/src/scene.test.ts
+++ b/packages/scene/src/scene.test.ts
@@ -427,8 +427,9 @@ test('the Last Train world is complete and consistent with the mystery', () => {
for (const id of ['last-train.station.background', 'last-train.train', 'last-train.detective', 'last-train.clock', 'last-train.ticket', 'last-train.ledger', 'last-train.rain', 'last-train.steam', 'last-train.fog', 'last-train.smoke', 'last-train.suitcase', 'last-train.newspaper', 'last-train.city', 'last-train.tracks', 'last-train.train.interior']) {
assert.ok(ids.has(id), id);
}
- // Placeholder-first: nothing claims to be final art yet.
- assert.ok(world.manifest.assets.every(a => a.status === 'placeholder'));
+ // Final art keeps its placeholder: the fallback if a file can't load.
+ assert.ok(world.manifest.assets.every(a => a.status === 'placeholder' || (a.source !== undefined && a.placeholder !== undefined)));
+ assert.ok(world.manifest.assets.some(a => a.status === 'final'));
});
test('validateWorld catches broken references', () => {
diff --git a/packages/shared-types/src/world.ts b/packages/shared-types/src/world.ts
index 806eff0..e75c79f 100644
--- a/packages/shared-types/src/world.ts
+++ b/packages/shared-types/src/world.ts
@@ -18,7 +18,7 @@ export type AssetType = 'background' | 'foreground' | 'character' | 'object' | '
export type AssetGroup = 'environment' | 'train' | 'character' | 'object' | 'effect';
/** placeholder = drawn procedurally today; final = production art is bundled for this id. */
export type AssetStatus = 'placeholder' | 'final';
-export type AssetFormat = 'webp' | 'png' | 'procedural';
+export type AssetFormat = 'webp' | 'png' | 'jpg' | 'procedural';
export type AssetAnimation = 'none' | 'loop' | 'states' | 'particles' | 'triggered';
/** Back to front. The UI is a separate layer above all of these. */
@@ -116,6 +116,12 @@ export interface AssetDefinition {
preload?: boolean;
/** Procedural stand-in, drawn when no final art is available. */
placeholder?: PlaceholderShape[];
+ /**
+ * Procedural detail drawn ON TOP of final art, in the asset box: clock hands
+ * that must show a clue's exact time, the text on a board. Painted art
+ * carries the look; the overlay carries the facts.
+ */
+ overlay?: PlaceholderShape[];
/** Light this asset casts (a glow pool drawn above the night overlay), in its own box. */
light?: {x: number; y: number; radius: number; color: 'lampGlow' | 'windowGlow' | 'headlight'; strength?: number; flicker?: boolean};
description: string;