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 @@

A suspect answers on the TV while the player questions him from a phone -
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 screen | The case library | | **Home:** create or join a room with the remote | **Library:** five complete cases | -| Room lobby with QR code | The 2.5D station scene | -| **Lobby:** scan the QR code, see who's in | **The world:** a layered 2.5D scene you explore with the remote | +| Room lobby with QR code | The Northern Star arriving at Harrow Cross | +| **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, explorable with the remote | Evidence card with a painted close-up | +| **Platform two:** focus objects with the remote | **Evidence:** every clue has its painted close-up | +| Detective Grace in the carriage doorway | The torn ticket beside the train | +| **Detective Grace** narrates and reacts to what you find | **On the platform:** a torn ticket in a puddle | +| The sleeper corridor of carriage C | The signal box at two minutes to midnight | +| **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;