feat(app): wire the voyage together
Controller drives world and curtain; caption with Back to sea; play, previous and next in the timeline bar; keyboard; no-WebGL frieze stage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ac017787b5
commit
d355b3a41f
@@ -185,7 +185,7 @@ export function transitionFor(fromSongId: string, toSongId: string): Step;
|
||||
```
|
||||
Controller: subscribes to the store; runs `transitionFor` → curtain/fade → `world.sailTo` → `world.setLanding`; a newer request cancels the running one (generation counter) and the last request wins; Play voyage advances every 3.4s (silent) and stops on user input; ←/→ prev/next song; Esc closes reading view, else leaves a landing; caption card shows saga · story time and song title; no WebGL → `createWorld` null → frieze of the current saga stays as the stage with a note "3D needs WebGL — browsing with the frieze"; `prefers-reduced-motion` → every transition is a 200ms crossfade.
|
||||
|
||||
- [ ] Steps: failing tests — `transitionFor` cases (polyphemus→survive sail + cave; full-speed-ahead→polyphemus? (troy→cyclops adjacent) curtain cyclops; storm→god-games fade + arena; survive→remember-them sail + null); controller with a fake world: five rapid `set({songId})` calls end with the world at the last song and the curtain lifted; no-WebGL path renders the note → implement → pass → full-app screenshots (below) → commit `feat(app): voyage controller`.
|
||||
- [x] Steps: failing tests — `transitionFor` cases (polyphemus→survive sail + cave; full-speed-ahead→polyphemus? (troy→cyclops adjacent) curtain cyclops; storm→god-games fade + arena; survive→remember-them sail + null); controller with a fake world: five rapid `set({songId})` calls end with the world at the last song and the curtain lifted; no-WebGL path renders the note → implement → pass → full-app screenshots (below) → commit `feat(app): voyage controller`.
|
||||
|
||||
### Task 8: Docs, verification, deploy
|
||||
|
||||
|
||||
+28
-8
@@ -7,12 +7,14 @@ 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;
|
||||
horizonFromFrieze(groundYpx: number): 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>;
|
||||
groundY(): number;
|
||||
}
|
||||
|
||||
export interface Controller {
|
||||
@@ -23,6 +25,11 @@ export interface Controller {
|
||||
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<void>;
|
||||
onLandingChange(fn: (landing: LandingId | null) => void): void;
|
||||
}
|
||||
|
||||
const PLAY_HOLD_MS = 3400;
|
||||
@@ -30,7 +37,8 @@ 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.
|
||||
* 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).
|
||||
*/
|
||||
export function createController(opts: {
|
||||
store: Store;
|
||||
@@ -47,6 +55,8 @@ export function createController(opts: {
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
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();
|
||||
@@ -68,18 +78,20 @@ export function createController(opts: {
|
||||
if (world) {
|
||||
if (landed && landed !== landing) {
|
||||
await world.setLanding(null);
|
||||
landed = null;
|
||||
setLanded(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;
|
||||
setLanded(landing);
|
||||
}
|
||||
if (my !== gen) return;
|
||||
if (curtainDown) {
|
||||
world?.horizonFromFrieze(curtain.groundY());
|
||||
await curtain.lift();
|
||||
curtainDown = false;
|
||||
}
|
||||
settled = to;
|
||||
ribbons();
|
||||
@@ -118,7 +130,7 @@ export function createController(opts: {
|
||||
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(); })
|
||||
.then(async () => { if (first.landing) { await world.setLanding(first.landing); setLanded(first.landing); } ribbons(); })
|
||||
.catch((e) => console.error(e))
|
||||
.finally(() => { running--; });
|
||||
}
|
||||
@@ -130,5 +142,13 @@ export function createController(opts: {
|
||||
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;
|
||||
},
|
||||
onLandingChange(fn) { landingListeners.push(fn); },
|
||||
};
|
||||
}
|
||||
|
||||
+105
-8
@@ -1,18 +1,115 @@
|
||||
import { mountCredits } from "../shared/credits";
|
||||
import { sagaOf, songById } from "../data/album";
|
||||
import { sagaOf, songById, type LandingId, type PlaceId } from "../data/album";
|
||||
import { createStore, parseState } from "./state";
|
||||
import { mountPanel } from "./panel/panel";
|
||||
import { mountTimeline } from "./timeline/timeline";
|
||||
import { createWorld } from "./world/world";
|
||||
import { createCurtain } from "./frieze/curtain";
|
||||
import { createController, type ControllerCurtain } from "./controller";
|
||||
|
||||
mountCredits();
|
||||
|
||||
const $ = (id: string) => document.getElementById(id)!;
|
||||
const store = createStore(parseState(location.search));
|
||||
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const narrow = matchMedia("(max-width: 900px)");
|
||||
|
||||
// Placeholder caption until the voyage controller (plan task 7) takes over the stage.
|
||||
const caption = document.getElementById("caption")!;
|
||||
const render = () => {
|
||||
const world = createWorld($("world") as HTMLCanvasElement);
|
||||
const curtain = createCurtain($("frieze-layer"));
|
||||
document.body.dataset.webgl = world ? "yes" : "no";
|
||||
|
||||
/**
|
||||
* Without WebGL the frieze is the stage: it is dropped for the current saga and never lifted, so the
|
||||
* panel and the timeline still browse the whole album (spec 002, goal 9).
|
||||
*/
|
||||
const stageCurtain: ControllerCurtain = world
|
||||
? curtain
|
||||
: {
|
||||
drop: (sagaId: PlaceId, opts?: { landing?: LandingId }) => curtain.drop(sagaId, opts),
|
||||
fade: () => curtain.drop(sagaOf(songById(store.get().songId)!).id),
|
||||
lift: () => Promise.resolve(),
|
||||
groundY: () => curtain.groundY(),
|
||||
};
|
||||
if (!world) {
|
||||
void curtain.drop(sagaOf(songById(store.get().songId)!).id);
|
||||
const note = document.createElement("p");
|
||||
note.className = "stage__note";
|
||||
note.textContent = "3D needs WebGL — browsing with the frieze.";
|
||||
$("stage").append(note);
|
||||
}
|
||||
|
||||
const controller = createController({ store, world, curtain: stageCurtain, reducedMotion });
|
||||
|
||||
mountPanel($("panel"), store, { timelineHidden: () => narrow.matches });
|
||||
const timeline = mountTimeline($("timeline"), store);
|
||||
|
||||
/* ---------- caption card: saga · story time, song title, Back to sea in a landing ---------- */
|
||||
|
||||
const LANDING_NAME: Record<LandingId, string> = { cave: "The Cyclops' cave", arena: "God Games", hall: "The hall of Ithaca" };
|
||||
const caption = $("caption");
|
||||
function renderCaption() {
|
||||
const song = songById(store.get().songId)!;
|
||||
const landing = controller.landing();
|
||||
caption.innerHTML = `<p class="cap__eyebrow"></p><h1 class="cap__title"></h1>`;
|
||||
caption.querySelector(".cap__eyebrow")!.textContent = `${sagaOf(song).name} · ${song.storyTime.label}`;
|
||||
caption.querySelector(".cap__eyebrow")!.textContent = landing
|
||||
? `Ashore · ${LANDING_NAME[landing]}`
|
||||
: `${sagaOf(song).name} · ${song.storyTime.label}`;
|
||||
caption.querySelector(".cap__title")!.textContent = song.title;
|
||||
};
|
||||
store.subscribe(render);
|
||||
render();
|
||||
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);
|
||||
}
|
||||
}
|
||||
store.subscribe((s, prev) => { if (s.songId !== prev.songId) renderCaption(); });
|
||||
controller.onLandingChange(renderCaption);
|
||||
renderCaption();
|
||||
|
||||
/* ---------- play / previous / next, in the timeline's controls slot ---------- */
|
||||
|
||||
const controls = $("timeline").querySelector(".timeline__controls")!;
|
||||
controls.innerHTML = `
|
||||
<button type="button" class="vc vc--play" data-act="play" aria-pressed="false">Play voyage</button>
|
||||
<button type="button" class="vc" data-act="prev" aria-label="Previous song">←</button>
|
||||
<button type="button" class="vc" data-act="next" aria-label="Next song">→</button>`;
|
||||
const playBtn = controls.querySelector<HTMLButtonElement>("[data-act=play]")!;
|
||||
function step(delta: 1 | -1) {
|
||||
controller.stop();
|
||||
const track = songById(store.get().songId)!.track + delta;
|
||||
const next = [...document.querySelectorAll<HTMLElement>("#timeline [data-song]")]
|
||||
.map((el) => el.dataset.song!)
|
||||
.find((id) => songById(id)?.track === track);
|
||||
if (next) store.set({ songId: next });
|
||||
}
|
||||
controls.addEventListener("click", (e) => {
|
||||
const act = (e.target as HTMLElement).closest<HTMLElement>("[data-act]")?.dataset.act;
|
||||
if (act === "play") (controller.playing() ? controller.stop() : controller.play());
|
||||
else if (act === "prev") step(-1);
|
||||
else if (act === "next") step(1);
|
||||
});
|
||||
controller.onPlayingChange((p) => {
|
||||
playBtn.textContent = p ? "Pause voyage" : "Play voyage";
|
||||
playBtn.setAttribute("aria-pressed", String(p));
|
||||
});
|
||||
|
||||
/* ---------- keyboard: ←/→ songs, Esc closes the reading view or leaves a landing ---------- */
|
||||
|
||||
addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.closest("input, textarea, select, [contenteditable], [role=slider], #timeline, .lyrics")) return;
|
||||
if (e.key === "ArrowRight") { step(1); e.preventDefault(); }
|
||||
else if (e.key === "ArrowLeft") { step(-1); e.preventDefault(); }
|
||||
else if (e.key === "Escape") {
|
||||
if (store.get().read) store.set({ read: false });
|
||||
else if (controller.landing()) void controller.leaveLanding();
|
||||
}
|
||||
});
|
||||
for (const ev of ["pointerdown", "wheel"] as const) $("stage").addEventListener(ev, () => controller.stop(), { passive: true });
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
|
||||
addEventListener("resize", () => { world?.resize(); timeline.refresh(); });
|
||||
|
||||
@@ -106,3 +106,22 @@ button { font: inherit; color: inherit; }
|
||||
.panel { max-height: 50dvh; border-left: 0; border-top: 2px solid var(--glaze); }
|
||||
.shell[data-panel="closed"] .panel { max-height: 56px; }
|
||||
}
|
||||
|
||||
/* caption: Back to sea; the no-WebGL note */
|
||||
.cap__back {
|
||||
margin-top: 10px; padding: 6px 14px; border: 2px solid var(--glaze); border-radius: 999px;
|
||||
background: var(--glaze); color: var(--added-white); font-size: var(--fs-small); font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.cap__back:hover { background: var(--clay-deep); }
|
||||
.stage__note {
|
||||
position: absolute; z-index: 4; top: 22px; right: 24px; margin: 0; padding: 8px 12px;
|
||||
background: var(--vellum-hi); border: 2px solid var(--glaze); font-size: var(--fs-small);
|
||||
}
|
||||
/* voyage controls in the timeline's bar */
|
||||
.vc {
|
||||
min-width: 36px; height: 34px; padding: 0 12px; border: 1.5px solid var(--glaze); border-radius: 999px;
|
||||
background: transparent; color: var(--iron); font-size: var(--fs-small); font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.vc:hover { background: var(--vellum); }
|
||||
.vc--play { background: var(--glaze); color: var(--added-white); }
|
||||
.vc--play:hover { background: var(--clay-deep); }
|
||||
|
||||
+32
-1
@@ -29,11 +29,13 @@ function fakes() {
|
||||
sailTo: (id) => later(`sail:${id}`),
|
||||
setLanding: (id) => later(`land:${id}`),
|
||||
setRibbons: () => {},
|
||||
horizonFromFrieze: (y) => { log.push(`horizon:${y}`); },
|
||||
};
|
||||
const curtain: ControllerCurtain = {
|
||||
drop: (saga) => later(`drop:${saga}`),
|
||||
lift: () => later("lift"),
|
||||
fade: () => later("fade"),
|
||||
groundY: () => 500,
|
||||
};
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
@@ -57,7 +59,9 @@ test("rapid navigation ends at the last requested song with the curtain lifted",
|
||||
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");
|
||||
// covered → sail → landing → lift: the stage is uncovered last, already inside the arena
|
||||
expect(f.log.lastIndexOf("land:arena")).toBeLessThan(f.log.lastIndexOf("lift"));
|
||||
expect(f.log.at(-1)).toBe("lift");
|
||||
expect(ctl.busy()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -71,6 +75,19 @@ test("without a world the controller still follows the songs (frieze-only browsi
|
||||
expect(f.log).toContain("drop:cyclops");
|
||||
});
|
||||
|
||||
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();
|
||||
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 });
|
||||
@@ -80,3 +97,17 @@ test("reduced motion turns every scene change into a fade", async () => {
|
||||
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();
|
||||
expect(ctl.landing()).toBe("cave");
|
||||
const left = ctl.leaveLanding();
|
||||
await f.flush();
|
||||
await left;
|
||||
expect(f.log.at(-1)).toBe("land:null");
|
||||
expect(ctl.landing()).toBeNull();
|
||||
expect(store.get().songId).toBe("survive");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user