Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 20 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

<p align="center">
<img src="docs/images/hero-tv-phone.jpg" alt="A suspect answers on the TV while the player questions him from a phone" width="100%" />
<br/><sub>Asking Inspector Voss a question from a phone. He answers on the TV, in character and in his own voice.</sub>
<br/><sub>Asking Inspector Voss a question from a phone. He answers on the TV, in character, in his own voice, with his painted portrait.</sub>
</p>

1. **Open a room on the TV.** It shows a 4-digit code and a QR code.
Expand All @@ -48,8 +48,21 @@
|:---:|:---:|
| <img src="docs/images/tv-home.jpg" alt="Home screen" /> | <img src="docs/images/tv-library.jpg" alt="The case library" /> |
| **Home:** create or join a room with the remote | **Library:** five complete cases |
| <img src="docs/images/tv-lobby.jpg" alt="Room lobby with QR code" /> | <img src="docs/images/tv-station.jpg" alt="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 |
| <img src="docs/images/tv-lobby.jpg" alt="Room lobby with QR code" /> | <img src="docs/images/tv-arrival.jpg" alt="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.

| | |
|:---:|:---:|
| <img src="docs/images/tv-station.jpg" alt="Platform two, explorable with the remote" /> | <img src="docs/images/tv-clue.jpg" alt="Evidence card with a painted close-up" /> |
| **Platform two:** focus objects with the remote | **Evidence:** every clue has its painted close-up |
| <img src="docs/images/tv-vestibule.jpg" alt="Detective Grace in the carriage doorway" /> | <img src="docs/images/tv-ticket.jpg" alt="The torn ticket beside the train" /> |
| **Detective Grace** narrates and reacts to what you find | **On the platform:** a torn ticket in a puddle |
| <img src="docs/images/tv-corridor.jpg" alt="The sleeper corridor of carriage C" /> | <img src="docs/images/tv-signal-box.jpg" alt="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

Expand All @@ -58,8 +71,6 @@
<br/><sub>Join in seconds · examine evidence · question suspects · solve puzzles with three-step hints</sub>
</p>

> **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
Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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.
Expand All @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion apps/tv/jest.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"^~/(.*)$": "<rootDir>/src/$1",
"^@amazon-devices/react-native-async-storage__async-storage$": "<rootDir>/node_modules/@amazon-devices/react-native-async-storage__async-storage/jest/async-storage-mock",
"^@amazon-devices/react-native-w3cmedia$": "<rootDir>/test/support/w3cmedia.ts",
"\\.(mp3|m4a|wav)$": "<rootDir>/test/support/fileStub.js"
"\\.(mp3|m4a|wav|png|jpg)$": "<rootDir>/test/support/fileStub.js"
},
"modulePaths": [
"<rootDir>/src"
Expand Down
247 changes: 247 additions & 0 deletions apps/tv/scripts/art/prepare_last_train.py
Original file line number Diff line number Diff line change
@@ -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 <SRC art-inbox dir> [--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()
Loading
Loading