diff --git a/src/app/controller.ts b/src/app/controller.ts new file mode 100644 index 0000000..1112600 --- /dev/null +++ b/src/app/controller.ts @@ -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; + setLanding(id: LandingId | null): Promise; + 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; + lift(): Promise; + fade(): Promise; +} + +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 = Promise.resolve(); + let timer: ReturnType | 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); }, + }; +} diff --git a/src/app/voyage.ts b/src/app/voyage.ts new file mode 100644 index 0000000..b846b69 --- /dev/null +++ b/src/app/voyage.ts @@ -0,0 +1,18 @@ +import { sagaOf, sagas, songById, type LandingId, type PlaceId } from "../data/album"; + +export type Transition = { kind: "sail" } | { kind: "curtain"; sagaId: PlaceId } | { kind: "fade" }; +export interface Step { transition: Transition; landing: LandingId | null } + +/** + * How the stage moves from one song to another (spec 002, goals 4–5): within a saga the ship sails; into + * the next or previous saga that saga's frieze drops like a curtain; across two or more sagas the stage + * fades through the curtain colour. The step ends in the target song's landing, if it has one. + */ +export function transitionFor(fromSongId: string, toSongId: string): Step { + const from = songById(fromSongId)!; + const to = songById(toSongId)!; + const order = (id: PlaceId) => sagas.findIndex((s) => s.id === id); + const gap = Math.abs(order(sagaOf(to).id) - order(sagaOf(from).id)); + const transition: Transition = gap === 0 ? { kind: "sail" } : gap === 1 ? { kind: "curtain", sagaId: to.sagaId } : { kind: "fade" }; + return { transition, landing: to.landing }; +} diff --git a/tests/voyage.test.ts b/tests/voyage.test.ts new file mode 100644 index 0000000..115e563 --- /dev/null +++ b/tests/voyage.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "bun:test"; +import { transitionFor } from "../src/app/voyage"; +import { createController, type ControllerWorld, type ControllerCurtain } from "../src/app/controller"; +import { createStore, DEFAULT_STATE } from "../src/app/state"; + +test("within a saga the ship sails, and landing songs land", () => { + expect(transitionFor("polyphemus", "survive")).toEqual({ transition: { kind: "sail" }, landing: "cave" }); + expect(transitionFor("survive", "remember-them")).toEqual({ transition: { kind: "sail" }, landing: null }); +}); + +test("into the next or previous saga the curtain drops with that saga's frieze", () => { + expect(transitionFor("warrior-of-the-mind", "polyphemus")).toEqual({ transition: { kind: "curtain", sagaId: "cyclops" }, landing: "cave" }); + expect(transitionFor("polyphemus", "warrior-of-the-mind")).toEqual({ transition: { kind: "curtain", sagaId: "troy" }, landing: null }); +}); + +test("jumps across two or more sagas fade", () => { + expect(transitionFor("storm", "god-games")).toEqual({ transition: { kind: "fade" }, landing: "arena" }); +}); + +test("the same song is a no-op sail", () => { + expect(transitionFor("storm", "storm")).toEqual({ transition: { kind: "sail" }, landing: null }); +}); + +function fakes() { + const log: string[] = []; + let release: (() => void)[] = []; + const later = (what: string) => new Promise((r) => { log.push(what); release.push(r); }); + const world: ControllerWorld = { + sailTo: (id) => later(`sail:${id}`), + setLanding: (id) => later(`land:${id}`), + setRibbons: () => {}, + }; + const curtain: ControllerCurtain = { + drop: (saga) => later(`drop:${saga}`), + lift: () => later("lift"), + fade: () => later("fade"), + }; + const flush = async () => { + for (let i = 0; i < 50; i++) { + const r = release; release = []; + r.forEach((f) => f()); + await Promise.resolve(); await Promise.resolve(); + } + }; + return { log, world, curtain, flush }; +} + +test("rapid navigation ends at the last requested song with the curtain lifted", async () => { + const f = fakes(); + const store = createStore(DEFAULT_STATE, { history: false }); + const ctl = createController({ store, world: f.world, curtain: f.curtain, reducedMotion: false }); + for (const id of ["just-a-man", "polyphemus", "storm", "puppeteer", "god-games"]) store.set({ songId: id }); + await f.flush(); + expect(ctl.settledSongId()).toBe("god-games"); + const lastSail = f.log.filter((l) => l.startsWith("sail:")).at(-1); + expect(lastSail).toBe("sail:god-games"); + const drops = f.log.filter((l) => l.startsWith("drop:") || l === "fade").length; + const lifts = f.log.filter((l) => l === "lift").length; + expect(lifts).toBeGreaterThanOrEqual(drops === 0 ? 0 : 1); + expect(f.log.at(-1)).toBe("land:arena"); + expect(ctl.busy()).toBe(false); +}); + +test("without a world the controller still follows the songs (frieze-only browsing)", async () => { + const f = fakes(); + const store = createStore(DEFAULT_STATE, { history: false }); + const ctl = createController({ store, world: null, curtain: f.curtain, reducedMotion: false }); + store.set({ songId: "polyphemus" }); + await f.flush(); + expect(ctl.settledSongId()).toBe("polyphemus"); + expect(f.log).toContain("drop:cyclops"); +}); + +test("reduced motion turns every scene change into a fade", async () => { + const f = fakes(); + const store = createStore(DEFAULT_STATE, { history: false }); + createController({ store, world: f.world, curtain: f.curtain, reducedMotion: true }); + store.set({ songId: "polyphemus" }); + await f.flush(); + expect(f.log).toContain("fade"); + expect(f.log.some((l) => l.startsWith("drop:"))).toBe(false); +});