Files
Epic-The-Musical-Timeline/src/mockups/13-curtain-call/main.ts
T

262 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { songs } from "../../data/sample";
import { mountCredits } from "../../shared/credits";
import { chartSvg, hereAt } from "./chart";
import { cyclopsFrieze, GROUND_Y, MOUTH, troyFrieze, VB_H, VB_W } from "./frieze";
import { CurtainScene, ease, webglAvailable, type Look } from "./scene";
mountCredits();
/* ---------- beats ---------- */
interface Beat { short: string; eyebrow: string; caption: string; songId?: string }
const BEATS: Beat[] = [
{ short: "Frieze", eyebrow: "The Troy Saga · year 0", caption: "Troy falls", songId: "the-horse-and-the-infant" },
{ short: "To sea", eyebrow: "Between sagas · a year at sea", caption: "Out on the open sea", songId: "full-speed-ahead" },
{ short: "Landfall", eyebrow: "The Cyclops Saga · year 1", caption: "Landfall: Cyclops' Island" },
{ short: "Curtain", eyebrow: "The Cyclops Saga", caption: "Polyphemus", songId: "polyphemus" },
{ short: "Cave", eyebrow: "Inside the cave", caption: "Into the cave", songId: "survive" },
];
/** Seconds for the transition that leaves beat i (1-2 s each, per the brief). */
const SEG_DUR = [1.8, 2.0, 1.2, 1.6];
const HOLD = 3.4; // seconds a beat holds while playing
const LOOKS: Record<Look, string> = { 1: "Vellum throughout", 2: "Two worlds", 3: "Frieze-led" };
/* ---------- params ---------- */
const params = new URLSearchParams(location.search);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const clampBeat = (n: number) => Math.min(5, Math.max(1, Math.round(n) || 1));
const startBeat = clampBeat(Number(params.get("beat") ?? 1));
const freezeT = params.has("t") ? Math.min(1, Math.max(0, Number(params.get("t")) || 0)) : null;
let look: Look = (["1", "2", "3"].includes(params.get("look") ?? "") ? Number(params.get("look")) : 2) as Look;
/* ---------- DOM ---------- */
const app = document.getElementById("app")!;
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
app.innerHTML = `
<section class="stage" id="stage" aria-label="Curtain call: a scripted transition in five beats">
<div class="stage__three" id="three"></div>
<div class="stage__fallback" id="fallback" hidden><p>3D needs WebGL; the frieze beats still play.</p></div>
<div class="stage__paper" aria-hidden="true"></div>
<aside class="chart" id="chart" aria-label="Captain's table map">
<p class="chart__head"><span>General chart</span> The voyage from Troy</p>
${chartSvg()}
</aside>
<div class="frieze-host frieze-host--troy" id="troy">${troyFrieze()}</div>
<div class="frieze-host frieze-host--cyclops" id="cyclops">${cyclopsFrieze()}</div>
<div class="cut" id="cut" aria-hidden="true"></div>
<header class="cap" id="cap" aria-live="polite">
<p class="cap__eyebrow" id="cap-eyebrow"></p>
<h1 class="cap__title" id="cap-title"></h1>
<p class="cap__row"><span class="chip" id="cap-chip"></span>
<button type="button" class="back" id="back" hidden>Back to sea</button></p>
</header>
<nav class="bar" aria-label="Beats">
<button type="button" class="bar__play" id="play" aria-pressed="false">Play</button>
<button type="button" class="bar__step" id="prev" aria-label="Previous beat">←</button>
<ol class="dots" id="dots">
${BEATS.map((b, i) => `<li><button type="button" data-beat="${i + 1}"><span class="dots__n">${i + 1}</span><span class="dots__label">${esc(b.short)}</span></button></li>`).join("")}
</ol>
<button type="button" class="bar__step" id="next" aria-label="Next beat">→</button>
<div class="looks" role="group" aria-label="Look">
<span class="looks__label">Look</span>
${([1, 2, 3] as Look[]).map((l) => `<button type="button" data-look="${l}" title="${LOOKS[l]}">${l}</button>`).join('<span class="looks__sep" aria-hidden="true">·</span>')}
<span class="looks__name" id="look-name"></span>
</div>
</nav>
</section>`;
const $ = (id: string) => document.getElementById(id)!;
const stage = $("stage");
const troy = $("troy");
const cyclops = $("cyclops");
const chart = $("chart");
const cut = $("cut");
const capEl = $("cap");
const playBtn = $("play") as HTMLButtonElement;
const backBtn = $("back") as HTMLButtonElement;
const here = document.getElementById("chart-here")!;
const troyParts = {
figs: troy.querySelector<SVGGElement>(".f-figs")!,
top: troy.querySelector<SVGGElement>(".f-top")!,
bottom: troy.querySelector<SVGGElement>(".f-bottom")!,
horizon: troy.querySelector<SVGPathElement>(".horizon-line")!,
};
const cyParts = {
zoom: cyclops.querySelector<SVGGElement>(".f-zoom")!,
mouth: cyclops.querySelector<SVGPathElement>(".mouth-fill")!,
};
let scene: CurtainScene | null = null;
if (webglAvailable()) {
try { scene = new CurtainScene($("three"), reduced); } catch { scene = null; }
}
if (!scene) $("fallback").hidden = false;
/* ---------- state ---------- */
let p = freezeT !== null ? Math.max(0, startBeat - 2 + freezeT) : startBeat - 1;
let anim: { from: number; to: number; start: number; dur: number } | null = null;
let cutting = false;
let playing = false;
let holdUntil = 0;
let shownBeat = -1;
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
const easeIn = (x: number) => { const t = clamp01(x); return t * t * t; };
const easeInOut = (x: number) => { const t = clamp01(x); return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
/* ---------- render: everything is a function of p ---------- */
function renderDom() {
// Beat 1 → 2: the band peels open along its ground line, which becomes the 3D horizon.
const s0 = clamp01(p);
troy.hidden = s0 >= 1;
if (!troy.hidden) {
const flat = ease(s0 / 0.32);
troyParts.figs.setAttribute("transform", `translate(0,${GROUND_Y}) scale(1,${(1 - flat).toFixed(4)}) translate(0,${-GROUND_Y})`);
troyParts.figs.style.opacity = String(1 - ease((s0 - 0.1) / 0.24));
const part = easeInOut((s0 - 0.12) / 0.5);
troyParts.top.setAttribute("transform", `translate(0,${(-part * (GROUND_Y + 80)).toFixed(1)})`);
troyParts.bottom.setAttribute("transform", `translate(0,${(part * (VB_H - GROUND_Y + 80)).toFixed(1)})`);
troyParts.horizon.style.opacity = String(1 - ease((s0 - 0.5) / 0.22));
}
// The captain's-table map rests at sea (beats 2–3) and is put away when the curtain drops.
const chartIn = ease((p - 0.62) / 0.38) * (1 - ease((p - 2) / 0.35));
chart.style.opacity = chartIn.toFixed(3);
chart.style.transform = `translateY(${((1 - chartIn) * 24).toFixed(1)}px) rotate(-2deg)`;
chart.style.visibility = chartIn > 0.001 ? "visible" : "hidden";
const [hx, hy] = hereAt(ease((p - 1.1) / 0.8));
here.setAttribute("transform", `translate(${hx.toFixed(1)},${hy.toFixed(1)})`);
// Beat 3 → 4: the Cyclops frieze drops like a curtain. Beat 4 → 5: through the cave mouth.
cyclops.hidden = p <= 2;
if (!cyclops.hidden) {
const drop = easeInOut(p - 2);
cyclops.style.transform = `translateY(${((drop - 1) * 102).toFixed(2)}%)`;
const z = clamp01(p - 3);
cyParts.mouth.style.opacity = String(1 - ease(z / 0.28));
const scale = 1 + 15 * easeIn((z - 0.08) / 0.92);
cyParts.zoom.setAttribute("transform", `translate(${MOUTH.x},${MOUTH.y}) scale(${scale.toFixed(4)}) translate(${-MOUTH.x},${-MOUTH.y})`);
cyclops.style.opacity = String(1 - ease((z - 0.72) / 0.26));
}
stage.dataset.world = p < 0.5 ? "frieze" : p < 2.5 ? "sea" : p < 3.5 ? "frieze" : "cave";
// Captions dip and swap at each transition's midpoint.
const near = Math.round(p);
const d = Math.abs(p - near);
capEl.style.opacity = String(clamp01(1 - d * 3.2));
if (near !== shownBeat) {
shownBeat = near;
const b = BEATS[near]!;
$("cap-eyebrow").textContent = b.eyebrow;
$("cap-title").textContent = b.caption;
const song = b.songId ? songs.find((s) => s.id === b.songId) : undefined;
const chip = $("cap-chip");
chip.hidden = !song;
if (song) chip.innerHTML = `<span class="chip__n">${song.track}</span>${esc(song.title)}`;
backBtn.hidden = near !== 4;
}
const target = anim ? anim.to : Math.round(p);
document.querySelectorAll<HTMLButtonElement>("#dots button").forEach((btn, i) => {
if (i === target) btn.setAttribute("aria-current", "step");
else btn.removeAttribute("aria-current");
});
($("prev") as HTMLButtonElement).disabled = target <= 0;
($("next") as HTMLButtonElement).disabled = target >= 4;
}
/* ---------- navigation ---------- */
function go(to: number) {
if (freezeT !== null || cutting) return;
to = Math.min(4, Math.max(0, to));
if (anim) { p = anim.to; anim = null; }
if (to === p) return;
if (reduced || Math.abs(to - p) !== 1) crossCut(to);
else anim = { from: p, to, start: performance.now(), dur: SEG_DUR[Math.min(p, to)]! * 1000 };
}
/** Non-adjacent jumps (and every step under reduced motion) fade through the look's curtain colour. */
function crossCut(to: number) {
cutting = true;
cut.classList.add("is-on");
window.setTimeout(() => {
p = to;
renderDom();
cut.classList.remove("is-on");
window.setTimeout(() => { cutting = false; holdUntil = performance.now() + HOLD * 1000; }, 380);
}, reduced ? 260 : 300);
}
function setPlaying(on: boolean) {
playing = on;
playBtn.textContent = on ? "Pause" : "Play";
playBtn.setAttribute("aria-pressed", String(on));
if (on) {
if (Math.round(p) >= 4 && !anim) go(0);
holdUntil = performance.now() + 1200;
}
}
function setLook(l: Look) {
look = l;
stage.dataset.look = String(l);
document.body.dataset.look = String(l);
scene?.setLook(l);
document.querySelectorAll<HTMLButtonElement>(".looks button").forEach((b) => b.setAttribute("aria-pressed", String(Number(b.dataset.look) === l)));
$("look-name").textContent = LOOKS[l];
const u = new URL(location.href);
u.searchParams.set("look", String(l));
history.replaceState(null, "", u);
}
playBtn.addEventListener("click", () => setPlaying(!playing));
$("prev").addEventListener("click", () => { setPlaying(false); go((anim ? anim.to : p) - 1); });
$("next").addEventListener("click", () => { setPlaying(false); go((anim ? anim.to : p) + 1); });
backBtn.addEventListener("click", () => { setPlaying(false); go(1); });
document.querySelectorAll<HTMLButtonElement>("#dots button").forEach((b) =>
b.addEventListener("click", () => { setPlaying(false); go(Number(b.dataset.beat) - 1); }));
document.querySelectorAll<HTMLButtonElement>(".looks button").forEach((b) =>
b.addEventListener("click", () => setLook(Number(b.dataset.look) as Look)));
window.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight") { setPlaying(false); go((anim ? anim.to : p) + 1); }
else if (e.key === "ArrowLeft") { setPlaying(false); go((anim ? anim.to : p) - 1); }
else if (e.key === " " && !(e.target instanceof HTMLButtonElement)) { e.preventDefault(); setPlaying(!playing); }
});
/* ---------- loop ---------- */
const t0 = performance.now();
function frame(now: number) {
if (anim) {
const k = clamp01((now - anim.start) / anim.dur);
p = anim.from + (anim.to - anim.from) * k;
if (k >= 1) { p = anim.to; anim = null; holdUntil = now + HOLD * 1000; }
} else if (playing && !cutting && now >= holdUntil) {
if (Math.round(p) >= 4) setPlaying(false);
else go(Math.round(p) + 1);
}
renderDom();
scene?.render(p, (now - t0) / 1000 + 2.2);
requestAnimationFrame(frame);
}
setLook(look);
renderDom();
requestAnimationFrame(frame);
if (params.get("play") === "1" && freezeT === null) setPlaying(true);
/** The 3D horizon must start exactly on the frieze's painted ground line, wherever the slice-scaled SVG puts it. */
function syncHorizon() {
const w = stage.clientWidth, h = stage.clientHeight;
if (!w || !h) return;
const s = Math.max(w / VB_W, h / VB_H);
scene?.setHorizon(((GROUND_Y - VB_H / 2) * s + h / 2) / h);
}
new ResizeObserver(syncHorizon).observe(stage);
syncHorizon();