feat(app): wire the voyage together

Controller drives world and curtain; caption with Back to sea; play,
previous and next in the timeline bar; keyboard; no-WebGL frieze stage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 15:11:43 +01:00
co-authored by Claude Opus 5.5
parent ac017787b5
commit d355b3a41f
5 changed files with 185 additions and 18 deletions
+28 -8
View File
@@ -7,12 +7,14 @@ export interface ControllerWorld {
sailTo(songId: string, opts?: { instant?: boolean }): Promise<void>;
setLanding(id: LandingId | null): Promise<void>;
setRibbons(motifIds: string[] | null, highlight: string | null): void;
horizonFromFrieze(groundYpx: number): void;
}
/** The parts of the frieze curtain the controller drives (src/app/frieze/curtain.ts). */
export interface ControllerCurtain {
drop(sagaId: PlaceId, opts?: { landing?: LandingId }): Promise<void>;
lift(): Promise<void>;
fade(): Promise<void>;
groundY(): number;
}
export interface Controller {
@@ -23,6 +25,11 @@ export interface Controller {
stop(): void;
playing(): boolean;
onPlayingChange(fn: (playing: boolean) => void): void;
/** The landing the stage is in, if any. */
landing(): LandingId | null;
/** "Back to sea": leave the landing, keep the song. */
leaveLanding(): Promise<void>;
onLandingChange(fn: (landing: LandingId | null) => void): void;
}
const PLAY_HOLD_MS = 3400;
@@ -30,7 +37,8 @@ const PLAY_HOLD_MS = 3400;
/**
* Moves the stage when the song changes. Steps run one at a time; a newer request makes the running step
* stop at its next await, and the last request wins. A curtain left down by a cancelled step is lifted by
* the next one, so rapid navigation never leaves the stage covered.
* the next one, so rapid navigation never leaves the stage covered. Order under a curtain: cover → sail →
* landing → horizon on the frieze's ground line → lift (a landing drop lifts through the doorway).
*/
export function createController(opts: {
store: Store;
@@ -47,6 +55,8 @@ export function createController(opts: {
let queue: Promise<void> = Promise.resolve();
let timer: ReturnType<typeof setTimeout> | null = null;
const playListeners: ((p: boolean) => void)[] = [];
const landingListeners: ((l: LandingId | null) => void)[] = [];
const setLanded = (l: LandingId | null) => { landed = l; landingListeners.forEach((fn) => fn(l)); };
const ribbons = () => {
const s = store.get();
@@ -68,18 +78,20 @@ export function createController(opts: {
if (world) {
if (landed && landed !== landing) {
await world.setLanding(null);
landed = null;
setLanded(null);
}
await world.sailTo(to, { instant: kind !== "sail" });
}
if (my !== gen) return;
if (curtainDown) {
await curtain.lift();
curtainDown = false;
}
if (world && landing !== landed) {
await world.setLanding(landing);
landed = landing;
setLanded(landing);
}
if (my !== gen) return;
if (curtainDown) {
world?.horizonFromFrieze(curtain.groundY());
await curtain.lift();
curtainDown = false;
}
settled = to;
ribbons();
@@ -118,7 +130,7 @@ export function createController(opts: {
const first = songById(settled)!;
running++;
queue = world.sailTo(settled, { instant: true })
.then(async () => { if (first.landing) { await world.setLanding(first.landing); landed = first.landing; } ribbons(); })
.then(async () => { if (first.landing) { await world.setLanding(first.landing); setLanded(first.landing); } ribbons(); })
.catch((e) => console.error(e))
.finally(() => { running--; });
}
@@ -130,5 +142,13 @@ export function createController(opts: {
stop() { setPlaying(false); },
playing: () => playingNow,
onPlayingChange(fn) { playListeners.push(fn); },
landing: () => landed,
leaveLanding() {
if (!world || !landed) return Promise.resolve();
const done = queue.then(async () => { await world.setLanding(null); setLanded(null); });
queue = done.catch((e) => console.error(e));
return done;
},
onLandingChange(fn) { landingListeners.push(fn); },
};
}