feat(app): voyage transitions and the stage controller

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 14:58:59 +01:00
co-authored by Claude Opus 5.5
parent b3385185b5
commit 27d624fa44
3 changed files with 234 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
import { songById, songs, type LandingId, type PlaceId } from "../data/album";
import type { Store } from "./state";
import { transitionFor } from "./voyage";
/** The parts of the 3D world the controller drives (src/app/world/world.ts implements them). */
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;
}
/** 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>;
}
export interface Controller {
/** The song the stage has finished moving to. */
settledSongId(): string;
busy(): boolean;
play(): void;
stop(): void;
playing(): boolean;
onPlayingChange(fn: (playing: boolean) => void): void;
}
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.
*/
export function createController(opts: {
store: Store;
world: ControllerWorld | null;
curtain: ControllerCurtain;
reducedMotion: boolean;
}): Controller {
const { store, world, curtain, reducedMotion } = opts;
let settled = store.get().songId;
let landed: LandingId | null = null;
let curtainDown = false;
let gen = 0;
let running = 0;
let queue: Promise<void> = Promise.resolve();
let timer: ReturnType<typeof setTimeout> | null = null;
const playListeners: ((p: boolean) => void)[] = [];
const ribbons = () => {
const s = store.get();
world?.setRibbons(s.ribbons ? songById(s.songId)!.motifIds : null, s.motif);
};
async function step(to: string, my: number) {
if (my !== gen) return;
const { transition, landing } = transitionFor(settled, to);
const kind = reducedMotion && transition.kind !== "sail" ? "fade" : transition.kind;
if (kind === "curtain" && transition.kind === "curtain") {
await curtain.drop(transition.sagaId, landing ? { landing } : undefined);
curtainDown = true;
} else if (kind === "fade") {
await curtain.fade();
curtainDown = true;
}
if (my !== gen) return;
if (world) {
if (landed && landed !== landing) {
await world.setLanding(null);
landed = 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;
}
settled = to;
ribbons();
if (timer === null && playingNow) schedule();
}
function request(to: string) {
const my = ++gen;
running++;
queue = queue.then(() => step(to, my)).catch((e) => console.error(e)).finally(() => { running--; });
}
let playingNow = false;
function schedule() {
timer = setTimeout(() => {
timer = null;
if (!playingNow) return;
const next = songs.find((x) => x.track === songById(settled)!.track + 1);
if (!next) return setPlaying(false);
store.set({ songId: next.id });
}, PLAY_HOLD_MS);
}
function setPlaying(p: boolean) {
playingNow = p;
if (!p && timer) { clearTimeout(timer); timer = null; }
playListeners.forEach((fn) => fn(p));
}
store.subscribe((s, prev) => {
if (s.songId !== prev.songId) request(s.songId);
else if (s.ribbons !== prev.ribbons || s.motif !== prev.motif) ribbons();
});
// First paint: put the ship at the deep-linked song without a transition.
if (world) {
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(); })
.catch((e) => console.error(e))
.finally(() => { running--; });
}
return {
settledSongId: () => settled,
busy: () => running > 0,
play() { if (!playingNow) { setPlaying(true); if (!running) schedule(); } },
stop() { setPlaying(false); },
playing: () => playingNow,
onPlayingChange(fn) { playListeners.push(fn); },
};
}