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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dependencies": {
"@roomriddle/client": "*",
"@roomriddle/game-engine": "*",
"@roomriddle/scene": "*",
"@roomriddle/mystery-data": "*",
"@roomriddle/shared-types": "*",
"msedge-tts": "^2.0.9",
Expand Down
22 changes: 22 additions & 0 deletions apps/server/scripts/dump-world.ts
Original file line number Diff line number Diff line change
@@ -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));
Binary file not shown.
61 changes: 57 additions & 4 deletions apps/tv/scripts/art/prepare_last_train.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}}),
Expand Down Expand Up @@ -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 = {
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
135 changes: 135 additions & 0 deletions apps/tv/scripts/art/preview.py
Original file line number Diff line number Diff line change
@@ -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 <set-id|all> [--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()
40 changes: 32 additions & 8 deletions apps/tv/src/assets/mysteries/the-last-train/art-report.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@
"file": "tracks.jpg",
"px": [
5600,
90
260
],
"kb": 178
"kb": 203
},
"station.lights": {
"file": "station.lights.png",
Expand All @@ -89,10 +89,10 @@
"foreground.pole": {
"file": "foreground.pole.png",
"px": [
110,
240,
1200
],
"kb": 102
"kb": 202
},
"bench": {
"file": "bench.png",
Expand All @@ -108,7 +108,7 @@
2600,
380
],
"kb": 1470
"kb": 1074
},
"train.interior": {
"file": "train.interior.jpg",
Expand Down Expand Up @@ -137,10 +137,10 @@
"detective": {
"file": "detective.png",
"px": [
480,
1120
400,
940
],
"kb": 632
"kb": 452
},
"notice": {
"file": "notice.png",
Expand Down Expand Up @@ -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
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/tv/src/assets/mysteries/the-last-train/detective.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/tv/src/assets/mysteries/the-last-train/foreground.pole.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/tv/src/assets/mysteries/the-last-train/tracks.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/tv/src/assets/mysteries/the-last-train/train.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 4 additions & 3 deletions apps/tv/src/game/CharacterSpeech.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
},
Expand Down
8 changes: 1 addition & 7 deletions apps/tv/src/world/Actors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Animated.View pointerEvents="none" style={[styles.train, {top: px(y), width: px(width), height: px(height), transform: [{translateX: Animated.multiply(x, PX_SCALE)}]}]}>
<AssetView assetId={def.assetId} width={width} height={height} />
{door && doorway && (
<Animated.View style={[styles.abs, {left: px(door.x), top: px(door.y), opacity: doors.interpolate({inputRange: [0, 1], outputRange: [0, 0.3]})}]}>
<AssetView assetId={doorway.id} width={door.w * 2} height={door.h} />
</Animated.View>
)}
{lights && (
<>
<Glow x={width - 40} y={140} radius={90} color="headlight" strength={0.22} flicker={false} reduced={reduced} />
Expand Down
5 changes: 3 additions & 2 deletions apps/tv/src/world/Effects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ export const Glow = ({x, y, radius, color = 'lampGlow', strength = 0.16, flicker
}, [flicker, reduced, seed, v]);
return (
<Animated.View pointerEvents="none" style={[styles.abs, {left: px(x - radius), top: px(y - radius), width: px(radius * 2), height: px(radius * 2), opacity: v}]}>
{[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) => (
<View
key={i}
style={[styles.abs, {
Expand All @@ -186,7 +187,7 @@ export const Glow = ({x, y, radius, color = 'lampGlow', strength = 0.16, flicker
height: px(radius * 2 * k),
borderRadius: px(radius * k),
backgroundColor: W[color],
opacity: strength,
opacity: strength * 0.62,
}]}
/>
))}
Expand Down
Loading
Loading