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
+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); },
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user