feat(app): voyage transitions and the stage controller
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b3385185b5
commit
27d624fa44
@@ -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); },
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<void>((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);
|
||||
});
|
||||
Reference in New Issue
Block a user