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 { /** A new call resolves the running sail at once. */ sailTo(songId: string, opts?: { instant?: boolean }): Promise; /** Resolves when the landing's arrival has played; `null` returns to sea at once. */ setLanding(id: LandingId | null): Promise; 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). Any call cancels the running one. */ export interface ControllerCurtain { drop(sagaId: PlaceId, opts?: { landing?: LandingId }): Promise; lift(): Promise; fade(): Promise; groundY(): number; } 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; /** The landing the stage is in, if any. */ landing(): LandingId | null; /** "Back to sea": leave the landing, keep the song. */ leaveLanding(): Promise; /** "Go ashore": enter the current song's landing through its saga's frieze. */ goAshore(): Promise; onLandingChange(fn: (landing: LandingId | null) => void): void; } const PLAY_HOLD_MS = 3400; /** * Moves the stage when the song changes. A newer request starts at once: the world and the curtain cancel * their running animation on the next call, and the older step stops at its next await (generation * check). The controller tracks what is on screen (the song the ship is headed for, and which frieze, if * any, covers the stage), so a step that starts under a frieze of the wrong saga swaps it first and never * sails visibly under it. Order under a curtain: cover → sail (instant) → start the landing → horizon on * the frieze's ground line → lift, so the lift reveals the landing's arrival. */ export function createController(opts: { store: Store; world: ControllerWorld | null; curtain: ControllerCurtain; reducedMotion: boolean; /** How long Play voyage holds each song (default 3.4s). */ holdMs?: number; }): Controller { const { store, world, curtain, reducedMotion } = opts; const holdMs = opts.holdMs ?? PLAY_HOLD_MS; let settled = store.get().songId; /** The song the ship is at or sailing to. */ let at = settled; /** What covers the stage: a saga's frieze, the fade, or nothing. */ let cover: PlaceId | "fade" | null = null; let landed: LandingId | null = null; let gen = 0; let running = 0; let timer: ReturnType | null = null; let playingNow = false; let voyageNav = false; 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(); world?.setRibbons(s.ribbons ? songById(s.songId)!.motifIds : null, s.motif); }; /** Covers the stage with `sagaId`'s frieze (or the fade), unless it is already covered by it. */ async function coverWith(kind: "curtain" | "fade", sagaId: PlaceId, landing: LandingId | null) { if (kind === "fade" || reducedMotion) { if (cover === "fade") return; cover = "fade"; await curtain.fade(); } else { if (cover === sagaId) return; cover = sagaId; await curtain.drop(sagaId, landing ? { landing } : undefined); } } async function uncover(my: number) { world?.horizonFromFrieze(curtain.groundY()); await curtain.lift(); if (my === gen) cover = null; } async function step(to: string, my: number) { const target = songById(to)!; const { transition, landing } = transitionFor(at, to); if (transition.kind === "curtain") await coverWith("curtain", transition.sagaId, landing); else if (transition.kind === "fade") await coverWith("fade", target.sagaId, landing); else if (cover !== null && cover !== "fade" && cover !== target.sagaId) await coverWith("curtain", target.sagaId, landing); if (my !== gen) return; const covered = cover !== null; if (world) { if (landed && landed !== landing) { await world.setLanding(null); setLanded(null); } at = to; await world.sailTo(to, { instant: covered }); if (my !== gen) return; if (landing && landed !== landing) { setLanded(landing); void world.setLanding(landing); } } else { at = to; } if (covered) await uncover(my); if (my !== gen) return; settled = to; ribbons(); } function track(p: Promise) { running++; return p.catch((e) => console.error(e)).finally(() => { running--; maybeSchedule(); }); } function maybeSchedule() { if (!playingNow || timer !== null || running > 0) return; timer = setTimeout(() => { timer = null; if (!playingNow) return; const next = songs.find((x) => x.track === songById(settled)!.track + 1); if (!next) return setPlaying(false); voyageNav = true; store.set({ songId: next.id }); voyageNav = false; }, holdMs); } function setPlaying(p: boolean) { if (playingNow === p) return; playingNow = p; if (!p && timer) { clearTimeout(timer); timer = null; } playListeners.forEach((fn) => fn(p)); maybeSchedule(); } store.subscribe((s, prev) => { if (s.songId !== prev.songId) { if (!voyageNav) setPlaying(false); void track(step(s.songId, ++gen)); } 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)!; void track(world.sailTo(settled, { instant: true }).then(async () => { ribbons(); if (first.landing) { setLanded(first.landing); await world.setLanding(first.landing); } })); } return { settledSongId: () => settled, busy: () => running > 0, play: () => setPlaying(true), stop: () => setPlaying(false), playing: () => playingNow, onPlayingChange(fn) { playListeners.push(fn); }, landing: () => landed, leaveLanding() { if (!world || !landed) return Promise.resolve(); ++gen; return track(world.setLanding(null).then(() => setLanded(null))); }, goAshore() { const song = songById(settled)!; if (!world || !song.landing || landed) return Promise.resolve(); const my = ++gen; return track((async () => { await coverWith("curtain", song.sagaId, song.landing); if (my !== gen) return; setLanded(song.landing); void world.setLanding(song.landing); await uncover(my); })()); }, onLandingChange(fn) { landingListeners.push(fn); }, }; }