262 lines
12 KiB
TypeScript
262 lines
12 KiB
TypeScript
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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();
|