fix(app): controller cancels instead of queueing; Go ashore; focus, narrow and lyrics fixes
From the final review: a request cancelled mid-drop no longer sails under the wrong frieze; new requests cancel running animations; Play voyage no longer sticks during the first scene and stops on any other navigation; the landing intro is revealed by the lift; Go ashore enters a landing through the frieze; Back to sea keeps keyboard focus; the timeline is compact below 900px; lyrics re-scroll after a tab switch; the shared toon gradient is no longer disposed with a landing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a196c09b82
commit
0456683d1b
10 files changed
+258
-129
No files matched your search
@@ -31,7 +31,7 @@ credits snippet.
|
||||
| `src/app/index.html`, `main.ts`, `style.css` | The app page: stage (canvas, frieze layer, caption, timeline) and panel. `main.ts` wires store → controller → world/curtain, panel, timeline, play/prev/next, keyboard (←/→, Esc), the no-WebGL frieze stage. `style.css` holds the design tokens (see `DESIGN.md`). |
|
||||
| `src/app/state.ts` | `AppState` (`songId`, `tab`, `motif`, `timeline`, `ribbons`, `read`), `parseState`/`stateToSearch` (URL), `createStore` (push on song change, replace otherwise, `popstate` restores). |
|
||||
| `src/app/voyage.ts` | `transitionFor(from, to)`: same saga → sail; adjacent saga → that saga's curtain; further → fade; plus the target's landing. |
|
||||
| `src/app/controller.ts` | Runs steps one at a time, last request wins, a cancelled step's curtain is lifted by the next. Order under a curtain: cover → sail → landing → horizon on the frieze ground line → lift. Play voyage (3.4s per song, silent), Back to sea. |
|
||||
| `src/app/controller.ts` | A new request starts at once (world and curtain cancel their running animation; older steps stop at their next await). Tracks what is on screen (`at`, `cover`) so a step never sails under the wrong saga's frieze. Under a curtain: cover → instant sail → start the landing → horizon on the frieze ground line → lift. Play voyage (3.4s per song, silent; any other navigation stops it), Go ashore, Back to sea. |
|
||||
| `src/app/world/` | `createWorld(canvas)` (null without WebGL 2): `route.ts` (9 places, 40 stops, pure), stylised islands, ship, ribbons ("threads on the sea"), `landings/` cave, arena, hall. |
|
||||
| `src/app/frieze/` | `friezeSvg(sagaId)` for the 9 sagas from a figure kit; `createCurtain(layer)`: drop, lift (part along the ground line, or zoom through a doorway), fade, `groundY()`. |
|
||||
| `src/app/panel/` | `mountPanel`: About (facts, Odyssey reading view), Listen & lyrics (real player, line-synced lyrics, Watch), Paths (strip when the timeline is hidden, motif rows, nine-place chart), Fan works. |
|
||||
|
||||
@@ -98,3 +98,6 @@ Small, specific, independently actionable. Remove only when actually done.
|
||||
- Mobile (<900px) is usable but not polished; no favicon yet (one 404 per page load).
|
||||
- Some frieze figures are small at five songs per saga; Charybdis reads mainly as a ring from the ship.
|
||||
- Timeline motif inks on parchment can look alike (Warrior / Thunder Bringer / Zeus).
|
||||
- `tests/frieze.test.ts` covers the curtain only on the reduced-motion (crossfade) path; the parting
|
||||
and doorway-zoom animations are checked by screenshots, not tests.
|
||||
- Ribbon labels on the sea are not clickable (the world has no click callback).
|
||||
@@ -189,8 +189,8 @@ Controller: subscribes to the store; runs `transitionFor` → curtain/fade → `
|
||||
|
||||
### Task 8: Docs, verification, deploy
|
||||
|
||||
- [ ] `DESIGN.md`: replace "visual direction open" with the chosen direction as tokens and rules (palette, type scale, meander band, motif colours from data, 3D style, panel/timeline layout).
|
||||
- [ ] `docs/ARCHITECTURE.md` (app modules, routes `/` and `/mockups/`, album data), `docs/DECISIONS.md` (album data separate from mockup data; landing mapping; silent Play voyage; fan works empty in v1), `AGENTS.md` (overview line, architecture, rules for `src/app/`), `PRODUCT.md` if it describes the site as mockups, `docs/TODO.md` (parts/motif time alignment, mobile polish, fan-work sourcing, Odyssey passage confirmation).
|
||||
- [ ] Screenshots at 1440×900 (`--use-angle=swiftshader --use-gl=angle --enable-unsafe-swiftshader --virtual-time-budget=6000`) into `screenshots/app-*.png`: first load; one song per saga; each landing; Listen playing with lyrics; Paths with timeline closed; ribbons on; reading view; no content (server without `CONTENT_DIR`); 800×900 narrow. Look at every one.
|
||||
- [ ] `bun test`, `bun run typecheck`, `bun run build`; rebuild the preview container (command in `docs/design/002-direction-round-notes.md` §7); curl `/`, `/mockups/`, `/content/library.json`.
|
||||
- [x] `DESIGN.md`: replace "visual direction open" with the chosen direction as tokens and rules (palette, type scale, meander band, motif colours from data, 3D style, panel/timeline layout).
|
||||
- [x] `docs/ARCHITECTURE.md` (app modules, routes `/` and `/mockups/`, album data), `docs/DECISIONS.md` (album data separate from mockup data; landing mapping; silent Play voyage; fan works empty in v1), `AGENTS.md` (overview line, architecture, rules for `src/app/`), `PRODUCT.md` if it describes the site as mockups, `docs/TODO.md` (parts/motif time alignment, mobile polish, fan-work sourcing, Odyssey passage confirmation).
|
||||
- [x] Screenshots at 1440×900 (`--use-angle=swiftshader --use-gl=angle --enable-unsafe-swiftshader --virtual-time-budget=6000`) into `screenshots/app-*.png`: first load; one song per saga; each landing; Listen playing with lyrics; Paths with timeline closed; ribbons on; reading view; no content (server without `CONTENT_DIR`); 800×900 narrow. Look at every one.
|
||||
- [x] `bun test`, `bun run typecheck`, `bun run build`; rebuild the preview container (command in `docs/design/002-direction-round-notes.md` §7); curl `/`, `/mockups/`, `/content/library.json`.
|
||||
- [ ] Final whole-branch review by a fresh agent; fix findings; `git rm docs/plans/002-voyage-app.md docs/plans/003-content-mount.md` before any merge request; spec 002 and 003 → `status: implemented`.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: approved
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# 002 — The voyage app (v1)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: approved
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Real content from a mounted folder (audio, timed lyrics, Homer)
|
||||
|
||||
+87
-50
@@ -4,12 +4,14 @@ 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<void>;
|
||||
/** Resolves when the landing's arrival has played; `null` returns to sea at once. */
|
||||
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). */
|
||||
/** 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<void>;
|
||||
lift(): Promise<void>;
|
||||
@@ -29,31 +31,42 @@ export interface Controller {
|
||||
landing(): LandingId | null;
|
||||
/** "Back to sea": leave the landing, keep the song. */
|
||||
leaveLanding(): Promise<void>;
|
||||
/** "Go ashore": enter the current song's landing through its saga's frieze. */
|
||||
goAshore(): Promise<void>;
|
||||
onLandingChange(fn: (landing: LandingId | null) => 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. Order under a curtain: cover → sail →
|
||||
* landing → horizon on the frieze's ground line → lift (a landing drop lifts through the doorway).
|
||||
* 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 curtainDown = false;
|
||||
let gen = 0;
|
||||
let running = 0;
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
let timer: ReturnType<typeof setTimeout> | 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)); };
|
||||
@@ -63,91 +76,115 @@ export function createController(opts: {
|
||||
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") {
|
||||
/** 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();
|
||||
curtainDown = true;
|
||||
} 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);
|
||||
}
|
||||
await world.sailTo(to, { instant: kind !== "sail" });
|
||||
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;
|
||||
if (world && landing !== landed) {
|
||||
await world.setLanding(landing);
|
||||
setLanded(landing);
|
||||
}
|
||||
if (my !== gen) return;
|
||||
if (curtainDown) {
|
||||
world?.horizonFromFrieze(curtain.groundY());
|
||||
await curtain.lift();
|
||||
curtainDown = false;
|
||||
}
|
||||
settled = to;
|
||||
ribbons();
|
||||
if (timer === null && playingNow) schedule();
|
||||
}
|
||||
|
||||
function request(to: string) {
|
||||
const my = ++gen;
|
||||
function track(p: Promise<void>) {
|
||||
running++;
|
||||
queue = queue.then(() => step(to, my)).catch((e) => console.error(e)).finally(() => { running--; });
|
||||
return p.catch((e) => console.error(e)).finally(() => { running--; maybeSchedule(); });
|
||||
}
|
||||
|
||||
let playingNow = false;
|
||||
function schedule() {
|
||||
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 });
|
||||
}, PLAY_HOLD_MS);
|
||||
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) request(s.songId);
|
||||
else if (s.ribbons !== prev.ribbons || s.motif !== prev.motif) ribbons();
|
||||
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)!;
|
||||
running++;
|
||||
queue = world.sailTo(settled, { instant: true })
|
||||
.then(async () => { if (first.landing) { await world.setLanding(first.landing); setLanded(first.landing); } ribbons(); })
|
||||
.catch((e) => console.error(e))
|
||||
.finally(() => { running--; });
|
||||
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() { if (!playingNow) { setPlaying(true); if (!running) schedule(); } },
|
||||
stop() { setPlaying(false); },
|
||||
play: () => setPlaying(true),
|
||||
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;
|
||||
++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); },
|
||||
};
|
||||
|
||||
+21
-12
@@ -40,32 +40,40 @@ if (!world) {
|
||||
|
||||
const controller = createController({ store, world, curtain: stageCurtain, reducedMotion });
|
||||
|
||||
mountPanel($("panel"), store, { timelineHidden: () => narrow.matches });
|
||||
const panel = mountPanel($("panel"), store, { timelineHidden: () => narrow.matches });
|
||||
const timeline = mountTimeline($("timeline"), store);
|
||||
|
||||
/* ---------- caption card: saga · story time, song title, Back to sea in a landing ---------- */
|
||||
// Below 900px the timeline starts compact (its slim course) and the motif strip lives in Paths.
|
||||
const compactIfNarrow = () => { if (narrow.matches && store.get().timeline === "open") store.set({ timeline: "closed" }, { push: false }); };
|
||||
compactIfNarrow();
|
||||
narrow.addEventListener("change", () => { compactIfNarrow(); panel.refresh(); timeline.refresh(); });
|
||||
|
||||
/* ---------- caption card: saga · story time, song title, Go ashore / Back to sea ---------- */
|
||||
|
||||
const LANDING_NAME: Record<LandingId, string> = { cave: "The Cyclops' cave", arena: "God Games", hall: "The hall of Ithaca" };
|
||||
const caption = $("caption");
|
||||
function renderCaption() {
|
||||
function renderCaption(focusAction = false) {
|
||||
const song = songById(store.get().songId)!;
|
||||
const landing = controller.landing();
|
||||
caption.innerHTML = `<p class="cap__eyebrow"></p><h1 class="cap__title"></h1>`;
|
||||
caption.innerHTML = `<p class="cap__eyebrow"></p><h1 class="cap__title" tabindex="-1"></h1>`;
|
||||
caption.querySelector(".cap__eyebrow")!.textContent = landing
|
||||
? `Ashore · ${LANDING_NAME[landing]}`
|
||||
: `${sagaOf(song).name} · ${song.storyTime.label}`;
|
||||
caption.querySelector(".cap__title")!.textContent = song.title;
|
||||
if (landing) {
|
||||
const back = document.createElement("button");
|
||||
back.type = "button";
|
||||
back.className = "cap__back";
|
||||
back.textContent = "Back to sea";
|
||||
back.addEventListener("click", () => void controller.leaveLanding());
|
||||
caption.append(back);
|
||||
const action = landing ? "Back to sea" : world && song.landing ? `Go ashore: ${LANDING_NAME[song.landing]}` : null;
|
||||
if (action) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "cap__back";
|
||||
btn.textContent = action;
|
||||
btn.addEventListener("click", () => void (landing ? controller.leaveLanding() : controller.goAshore()));
|
||||
caption.append(btn);
|
||||
}
|
||||
// the button that had focus was just replaced: keep keyboard users on the caption's action
|
||||
if (focusAction) (caption.querySelector<HTMLElement>(".cap__back") ?? caption.querySelector<HTMLElement>(".cap__title"))!.focus();
|
||||
}
|
||||
store.subscribe((s, prev) => { if (s.songId !== prev.songId) renderCaption(); });
|
||||
controller.onLandingChange(renderCaption);
|
||||
controller.onLandingChange(() => renderCaption(caption.contains(document.activeElement)));
|
||||
renderCaption();
|
||||
|
||||
/* ---------- play / previous / next, in the timeline's controls slot ---------- */
|
||||
@@ -109,6 +117,7 @@ addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
}
|
||||
});
|
||||
for (const ev of ["pointerdown", "wheel"] as const) $("stage").addEventListener(ev, () => controller.stop(), { passive: true });
|
||||
// (any song change the voyage didn't make itself also stops it: see createController)
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
|
||||
|
||||
@@ -89,7 +89,9 @@ export function createListen(deps: ListenDeps) {
|
||||
// createVideoCards only reads `videos`; the album song has the same shape for it
|
||||
el.querySelector(".watch__cards")!.append(createVideoCards(s as unknown as Song));
|
||||
wireScrub(el);
|
||||
// a fresh render starts at the top: let the next tick scroll the current line back into view
|
||||
lineNow = -1;
|
||||
lineScrolled = -1;
|
||||
return el;
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,8 @@ export function disposeTree(root: THREE.Object3D) {
|
||||
else if (mat) mats.add(mat);
|
||||
});
|
||||
for (const m of mats) {
|
||||
for (const v of Object.values(m)) if (v instanceof THREE.Texture) v.dispose();
|
||||
// the shared toon gradient (`steps`) also shades the sea's islands: never dispose it with a landing
|
||||
for (const v of Object.values(m)) if (v instanceof THREE.Texture && v !== steps) v.dispose();
|
||||
m.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
+136
-59
@@ -1,7 +1,7 @@
|
||||
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";
|
||||
import { createStore, DEFAULT_STATE, type AppState } 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" });
|
||||
@@ -21,93 +21,170 @@ test("the same song is a no-op sail", () => {
|
||||
expect(transitionFor("storm", "storm")).toEqual({ transition: { kind: "sail" }, landing: null });
|
||||
});
|
||||
|
||||
/**
|
||||
* Fakes that behave like the real modules: every animation is a pending promise the test releases; a new
|
||||
* call on the curtain (any of drop/lift/fade) or a new sail resolves the running one at once, as the real
|
||||
* curtain and world cancel. `setLanding(null)` is immediate, like the real world.
|
||||
*/
|
||||
function fakes() {
|
||||
const log: string[] = [];
|
||||
let release: (() => void)[] = [];
|
||||
const later = (what: string) => new Promise<void>((r) => { log.push(what); release.push(r); });
|
||||
const pending: { what: string; resolve: () => void }[] = [];
|
||||
const start = (what: string, group: string) => {
|
||||
for (const p of pending.filter((p) => p.what.startsWith(group))) { p.resolve(); pending.splice(pending.indexOf(p), 1); }
|
||||
log.push(what);
|
||||
return new Promise<void>((resolve) => pending.push({ what: group + what, resolve }));
|
||||
};
|
||||
const world: ControllerWorld = {
|
||||
sailTo: (id) => later(`sail:${id}`),
|
||||
setLanding: (id) => later(`land:${id}`),
|
||||
sailTo: (id, o) => start(`sail:${id}${o?.instant ? "!" : ""}`, "W"),
|
||||
setLanding: (id) => (id === null ? (log.push("land:null"), Promise.resolve()) : start(`land:${id}`, "L")),
|
||||
setRibbons: () => {},
|
||||
horizonFromFrieze: (y) => { log.push(`horizon:${y}`); },
|
||||
};
|
||||
const curtain: ControllerCurtain = {
|
||||
drop: (saga) => later(`drop:${saga}`),
|
||||
lift: () => later("lift"),
|
||||
fade: () => later("fade"),
|
||||
drop: (saga, o) => start(`drop:${saga}${o?.landing ? `>${o.landing}` : ""}`, "C"),
|
||||
lift: () => start("lift", "C"),
|
||||
fade: () => start("fade", "C"),
|
||||
groundY: () => 500,
|
||||
};
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const r = release; release = [];
|
||||
r.forEach((f) => f());
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
}
|
||||
const tick = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); };
|
||||
/** Finish the oldest running animation whose log entry starts with `prefix`. */
|
||||
const release = async (prefix: string) => {
|
||||
const p = pending.find((p) => p.what.slice(1).startsWith(prefix));
|
||||
if (!p) throw new Error(`nothing pending for ${prefix}; pending: ${pending.map((p) => p.what).join(", ")}`);
|
||||
pending.splice(pending.indexOf(p), 1);
|
||||
p.resolve();
|
||||
await tick();
|
||||
};
|
||||
return { log, world, curtain, flush };
|
||||
const settle = async () => { for (let i = 0; i < 40 && pending.length; i++) { pending.splice(0).forEach((p) => p.resolve()); await tick(); } };
|
||||
return { log, world, curtain, release, settle, tick };
|
||||
}
|
||||
|
||||
test("rapid navigation ends at the last requested song with the curtain lifted", async () => {
|
||||
function setup(state: Partial<AppState> = {}, opts: { world?: boolean; reducedMotion?: boolean; holdMs?: number } = {}) {
|
||||
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);
|
||||
// covered → sail → landing → lift: the stage is uncovered last, already inside the arena
|
||||
expect(f.log.lastIndexOf("land:arena")).toBeLessThan(f.log.lastIndexOf("lift"));
|
||||
const store = createStore({ ...DEFAULT_STATE, ...state }, { history: false });
|
||||
const ctl = createController({
|
||||
store, world: opts.world === false ? null : f.world, curtain: f.curtain,
|
||||
reducedMotion: opts.reducedMotion ?? false, holdMs: opts.holdMs,
|
||||
});
|
||||
return { f, store, ctl };
|
||||
}
|
||||
|
||||
test("a new request cancels the running sail instead of waiting for it", async () => {
|
||||
const { f, store } = setup({ songId: "polyphemus" });
|
||||
await f.settle();
|
||||
store.set({ songId: "survive" });
|
||||
await f.tick();
|
||||
expect(f.log.at(-1)).toBe("sail:survive");
|
||||
store.set({ songId: "remember-them" }); // survive's sail is still running
|
||||
await f.tick();
|
||||
expect(f.log).toContain("sail:remember-them");
|
||||
});
|
||||
|
||||
test("cancelled during a curtain drop, the next step never sails under the wrong saga's frieze", async () => {
|
||||
const { f, store, ctl } = setup({ songId: "full-speed-ahead" });
|
||||
await f.settle();
|
||||
f.log.length = 0;
|
||||
store.set({ songId: "polyphemus" });
|
||||
await f.tick();
|
||||
expect(f.log).toEqual(["drop:cyclops>cave"]);
|
||||
store.set({ songId: "just-a-man" }); // back in Troy while the Cyclops frieze is coming down
|
||||
await f.tick();
|
||||
// the frieze on screen is Cyclops', the target is Troy: Troy's frieze replaces it before anything lifts
|
||||
expect(f.log[1]).toBe("drop:troy");
|
||||
await f.settle();
|
||||
expect(f.log).toContain("sail:just-a-man!");
|
||||
expect(f.log.at(-1)).toBe("lift");
|
||||
expect(ctl.settledSongId()).toBe("just-a-man");
|
||||
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("rapid navigation, released one step at a time, ends at the last song with the stage uncovered", async () => {
|
||||
const { f, store, ctl } = setup();
|
||||
await f.settle();
|
||||
for (const id of ["just-a-man", "polyphemus", "storm", "puppeteer", "god-games"]) {
|
||||
store.set({ songId: id });
|
||||
await f.tick();
|
||||
await f.release(f.log.at(-1)!.split(/[:!>]/)[0]!);
|
||||
}
|
||||
await f.settle();
|
||||
expect(ctl.settledSongId()).toBe("god-games");
|
||||
expect(f.log.filter((l) => l.startsWith("sail:")).at(-1)).toMatch(/^sail:god-games/);
|
||||
expect(f.log.at(-1)).toBe("lift");
|
||||
expect(ctl.landing()).toBe("arena");
|
||||
expect(ctl.busy()).toBe(false);
|
||||
});
|
||||
|
||||
test("the horizon is matched to the frieze's ground line before the curtain lifts", async () => {
|
||||
const f = fakes();
|
||||
const store = createStore(DEFAULT_STATE, { history: false });
|
||||
createController({ store, world: f.world, curtain: f.curtain, reducedMotion: false });
|
||||
await f.flush();
|
||||
store.set({ songId: "just-a-man" }); // same saga: no curtain
|
||||
await f.flush();
|
||||
expect(f.log.filter((l) => l.startsWith("horizon:"))).toHaveLength(0);
|
||||
store.set({ songId: "polyphemus" }); // next saga: curtain
|
||||
await f.flush();
|
||||
test("the landing starts before the curtain lifts, so the lift reveals its arrival", async () => {
|
||||
const { f, store } = setup({ songId: "warrior-of-the-mind" });
|
||||
await f.settle();
|
||||
f.log.length = 0;
|
||||
store.set({ songId: "polyphemus" });
|
||||
await f.tick();
|
||||
await f.release("drop");
|
||||
await f.release("sail");
|
||||
// land:cave is still running (its intro), and the lift has already begun
|
||||
expect(f.log).toEqual(["drop:cyclops>cave", "sail:polyphemus!", "land:cave", "horizon:500", "lift"]);
|
||||
});
|
||||
|
||||
test("the horizon is matched to the frieze's ground line only when a curtain lifts", async () => {
|
||||
const { f, store } = setup();
|
||||
await f.settle();
|
||||
store.set({ songId: "just-a-man" });
|
||||
await f.settle();
|
||||
expect(f.log.some((l) => l.startsWith("horizon:"))).toBe(false);
|
||||
store.set({ songId: "polyphemus" });
|
||||
await f.settle();
|
||||
expect(f.log).toContain("horizon:500");
|
||||
expect(f.log.indexOf("horizon:500")).toBeLessThan(f.log.lastIndexOf("lift"));
|
||||
});
|
||||
|
||||
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 });
|
||||
test("without a world the controller still follows the songs (frieze-only browsing)", async () => {
|
||||
const { f, store, ctl } = setup({}, { world: false });
|
||||
store.set({ songId: "polyphemus" });
|
||||
await f.flush();
|
||||
await f.settle();
|
||||
expect(ctl.settledSongId()).toBe("polyphemus");
|
||||
expect(f.log).toContain("drop:cyclops>cave");
|
||||
});
|
||||
|
||||
test("reduced motion turns every scene change into a fade", async () => {
|
||||
const { f, store } = setup({}, { reducedMotion: true });
|
||||
store.set({ songId: "polyphemus" });
|
||||
await f.settle();
|
||||
expect(f.log).toContain("fade");
|
||||
expect(f.log.some((l) => l.startsWith("drop:"))).toBe(false);
|
||||
});
|
||||
|
||||
test("Back to sea leaves the landing without changing the song", async () => {
|
||||
const f = fakes();
|
||||
const store = createStore({ ...DEFAULT_STATE, songId: "survive" }, { history: false });
|
||||
const ctl = createController({ store, world: f.world, curtain: f.curtain, reducedMotion: false });
|
||||
await f.flush();
|
||||
test("Back to sea leaves the landing without changing the song; Go ashore enters it through the frieze", async () => {
|
||||
const { f, store, ctl } = setup({ songId: "survive" });
|
||||
await f.settle();
|
||||
expect(ctl.landing()).toBe("cave");
|
||||
const left = ctl.leaveLanding();
|
||||
await f.flush();
|
||||
await left;
|
||||
await ctl.leaveLanding();
|
||||
expect(f.log.at(-1)).toBe("land:null");
|
||||
expect(ctl.landing()).toBeNull();
|
||||
expect(store.get().songId).toBe("survive");
|
||||
f.log.length = 0;
|
||||
const ashore = ctl.goAshore();
|
||||
await f.settle();
|
||||
await ashore;
|
||||
expect(f.log).toEqual(["drop:cyclops>cave", "land:cave", "horizon:500", "lift"]);
|
||||
expect(ctl.landing()).toBe("cave");
|
||||
});
|
||||
|
||||
test("Play voyage advances even when pressed while the first scene is still arriving", async () => {
|
||||
const { f, store, ctl } = setup({ songId: "survive" }, { holdMs: 5 });
|
||||
ctl.play(); // the cave intro of the first paint is still running
|
||||
await f.settle();
|
||||
await Bun.sleep(30);
|
||||
await f.settle();
|
||||
expect(store.get().songId).not.toBe("survive");
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
test("any navigation the voyage didn't make stops Play voyage", async () => {
|
||||
const { f, store, ctl } = setup({}, { holdMs: 1000 });
|
||||
await f.settle();
|
||||
ctl.play();
|
||||
expect(ctl.playing()).toBe(true);
|
||||
store.set({ songId: "storm" }); // e.g. a timeline click
|
||||
expect(ctl.playing()).toBe(false);
|
||||
});
|
||||
Reference in new issue
Block a user