diff --git a/apps/server/package.json b/apps/server/package.json index f14cde2..4b65465 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,6 +15,7 @@ "dependencies": { "@roomriddle/client": "*", "@roomriddle/game-engine": "*", + "@roomriddle/scene": "*", "@roomriddle/mystery-data": "*", "@roomriddle/shared-types": "*", "msedge-tts": "^2.0.9", diff --git a/apps/server/scripts/dump-world.ts b/apps/server/scripts/dump-world.ts new file mode 100644 index 0000000..5933c3c --- /dev/null +++ b/apps/server/scripts/dump-world.ts @@ -0,0 +1,22 @@ +/** + * Dumps The Last Train's world (sets, placed nodes, manifest) as JSON for the + * offline preview renderer (apps/tv/scripts/art/preview.py). + * Usage: npx tsx apps/server/scripts/dump-world.ts > world.json + */ +import {lastTrainWorld} from '@roomriddle/mystery-data'; +import {AssetRegistry, placeSet} from '@roomriddle/scene'; + +const registry = new AssetRegistry([lastTrainWorld.manifest]); +const out = { + basePath: lastTrainWorld.manifest.basePath, + train: lastTrainWorld.train, + assets: Object.fromEntries(lastTrainWorld.manifest.assets.map(a => [a.id, {size: a.size, depth: a.depth, layer: a.layer, source: a.status === 'final' ? a.source : null, light: a.light ?? null, overlay: a.overlay ?? []}])), + sets: lastTrainWorld.scenes.flatMap(s => s.sets).map(set => ({ + id: set.id, + camera: set.camera, + train: set.train ?? null, + objects: set.objects, + nodes: [...placeSet(set, registry).values()], + })), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/apps/tv/scripts/art/__pycache__/prepare_last_train.cpython-313.pyc b/apps/tv/scripts/art/__pycache__/prepare_last_train.cpython-313.pyc new file mode 100644 index 0000000..2f15dec Binary files /dev/null and b/apps/tv/scripts/art/__pycache__/prepare_last_train.cpython-313.pyc differ diff --git a/apps/tv/scripts/art/prepare_last_train.py b/apps/tv/scripts/art/prepare_last_train.py index 68c223d..b11dfea 100644 --- a/apps/tv/scripts/art/prepare_last_train.py +++ b/apps/tv/scripts/art/prepare_last_train.py @@ -37,15 +37,20 @@ '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', {}), + 'tracks': ('last-train/last-train.tracks.png', 5600, 260, 'trackbed', {}), '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}), + 'foreground.pole': ('last-train/last-train.foreground.pole.png', 240, 1200, 'contain', {'align': 'bottom'}), '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}), + # True proportions (the painting is 10:1); it sits at the bottom of the 2600x380 box. + 'train': ('last-train/last-train.train.png', 2600, 380, 'width', {}), '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}), + 'detective': ('shared/shared.grace.full.png', 200, 470, 'contain', {'align': 'bottom', 'scale': 2}), + # The rest of the cast, standing in the world (same scale as Grace). + 'voss': ('last-train/characters/last-train.voss.neutral.png', 200, 470, 'contain', {'align': 'bottom', 'scale': 2}), + 'driver': ('last-train/characters/last-train.driver.neutral.png', 200, 470, 'contain', {'align': 'bottom', 'scale': 2}), + 'signalman': ('last-train/characters/last-train.signalman.nervous.png', 200, 470, '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}}), @@ -154,6 +159,37 @@ def save(im, path_stem): return p +# Clue close-ups whose painted dials have no hands: (cx, cy, r) as fractions of +# the image width (measured by eye), and the clue's time. They must match the world. +CLOSEUP_HANDS = { + 'platform-clock': ((0.44, 0.555, 0.235), (11, 59, None)), + 'waiting-room-clock': ((0.505, 0.385, 0.2), (11, 53, 20)), + 'pocket-watch': ((0.433, 0.506, 0.2), (11, 47, None)), +} + + +def draw_hands(im, at, time): + import math + from PIL import ImageDraw + k = 4 # supersample for smooth edges + layer = Image.new('RGBA', (im.width * k, im.height * k), (0, 0, 0, 0)) + d = ImageDraw.Draw(layer) + cx, cy, r = at[0] * im.width * k, at[1] * im.width * k, at[2] * im.width * k + h, m, s = time + def hand(deg, length, width, color, tail=0.12): + a = math.radians(deg) + x2, y2 = cx + length * math.sin(a), cy - length * math.cos(a) + x1, y1 = cx - tail * length * math.sin(a), cy + tail * length * math.cos(a) + d.line((x1, y1, x2, y2), fill=color, width=max(1, int(width))) + ink = (24, 18, 14, 245) + hand(((h % 12) + m / 60) * 30, r * 0.52, r * 0.075, ink) + hand(m * 6, r * 0.8, r * 0.05, ink) + if s is not None: + hand(s * 6, r * 0.86, r * 0.02, (150, 30, 24, 235), tail=0.2) + d.ellipse((cx - r * 0.07, cy - r * 0.07, cx + r * 0.07, cy + r * 0.07), fill=(150, 112, 50, 255)) + im.alpha_composite(layer.resize(im.size, Image.LANCZOS)) + + def extras(src: Path): """Clue close-ups (shown on evidence cards) and speech-bubble portraits.""" closeups = { @@ -167,6 +203,8 @@ def extras(src: Path): 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) + if clue in CLOSEUP_HANDS: + draw_hands(im, *CLOSEUP_HANDS[clue]) im.save(d / f'{clue}.png', optimize=True) print(f'closeup {clue:20s} {im.size}') # Portraits: the head and shoulders of each expression, square. @@ -226,6 +264,21 @@ def main(): out = fade_top(tile(band, W, H), o['fade']) elif mode == 'tile': out = tile(im, W, H) + elif mode == 'trackbed': + # Rails along the bottom, dark ballast above: the ground between + # the station and the platform, so no sky shows under the train. + rails = tile(im, W, 90) + out = Image.new('RGB', (W, H), (14, 13, 13)) + a = np.zeros((H - 90, W, 3), dtype=np.uint8) + g = np.linspace(9, 22, H - 90)[:, None, None] + a[:] = g.astype(np.uint8) + out.paste(Image.fromarray(a), (0, 0)) + out.paste(rails, (0, H - 90)) + elif mode == 'width': + im = trim(im.convert('RGBA')) + nh = round(im.height * W / im.width) + out = Image.new('RGBA', (W, H), (0, 0, 0, 0)) + out.alpha_composite(im.resize((W, nh), Image.LANCZOS), (0, H - nh)) elif mode == 'disc': out = disc(im, W, H, o['at']) else: diff --git a/apps/tv/scripts/art/preview.py b/apps/tv/scripts/art/preview.py new file mode 100644 index 0000000..dcb28d2 --- /dev/null +++ b/apps/tv/scripts/art/preview.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +Offline preview of a world set, drawn like the TV's Stage: depth planes +(translate = -camera x depth, zoom about the screen centre), the same +back-to-front order, the train at its state's position and light pools. +Iterating placement here takes seconds instead of a device rebuild; the +device remains the final check. + +Usage: + npx tsx apps/server/scripts/dump-world.ts > world.json + python preview.py world.json [--cam x,y,zoom] [--boxes] [--out dir] +""" +import json +import sys +from pathlib import Path + +from PIL import Image, ImageDraw + +REPO = Path(__file__).resolve().parents[4] +ART = REPO / 'apps/tv/src/assets/mysteries/the-last-train' +LAYERS = ['far', 'background', 'midground', 'interactive', 'character', 'foreground', 'effects'] +W, H = 1920, 1080 + + +def load(asset): + if not asset['source']: + return None + return Image.open(ART / asset['source']).convert('RGBA') + + +def render(world, set_, cam, boxes=False, train_state=None): + out = Image.new('RGBA', (W, H), (8, 10, 14, 255)) + assets = world['assets'] + nodes = [n for n in set_['nodes'] if n['assetId'] != world['train']['assetId']] + nodes.sort(key=lambda n: (LAYERS.index(n['layer']), n['z'])) + train = world['train'] + tstate = train_state or (set_['train'] or {}).get('state') + drawn_train = False + + def to_screen(x, y, depth): + z = 1 + (cam[2] - 1) * depth + sx = (x - cam[0] * depth - W / 2) * z + W / 2 + sy = (y - cam[1] * depth - H / 2) * z + H / 2 + return sx, sy, z + + def paste(asset_id, x, y, w, h, depth, opacity=1.0): + a = assets[asset_id] + im = load(a) + sx, sy, z = to_screen(x, y, depth) + if im is None: + d = ImageDraw.Draw(out) + d.rectangle((sx, sy, sx + w * z, sy + h * z), outline=(255, 0, 255), width=2) + return + im = im.resize((max(1, round(w * z)), max(1, round(h * z)))) + if opacity < 1: + im.putalpha(im.getchannel('A').point(lambda v: int(v * opacity))) + out.alpha_composite(im, (round(sx), round(sy))) if sx > -10000 else None + ov = a.get('overlay') or [] + if ov: + d = ImageDraw.Draw(out) + k = w / a['size']['width'] * z + for s in ov: + if s.get('role', '').startswith('hand'): + import math + px_, py_ = s['pivot']['x'], s['pivot']['y'] + ang = math.radians(s.get('rotate', 0)) + ln = s['h'] - (s['h'] - (py_ - s['y'])) + ex = px_ + (py_ - s['y']) * math.sin(ang) + ey = py_ - (py_ - s['y']) * math.cos(ang) + d.line((sx + px_ * k, sy + py_ * k, sx + ex * k, sy + ey * k), fill=(15, 12, 10), width=max(1, round(s['w'] * k))) + + def draw_train(): + if not set_['train'] or not tstate: + return + a = assets[train['assetId']] + x = train['positions'][tstate] + paste(train['assetId'], x, set_['train']['y'], a['size']['width'], a['size']['height'], train['depth']) + + for n in nodes: + if not drawn_train and set_['train'] and (LAYERS.index(n['layer']) > LAYERS.index('midground') or (n['layer'] == 'midground' and n['z'] > 0)): + draw_train() + drawn_train = True + if not n['visible']: + if boxes: + sx, sy, z = to_screen(n['x'], n['y'], n['depth']) + ImageDraw.Draw(out).rectangle((sx, sy, sx + n['width'] * z, sy + n['height'] * z), outline=(0, 255, 255), width=2) + continue + paste(n['assetId'], n['x'], n['y'], n['width'], n['height'], n['depth'], n['opacity']) + if not drawn_train: + draw_train() + # Lights + glow = Image.new('RGBA', (W, H), (0, 0, 0, 0)) + gd = ImageDraw.Draw(glow) + for n in nodes: + light = assets[n['assetId']].get('light') + if not light: + continue + kx = n['width'] / assets[n['assetId']]['size']['width'] + sx, sy, z = to_screen(n['x'] + light['x'] * kx, n['y'] + light['y'] * kx, n['depth']) + r = light['radius'] * z + gd.ellipse((sx - r, sy - r, sx + r, sy + r), outline=(255, 200, 80, 255), width=3) + gd.line((sx - 8, sy, sx + 8, sy), fill=(255, 0, 0, 255), width=3) + gd.line((sx, sy - 8, sx, sy + 8), fill=(255, 0, 0, 255), width=3) + out.alpha_composite(glow) + if boxes: + d = ImageDraw.Draw(out) + ids = {o['nodeId']: o['name'] for o in set_['objects']} + for n in nodes: + if n['id'] in ids: + sx, sy, z = to_screen(n['x'], n['y'], n['depth']) + d.rectangle((sx, sy, sx + n['width'] * z, sy + n['height'] * z), outline=(0, 255, 0), width=2) + d.text((sx + 3, sy + 3), ids[n['id']], fill=(0, 255, 0)) + return out.convert('RGB') + + +def main(): + world = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8-sig')) + which = sys.argv[2] + boxes = '--boxes' in sys.argv + outdir = Path(sys.argv[sys.argv.index('--out') + 1]) if '--out' in sys.argv else Path('.') + outdir.mkdir(parents=True, exist_ok=True) + for s in world['sets']: + if which not in ('all', s['id']): + continue + cam = [s['camera']['x'], s['camera']['y'], s['camera']['zoom']] + if '--cam' in sys.argv: + cam = [float(v) for v in sys.argv[sys.argv.index('--cam') + 1].split(',')] + img = render(world, s, cam, boxes) + p = outdir / f'preview-{s["id"]}.jpg' + img.save(p, quality=85) + print(p) + + +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 index 6792b3b..9f636d8 100644 --- a/apps/tv/src/assets/mysteries/the-last-train/art-report.json +++ b/apps/tv/src/assets/mysteries/the-last-train/art-report.json @@ -74,9 +74,9 @@ "file": "tracks.jpg", "px": [ 5600, - 90 + 260 ], - "kb": 178 + "kb": 203 }, "station.lights": { "file": "station.lights.png", @@ -89,10 +89,10 @@ "foreground.pole": { "file": "foreground.pole.png", "px": [ - 110, + 240, 1200 ], - "kb": 102 + "kb": 202 }, "bench": { "file": "bench.png", @@ -108,7 +108,7 @@ 2600, 380 ], - "kb": 1470 + "kb": 1074 }, "train.interior": { "file": "train.interior.jpg", @@ -137,10 +137,10 @@ "detective": { "file": "detective.png", "px": [ - 480, - 1120 + 400, + 940 ], - "kb": 632 + "kb": 452 }, "notice": { "file": "notice.png", @@ -237,5 +237,29 @@ 440 ], "kb": 192 + }, + "voss": { + "file": "voss.png", + "px": [ + 400, + 940 + ], + "kb": 441 + }, + "driver": { + "file": "driver.png", + "px": [ + 400, + 940 + ], + "kb": 531 + }, + "signalman": { + "file": "signalman.png", + "px": [ + 400, + 940 + ], + "kb": 454 } } 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 index 6e1f2cc..c23d241 100644 Binary files a/apps/tv/src/assets/mysteries/the-last-train/closeups/platform-clock.png 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 index 97c9b92..2df658f 100644 Binary files a/apps/tv/src/assets/mysteries/the-last-train/closeups/pocket-watch.png 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/waiting-room-clock.png b/apps/tv/src/assets/mysteries/the-last-train/closeups/waiting-room-clock.png index 2aaee6a..1186fd2 100644 Binary files a/apps/tv/src/assets/mysteries/the-last-train/closeups/waiting-room-clock.png 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/detective.png b/apps/tv/src/assets/mysteries/the-last-train/detective.png index 626416c..6013d19 100644 Binary files a/apps/tv/src/assets/mysteries/the-last-train/detective.png and b/apps/tv/src/assets/mysteries/the-last-train/detective.png differ diff --git a/apps/tv/src/assets/mysteries/the-last-train/driver.png b/apps/tv/src/assets/mysteries/the-last-train/driver.png new file mode 100644 index 0000000..b739f7f Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/driver.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 index 15a7175..e8e22aa 100644 Binary files a/apps/tv/src/assets/mysteries/the-last-train/foreground.pole.png 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/signalman.png b/apps/tv/src/assets/mysteries/the-last-train/signalman.png new file mode 100644 index 0000000..543913e Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/signalman.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 index 90a8e88..48d8221 100644 Binary files a/apps/tv/src/assets/mysteries/the-last-train/tracks.jpg 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.png b/apps/tv/src/assets/mysteries/the-last-train/train.png index e9769b7..c46fcfb 100644 Binary files a/apps/tv/src/assets/mysteries/the-last-train/train.png and b/apps/tv/src/assets/mysteries/the-last-train/train.png differ diff --git a/apps/tv/src/assets/mysteries/the-last-train/voss.png b/apps/tv/src/assets/mysteries/the-last-train/voss.png new file mode 100644 index 0000000..638f801 Binary files /dev/null and b/apps/tv/src/assets/mysteries/the-last-train/voss.png differ diff --git a/apps/tv/src/game/CharacterSpeech.tsx b/apps/tv/src/game/CharacterSpeech.tsx index 8766bc4..0d3e43b 100644 --- a/apps/tv/src/game/CharacterSpeech.tsx +++ b/apps/tv/src/game/CharacterSpeech.tsx @@ -11,7 +11,8 @@ import {useGame} from './GameContext'; export const VOICE_WAIT_MS = 3500; /** On screen for about as long as it takes to say, within limits. */ export function holdMsFor(text: string): number { - return Math.min(16_000, Math.max(5000, 2200 + text.length * 70)); + // Long enough to read from the couch while the voice plays (≈ 14 characters a second, plus a beat). + return Math.min(18_000, Math.max(7000, 3000 + text.length * 75)); } export function initials(name: string): string { @@ -173,8 +174,8 @@ const styles = StyleSheet.create({ position: 'absolute', left: theme.spacing.screenX, right: theme.spacing.screenX, - // Above the footer line most screens keep at the bottom edge. - bottom: theme.spacing.screenY + px(84), + // Clear of the world's action bar (top edge ~850) and every screen's footer row. + bottom: px(250), // Right side: screens keep their primary buttons bottom-left, so the bubble never covers focus. alignItems: 'flex-end', }, diff --git a/apps/tv/src/world/Actors.tsx b/apps/tv/src/world/Actors.tsx index e0900e8..28f8b9a 100644 --- a/apps/tv/src/world/Actors.tsx +++ b/apps/tv/src/world/Actors.tsx @@ -44,18 +44,12 @@ export const TrainView = memo( 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. + // window boxes are skipped (its doors are painted). 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 && ( <> diff --git a/apps/tv/src/world/Effects.tsx b/apps/tv/src/world/Effects.tsx index 0c8aa17..aa56009 100644 --- a/apps/tv/src/world/Effects.tsx +++ b/apps/tv/src/world/Effects.tsx @@ -176,7 +176,8 @@ export const Glow = ({x, y, radius, color = 'lampGlow', strength = 0.16, flicker }, [flicker, reduced, seed, v]); return ( - {[1, 0.66, 0.36].map((k, i) => ( + {/* Five soft steps read as a falloff, not as rings. */} + {[1, 0.8, 0.6, 0.42, 0.26].map((k, i) => ( ))} diff --git a/apps/tv/src/world/art.ts b/apps/tv/src/world/art.ts index 6fd548b..f3f1187 100644 --- a/apps/tv/src/world/art.ts +++ b/apps/tv/src/world/art.ts @@ -28,6 +28,9 @@ export const bundledArt: Readonly> = { '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.voss': require('../assets/mysteries/the-last-train/voss.png'), + 'last-train.driver': require('../assets/mysteries/the-last-train/driver.png'), + 'last-train.signalman': require('../assets/mysteries/the-last-train/signalman.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'), diff --git a/apps/tv/test/CharacterSpeech.spec.tsx b/apps/tv/test/CharacterSpeech.spec.tsx index 61b3435..42cc8da 100644 --- a/apps/tv/test/CharacterSpeech.spec.tsx +++ b/apps/tv/test/CharacterSpeech.spec.tsx @@ -33,8 +33,8 @@ afterEach(() => jest.useRealTimers()); test('helpers: initials drop titles, hold time scales with the line, http base from the socket URL', () => { expect(initials('Inspector R. Voss')).toBe('RV'); expect(initials('The Yaksha')).toBe('Y'); - expect(holdMsFor('short')).toBe(5000); - expect(holdMsFor('x'.repeat(1000))).toBe(16000); + expect(holdMsFor('short')).toBe(7000); + expect(holdMsFor('x'.repeat(1000))).toBe(18000); expect(httpBase('ws://10.0.2.2:4000/ws')).toBe('http://10.0.2.2:4000'); expect(httpBase('wss://rr.example/ws')).toBe('https://rr.example'); }); diff --git a/package-lock.json b/package-lock.json index c01f1af..b64bf2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "@roomriddle/client": "*", "@roomriddle/game-engine": "*", "@roomriddle/mystery-data": "*", + "@roomriddle/scene": "*", "@roomriddle/shared-types": "*", "msedge-tts": "^2.0.9", "qrcode-generator": "^2.0.4", 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 8e961e8..8db5fbb 100644 --- a/packages/mystery-data/src/the-last-train/world/index.ts +++ b/packages/mystery-data/src/the-last-train/world/index.ts @@ -38,17 +38,23 @@ const TRAIN_NODE: WorldNode = {id: 'train', assetId: 'last-train.train', x: TRAI const STATION_BACKDROP: WorldNode[] = [ {id: 'sky', assetId: 'last-train.sky', x: -340, y: -60}, {id: 'city', assetId: 'last-train.city', x: -740, y: 430}, - {id: 'station', assetId: 'last-train.station.background', x: -40, y: 120}, - {id: 'tracks', assetId: 'last-train.tracks', x: -1840, y: 960, z: -2}, + // Raised so the booking-hall windows (and the clock in one) show above the train's roof. + {id: 'station', assetId: 'last-train.station.background', x: -40, y: 40}, + // Ballast + rails: the ground under the train, so no sky shows below the platform in any shot. + {id: 'tracks', assetId: 'last-train.tracks', x: -1840, y: 790, z: -2}, {id: 'platform', assetId: 'last-train.station.platform', x: -1840, y: 990, z: 1}, - {id: 'lamp-1', assetId: 'last-train.station.lights', x: 180, y: 440, z: 2}, - {id: 'lamp-2', assetId: 'last-train.station.lights', x: 1320, y: 440, z: 2}, - {id: 'lamp-3', assetId: 'last-train.station.lights', x: 2420, y: 440, z: 2}, + // The camera follows the arriving train far to the left: the ground continues there too. + {id: 'tracks-west', assetId: 'last-train.tracks', x: -7440, y: 790, z: -2}, + {id: 'platform-west', assetId: 'last-train.station.platform', x: -7440, y: 990, z: 1}, + {id: 'lamp-1', assetId: 'last-train.station.lights', x: 600, y: 440, z: 2}, + {id: 'lamp-2', assetId: 'last-train.station.lights', x: 1500, y: 440, z: 2}, + {id: 'lamp-3', assetId: 'last-train.station.lights', x: 2480, y: 440, z: 2}, {id: 'pole-1', assetId: 'last-train.foreground.pole', x: -60, y: -40}, {id: 'pole-2', assetId: 'last-train.foreground.pole', x: 1980, y: -40}, ]; -const DETECTIVE_ON_PLATFORM: WorldNode = {id: 'detective-spot', assetId: 'last-train.detective', x: 1560, y: 560, depth: 0.82, z: 4}; +// Feet on the platform (its edge is at y 990 in the 0.75 plane). +const DETECTIVE_ON_PLATFORM: WorldNode = {id: 'detective-spot', assetId: 'last-train.detective', x: 1580, y: 625, width: 166, height: 390, depth: 0.82, z: 4}; /** * Where the camera rests when players have control: lifted a little, so @@ -111,16 +117,21 @@ const stationSet: WorldSet = { DOOR_NODE, {id: 'bench', assetId: 'last-train.bench', x: 700, y: 900, z: 1}, // 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}, - // 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: 'clock', assetId: 'last-train.clock', x: 175, y: 146, width: 246, height: 246}, + // Pinned on the tower, under the clock. + {id: 'notice', assetId: 'last-train.notice', x: 208, y: 405}, + // Over the clock painted in the booking hall's middle window (station art px 887,565). + // Background layer: the train passes in front of it, never behind it. + {id: 'waiting-clock', assetId: 'last-train.waiting-room-clock', x: 802, y: 560, layer: 'background', z: 5}, {id: 'suitcase', assetId: 'last-train.suitcase', x: 300, y: 880}, {id: 'newspaper', assetId: 'last-train.newspaper', x: 780, y: 868}, + // Ned Hollis waits by the carriages, cap in hand. + {id: 'driver', assetId: 'last-train.driver', x: 1150, y: 625, width: 166, height: 390, z: 3}, DETECTIVE_ON_PLATFORM, ], objects: [ - {id: 'obj-clock', nodeId: 'clock', name: 'Platform clock', interaction: 'inspect', clueId: 'platform-clock', inspectZoom: 1.9}, + // Focus hugs the dial (centre 298,269, ⌀145), not the tower stonework around it. + {id: 'obj-clock', nodeId: 'clock', name: 'Platform clock', interaction: 'inspect', clueId: 'platform-clock', inspectZoom: 1.9, bounds: {x: 222, y: 193, width: 152, height: 152}}, {id: 'obj-notice', nodeId: 'notice', name: 'Company notice', interaction: 'read', clueId: 'company-notice', inspectZoom: 2.1}, {id: 'obj-waiting-clock', nodeId: 'waiting-clock', name: 'Waiting room clock', interaction: 'inspect', clueId: 'waiting-room-clock', inspectZoom: 2.2}, { @@ -153,7 +164,8 @@ const vestibuleSet: WorldSet = { label: 'Carriage C, inside the door', nodes: [ {id: 'vestibule', assetId: 'last-train.train.vestibule', x: -140, y: 0}, - {id: 'detective-spot', assetId: 'last-train.detective', x: 1180, y: 470, depth: 0.8, z: 2}, + // Inside, the camera is close: the cast stands larger, feet on the vestibule floor. + {id: 'detective-spot', assetId: 'last-train.detective', x: 1150, y: 330, width: 290, height: 680, depth: 0.8, z: 2}, ], objects: [], camera: {x: 0, y: 0, zoom: 1}, @@ -167,9 +179,12 @@ const trainSet: WorldSet = { nodes: [ ...STATION_BACKDROP, TRAIN_NODE, - {id: 'departure-board', assetId: 'last-train.departure-board', x: 1320, y: 260}, - {id: 'carriage-plan', assetId: 'last-train.carriage-plan', x: TRAIN_STOP_X + BOARDING_DOOR.x + 140, y: TRAIN_Y + 130, depth: TRAIN_DEPTH, z: 2}, + // Hangs from the canopy on the platform side, in front of the booking hall. + {id: 'departure-board', assetId: 'last-train.departure-board', x: 800, y: 430, depth: 0.75, z: 2}, + // On the carriage side, below the windows (the painted train fills the bottom 253 px of its box). + {id: 'carriage-plan', assetId: 'last-train.carriage-plan', x: TRAIN_STOP_X + BOARDING_DOOR.x + 140, y: TRAIN_Y + 175, width: 96, height: 120, depth: TRAIN_DEPTH, z: 2}, {id: 'ticket', assetId: 'last-train.ticket', x: 1180, y: 940}, + {id: 'driver', assetId: 'last-train.driver', x: 300, y: 625, width: 166, height: 390, z: 3}, DETECTIVE_ON_PLATFORM, ], objects: [ @@ -186,19 +201,23 @@ const trainSet: WorldSet = { const corridorSet: WorldSet = { id: 'corridor', label: 'Carriage C, the sleeper corridor', + // The painting is the set: objects are focus spots over what is painted (art px = node + 380, 20). nodes: [ - {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}, - // 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}, + {id: 'interior', assetId: 'last-train.train.interior', x: -380, y: -20}, + // Left on the brass rail ledge under the window. + {id: 'ledger', assetId: 'last-train.ledger', x: 220, y: 740, width: 300, height: 100, depth: 0.35, visible: false}, + // A brass glint on the corridor runner, by compartment seven. + {id: 'pocket-watch', assetId: 'last-train.pocket-watch', x: 1110, y: 790, width: 120, height: 80, depth: 0.35, visible: false}, + // The brass plate on compartment eight's door. + {id: 'seat-card', assetId: 'last-train.seat-card', x: 1700, y: 185, width: 130, height: 100, depth: 0.35, visible: false}, + // The emergency brake painted on the wall (art px 930–1060 × 280–610). + {id: 'brake', assetId: 'last-train.brake', x: 550, y: 260, width: 130, height: 330, depth: 0.35, visible: false}, + // Voss waits down the corridor, feet on the runner, in the painting's perspective. + {id: 'voss', assetId: 'last-train.voss', x: 1290, y: 500, width: 150, height: 360, depth: 0.35, z: 3}, + {id: 'detective-spot', assetId: 'last-train.detective', x: 1000, y: 490, width: 260, height: 640, depth: 0.35, z: 4}, ], objects: [ - {id: 'obj-watch', nodeId: 'pocket-watch', name: 'Beneath seat seven', interaction: 'inspect', clueId: 'pocket-watch', inspectZoom: 2}, + {id: 'obj-watch', nodeId: 'pocket-watch', name: 'By compartment seven', interaction: 'inspect', clueId: 'pocket-watch', inspectZoom: 2}, {id: 'obj-ledger', nodeId: 'ledger', name: "Conductor's ledger", interaction: 'open', clueId: 'conductors-ledger', inspectZoom: 2}, {id: 'obj-seat-card', nodeId: 'seat-card', name: 'Seat eight', interaction: 'read', clueId: 'seat-eight-card', inspectZoom: 2.2}, {id: 'obj-brake', nodeId: 'brake', name: 'Emergency brake', interaction: 'activate', clueId: 'wired-brake', inspectZoom: 1.8}, @@ -211,11 +230,14 @@ const corridorSet: WorldSet = { const signalBoxSet: WorldSet = { id: 'signal-box', label: 'The signal box', + // The lever frame and the lamp-room door are painted: focus spots sit over them (art px = node + 380, 20). nodes: [ - {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}, + {id: 'interior', assetId: 'last-train.signal-box.interior', x: -380, y: -20}, + {id: 'lever-frame', assetId: 'last-train.lever-frame', x: 90, y: 560, width: 1230, height: 360, depth: 0.35, visible: false}, + {id: 'lamp-room-door', assetId: 'last-train.lamp-room-door', x: 1700, y: 240, width: 220, height: 600, depth: 0.35, visible: false}, + // Tom Ashby by the lamp stand, feet on the boards. + {id: 'signalman', assetId: 'last-train.signalman', x: 1450, y: 440, width: 210, height: 520, depth: 0.35, z: 3}, + {id: 'detective-spot', assetId: 'last-train.detective', x: 1060, y: 420, width: 260, height: 680, depth: 0.35, z: 4}, ], objects: [ {id: 'obj-levers', nodeId: 'lever-frame', name: 'Lever frame', interaction: 'inspect', clueId: 'blackwater-lever', inspectZoom: 1.5}, 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 67b67c4..09b2f6a 100644 --- a/packages/mystery-data/src/the-last-train/world/manifest.ts +++ b/packages/mystery-data/src/the-last-train/world/manifest.ts @@ -111,14 +111,16 @@ const TRACKS = asset({ group: 'environment', layer: 'midground', depth: 0.6, - size: {width: 5600, height: 90}, + // Rails at the bottom, ballast above: also the ground between station and platform. + size: {width: 5600, height: 260}, animation: 'none', - description: 'Rails and sleepers running the length of the platform.', + description: 'Rails and sleepers running the length of the platform, on dark ballast.', placeholder: [ - r(0, 30, 5600, 60, 'iron'), - ...row(56, i => r(i * 100, 38, 60, 44, 'wood', {opacity: 0.7})), - r(0, 34, 5600, 8, 'rail'), - r(0, 70, 5600, 8, 'rail'), + r(0, 0, 5600, 260, 'coatDark'), + r(0, 200, 5600, 60, 'iron'), + ...row(56, i => r(i * 100, 208, 60, 44, 'wood', {opacity: 0.7})), + r(0, 204, 5600, 8, 'rail'), + r(0, 240, 5600, 8, 'rail'), ], }); @@ -132,7 +134,8 @@ const LAMP = asset({ animation: 'triggered', animationNote: 'Lamp head glow flickers (effect: flicker). Final art: lamp without glow; glow stays procedural.', description: 'Cast-iron platform gas lamp.', - light: {x: 45, y: 50, radius: 170, color: 'lampGlow', strength: 0.14, flicker: true}, + // Centred on the painted lamp head (measured: 45,97). + light: {x: 45, y: 97, radius: 120, color: 'lampGlow', strength: 0.14, flicker: true}, placeholder: [r(38, 60, 14, 500, 'iron'), r(20, 20, 50, 60, 'ironLight', {radius: 8}), r(28, 30, 34, 40, 'lamp', {radius: 6, role: 'lamp-head'})], }); @@ -142,10 +145,10 @@ const POLE = asset({ group: 'environment', layer: 'foreground', depth: 1, - size: {width: 110, height: 1200}, + size: {width: 240, height: 1200}, animation: 'none', description: 'Canopy column close to the camera, almost black.', - placeholder: [r(20, 0, 70, 1200, 'iron'), r(0, 0, 110, 60, 'iron')], + placeholder: [r(85, 0, 70, 1200, 'iron'), r(60, 0, 120, 60, 'iron')], }); const BENCH = asset({ @@ -310,20 +313,32 @@ const DETECTIVE = asset({ group: 'character', layer: 'character', depth: 0.8, - size: {width: 240, height: 560}, + // Scaled to the train: a carriage side is ~250 px tall in its plane. + size: {width: 200, height: 470}, animation: 'states', animationNote: 'Layers: shadow, body, coat, face, dialogue pose. States: enter, idle (breathing), talk, react, exit. Final: layered PNG/WebP parts or a short sprite per state.', description: 'The detective: long coat, hat brim low, seen from the side-back. Placeholder silhouette.', - placeholder: [ - r(30, 530, 180, 30, 'shadow', {radius: 15, opacity: 0.5, role: 'shadow'}), - r(70, 250, 100, 280, 'coatDark', {radius: 12, role: 'legs'}), - r(50, 120, 140, 300, 'coat', {radius: 30, role: 'body'}), - r(96, 70, 56, 70, 'skin', {radius: 26, role: 'face'}), - r(64, 52, 120, 26, 'hat', {radius: 12, role: 'hat'}), - r(88, 20, 72, 44, 'hat', {radius: 14, role: 'hat'}), - r(50, 150, 140, 16, 'coatDark', {role: 'collar'}), - ], -}); + placeholder: figure(), +}); + +/** A standing silhouette in a 200×470 box (fallback for any cast member). */ +function figure(coat: WorldColor = 'coat'): PlaceholderShape[] { + return [ + r(25, 445, 150, 25, 'shadow', {radius: 12, opacity: 0.5, role: 'shadow'}), + r(58, 210, 84, 235, 'coatDark', {radius: 10, role: 'legs'}), + r(42, 100, 116, 250, coat, {radius: 26, role: 'body'}), + r(80, 58, 46, 58, 'skin', {radius: 22, role: 'face'}), + r(53, 44, 100, 22, 'hat', {radius: 10, role: 'hat'}), + r(73, 16, 60, 36, 'hat', {radius: 12, role: 'hat'}), + ]; +} + +const castMember = (id: string, description: string): AssetDefinition => + asset({id, type: 'character', group: 'character', layer: 'interactive', depth: 0.82, size: {width: 200, height: 470}, animation: 'none', description, placeholder: figure('coatDark')}); + +const VOSS = castMember('last-train.voss', 'Inspector R. Voss, Northern Railway Company: bowler hat, dark overcoat, standing.'); +const DRIVER = castMember('last-train.driver', 'Ned Hollis, engine driver: cap, soot-dark overalls, standing.'); +const SIGNALMAN = castMember('last-train.signalman', 'Tom Ashby, night signalman: waistcoat, lamp in hand, standing.'); // ── Objects (interactive) ────────────────────────────────────────────── @@ -583,6 +598,9 @@ const FINAL_ART: Readonly r(34, 30 + i * 13, i === 2 ? 112 : 84 + ((i * 17) % 26), 4, 'ink', {opacity: 0.5}))}, @@ -637,6 +655,9 @@ export const lastTrainManifest: AssetManifest = { TRAIN_INTERIOR, VESTIBULE, DETECTIVE, + VOSS, + DRIVER, + SIGNALMAN, CLOCK, NOTICE, WAITING_CLOCK,