feat(app): 3D world, landings and friezes

Nine stylised places on one route, ship and ribbons, the cave, arena and
hall landings, nine saga friezes and the curtain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 15:08:11 +01:00
co-authored by Claude Opus 5.5
parent 27d624fa44
commit ac017787b5
17 changed files with 2865 additions and 2 deletions
+1 -1
View File
@@ -170,7 +170,7 @@ export function createCurtain(layer: HTMLElement): { drop(sagaId: PlaceId, opts?
```
Style per the design record (toon/ink, flat bands, small sun). Each place gets a distinct stylised silhouette (Troy walls; Cyclops' island + cave mouth; storm spiral at sea; Aeaea with a palace; Underworld dark gate; thunder sea with Scylla's cliffs; Olympus peak; Charybdis whirlpool; Ithaca with palace). Landings: cave (port 17's), arena (Athena vs. Odysseus as black-figure cut-outs on a sandy ring with columns), hall (Ithaca hall with a bow and a hearth). Friezes: one per saga from the shared kit; reuse 17's Troy and Cyclops friezes.
- [ ] Steps: failing tests — `PLACES.length===9` in saga order; `stops().length===40`, `s` strictly increasing, each stop within 1.5×radius of its place; `friezeSvg(id)` contains the saga name and every song title of that saga (escaped) for all 9 → implement → `bun test` pass → screenshots of each place, each landing and each frieze → commit `feat(app): 3D world, landings and friezes`.
- [x] Steps: failing tests — `PLACES.length===9` in saga order; `stops().length===40`, `s` strictly increasing, each stop within 1.5×radius of its place; `friezeSvg(id)` contains the saga name and every song title of that saga (escaped) for all 9 → implement → `bun test` pass → screenshots of each place, each landing and each frieze → commit `feat(app): 3D world, landings and friezes`.
### Task 7: Voyage controller and integration
+215
View File
@@ -0,0 +1,215 @@
// The curtain call (design record idea 2): a saga's frieze drops over the stage like a curtain, then lifts
// by parting along its painted ground line, which is where the 3D horizon sits (the world is told with
// horizonFromFrieze(groundY())). Dropped for a landing, it lifts instead by zooming through the doorway.
// fade() covers the stage in the curtain's colour for long jumps. Every call cancels the running one
// (its promise resolves at once) and starts from what is on screen, so rapid navigation never sticks.
// With prefers-reduced-motion every move is a 200ms crossfade.
import type { LandingId, PlaceId } from "../../data/album";
import { DOOR_SAGA, GROUND_Y, VB_FIT_H, VB_H, VB_W, VB_Y, friezeLayout, friezeSvg } from "./sagas";
export interface Curtain {
/** Drop the saga's frieze over the stage; resolves when it covers the stage. */
drop(sagaId: PlaceId, opts?: { landing?: LandingId }): Promise<void>;
/** Uncover the stage: part along the ground line, zoom through the doorway (after a landing drop), or fade out a fade(). */
lift(): Promise<void>;
/** Cover the stage in the curtain colour (for jumps across several sagas); lift() uncovers it. */
fade(): Promise<void>;
/** The frieze's ground line, in px from the top of the layer: where the 3D horizon must sit as it lifts. */
groundY(): number;
}
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
const ease = (x: number) => { const t = clamp01(x); return t * t * (3 - 2 * t); };
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; };
const DUR = { drop: 1200, split: 1500, zoom: 1600, fadeIn: 450, fadeOut: 650, reduced: 200 };
export function createCurtain(layer: HTMLElement): Curtain {
const doc = layer.ownerDocument;
const box = doc.createElement("div");
box.className = "fz-box";
box.setAttribute("aria-hidden", "true");
const host = doc.createElement("div");
host.className = "fz-host";
host.hidden = true;
const cover = doc.createElement("div");
cover.className = "fz-cover";
cover.hidden = true;
layer.append(box, host, cover);
const reduced = () => !!doc.defaultView?.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const now = () => doc.defaultView?.performance?.now() ?? Date.now();
const raf = (f: () => void) => {
const w = doc.defaultView;
if (w?.requestAnimationFrame) w.requestAnimationFrame(() => f());
else setTimeout(f, 16);
};
let saga: PlaceId | null = null;
let landing: LandingId | null = null;
/** 0 = above the stage, 1 = covering it. */
let drop = 0;
let coverK = 0;
let parts: { zoom: SVGGElement | null; top: SVGGElement | null; bottom: SVGGElement | null; figs: SVGGElement | null; line: SVGElement | null; doors: SVGElement[]; masked: SVGGElement | null; mask: string } =
{ zoom: null, top: null, bottom: null, figs: null, line: null, doors: [], masked: null, mask: "" };
/* ---------- the running animation; a new call cancels it ---------- */
let token = 0;
let finish: (() => void) | null = null;
function animate(dur: number, frame: (k: number) => void): Promise<void> {
finish?.();
const mine = ++token;
return new Promise((resolve) => {
finish = () => { finish = null; resolve(); };
const t0 = now();
const step = () => {
if (mine !== token) return;
const k = dur > 0 ? clamp01((now() - t0) / dur) : 1;
frame(k);
if (k >= 1) finish?.();
else raf(step);
};
step();
});
}
const cancel = () => { token++; finish?.(); };
/* ---------- layout ---------- */
function fit(): { s: number; oy: number } {
const w = box.clientWidth, h = box.clientHeight;
if (!w || !h) return { s: 0, oy: 0 };
const s = Math.min(w / VB_W, h / VB_FIT_H);
return { s, oy: box.offsetTop + (h - VB_FIT_H * s) / 2 };
}
/** How far up the frieze must sit to be fully above the stage (its glaze runs 600 units below the band). */
const travel = () => { const f = fit(); return f.oy + (VB_H + 600 - VB_Y) * f.s + 24 || layer.clientHeight || 1000; };
function mount(id: PlaceId) {
if (saga === id && parts.zoom) return;
saga = id;
host.innerHTML = friezeSvg(id);
const q = <T extends Element>(sel: string) => host.querySelector<T>(sel);
parts = {
zoom: q<SVGGElement>(".f-zoom"), top: q<SVGGElement>(".f-top"), bottom: q<SVGGElement>(".f-bottom"),
figs: q<SVGGElement>(".f-figs"), line: q<SVGElement>(".horizon-line"), doors: [...host.querySelectorAll<SVGElement>(".door-fill")],
masked: q<SVGGElement>("g[mask]"), mask: q<SVGGElement>("g[mask]")?.getAttribute("mask") ?? "",
};
host.dataset.saga = id;
}
/** Put every part back where it is painted. */
function reset() {
parts.zoom?.removeAttribute("transform");
parts.top?.removeAttribute("transform");
parts.bottom?.removeAttribute("transform");
parts.figs?.removeAttribute("transform");
if (parts.figs) parts.figs.style.opacity = "";
if (parts.line) parts.line.style.opacity = "";
parts.doors.forEach((d) => (d.style.opacity = ""));
if (parts.masked && parts.mask) parts.masked.setAttribute("mask", parts.mask);
host.style.opacity = "";
}
const place = () => { host.style.transform = drop >= 1 ? "" : `translateY(${((drop - 1) * travel()).toFixed(1)}px)`; };
const showCover = () => { cover.hidden = coverK <= 0; cover.style.opacity = String(coverK); };
function hideFrieze() {
host.hidden = true;
drop = 0;
landing = null;
reset();
place();
}
/* ---------- the three moves ---------- */
async function dropFn(id: PlaceId, opts: { landing?: LandingId } = {}): Promise<void> {
const wantDoor = opts.landing && DOOR_SAGA[opts.landing] === id ? opts.landing : null;
const covered = !host.hidden && drop >= 1 && saga === id;
cancel();
if (saga !== id || host.hidden) { mount(id); reset(); }
else if (!covered) reset();
landing = wantDoor;
host.hidden = false;
if (covered) { reset(); place(); host.style.opacity = "1"; return; }
if (reduced()) {
drop = 1;
place();
const from = host.style.opacity === "" ? 0 : Number(host.style.opacity);
await animate(DUR.reduced, (k) => { host.style.opacity = String(from + (1 - from) * k); });
} else {
host.style.opacity = "1";
const from = drop;
await animate(DUR.drop * (1 - from), (k) => { drop = from + (1 - from) * easeInOut(k); place(); });
}
if (saga === id && !host.hidden && drop >= 1) {
// once covered, any fade() underneath is no longer needed
coverK = 0;
showCover();
}
}
async function liftFn(): Promise<void> {
cancel();
if (host.hidden && coverK <= 0) return;
if (reduced()) {
const h0 = host.hidden ? 0 : Number(host.style.opacity || 1), c0 = coverK;
await animate(DUR.reduced, (k) => {
host.style.opacity = String(h0 * (1 - k));
coverK = c0 * (1 - k);
showCover();
});
if (host.style.opacity === "0") hideFrieze();
return;
}
if (coverK > 0) {
// a fade(): the frieze (if any) goes at once, the colour fades out over the new scene
hideFrieze();
const c0 = coverK;
await animate(DUR.fadeOut, (k) => { coverK = c0 * (1 - ease(k)); showCover(); });
return;
}
const door = landing ? friezeLayout(saga!).door : null;
if (door) {
// zoom through the doorway into the landing (13/17's cave zoom)
await animate(DUR.zoom, (z) => {
parts.doors.forEach((d) => (d.style.opacity = String(1 - ease(z / 0.28))));
const scale = 1 + 15 * easeIn((z - 0.08) / 0.92);
parts.zoom?.setAttribute("transform", `translate(${door.x},${door.y}) scale(${scale.toFixed(4)}) translate(${-door.x},${-door.y})`);
host.style.opacity = String(1 - ease((z - 0.72) / 0.26));
if (z >= 1) hideFrieze();
});
return;
}
// part along the ground line, which is the horizon: figures sink into the line, the halves slide apart
// the doorway stays shut: no hole travels with the parting halves
parts.doors.forEach((d) => (d.style.opacity = "0"));
parts.masked?.removeAttribute("mask");
await animate(DUR.split, (s0) => {
const flat = ease(s0 / 0.24);
parts.figs?.setAttribute("transform", `translate(0,${GROUND_Y}) scale(1,${(1 - flat).toFixed(4)}) translate(0,${-GROUND_Y})`);
if (parts.figs) parts.figs.style.opacity = String(1 - ease((s0 - 0.08) / 0.18));
const part = easeInOut((s0 - 0.1) / 0.55);
parts.top?.setAttribute("transform", `translate(0,${(-part * (GROUND_Y + 700)).toFixed(1)})`);
parts.bottom?.setAttribute("transform", `translate(0,${(part * (VB_H - GROUND_Y + 700)).toFixed(1)})`);
if (parts.line) parts.line.style.opacity = String(1 - ease((s0 - 0.5) / 0.3));
if (s0 >= 1) hideFrieze();
});
}
async function fadeFn(): Promise<void> {
cancel();
const c0 = coverK;
cover.hidden = false;
await animate((reduced() ? DUR.reduced : DUR.fadeIn) * (1 - c0), (k) => { coverK = c0 + (1 - c0) * k; showCover(); });
}
function groundY(): number {
const f = fit();
return f.oy + (GROUND_Y - VB_Y) * f.s;
}
return { drop: dropFn, lift: liftFn, fade: fadeFn, groundY };
}
+296
View File
@@ -0,0 +1,296 @@
// The black-figure figure kit for the saga friezes. Copied from mockup 17's figures.ts (via 04 and 13;
// mockups are never imported) and extended with the props the nine sagas need: tridents, bows, wings,
// long hair, pigs, cattle, trees, columns, cliffs, a whirlpool and Scylla's heads.
// Glaze is `fill: var(--glaze)` (.ink); "incised" detail is scratched back to the clay (.inc).
// Every vignette shares one 300-unit-tall panel space with the ground line at GROUND.
export const PANEL_H = 300;
export const GROUND = 284;
/** Escapes text for SVG/HTML markup, apostrophes included. */
export const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
export const g = (t: string, body: string) => `<g transform="${t}">${body}</g>`;
export const inc = (d: string, w = 1.6) => `<path class="inc" d="${d}" stroke-width="${w}"/>`;
export const ink = (d: string) => `<path class="ink" d="${d}"/>`;
/** A limb: a glaze stroke over a wider clay stroke, so it reads as incised where it crosses glaze. */
export const limb = (d: string, w = 6) =>
`<path class="limb-edge" d="${d}" stroke-width="${w + 3}"/><path class="limb" d="${d}" stroke-width="${w}"/>`;
type Arms = "spear" | "open" | "down" | "raise" | "reach" | "mourn" | "club" | "throw" | "bow" | "bolt";
export interface Person {
x: number; y?: number; s?: number; f?: 1 | -1; arms?: Arms;
helmet?: boolean; shield?: boolean; robe?: boolean; cyclops?: boolean; kneel?: boolean;
beard?: boolean; infant?: boolean; cup?: boolean;
/** Long hair down the back (women, goddesses). */
hair?: boolean; wings?: boolean; crown?: boolean; trident?: boolean; bag?: boolean; staff?: boolean;
}
export function person(p: Person): string {
const { x, y = GROUND, s = 1, f = 1, arms = "down" } = p;
const lift = p.kneel ? 36 : 0;
let legs = "";
if (p.kneel) {
legs = limb("M4,-44 L34,-40 L36,-2", 8) + limb("M-2,-44 L-24,-4 L-4,-2", 8);
} else if (!p.robe) {
legs = limb("M-2,-80 L-10,-40 L-24,-2 L-14,-2", 8) + limb("M4,-80 L14,-40 L30,-2 L40,-2", 8);
}
let upper = "";
if (p.wings) {
upper += ink("M-8,-150 Q-60,-196 -78,-150 Q-62,-152 -66,-132 Q-50,-138 -50,-118 Q-34,-126 -10,-118 Z");
upper += inc("M-14,-142 Q-44,-160 -66,-150 M-14,-130 Q-38,-142 -58,-132", 1.4);
}
if (p.hair) upper += ink("M-8,-176 Q-20,-150 -14,-128 L-2,-132 Q-6,-152 2,-168 Z");
// the back arm sits behind the torso
if (arms === "open") upper += limb("M-8,-148 L-36,-176 L-44,-196");
if (arms === "mourn") upper += limb("M-8,-148 L-14,-120 L-6,-104");
if (arms === "bow") upper += limb("M-8,-148 L6,-150 L18,-152");
upper += ink("M-3,-160 h8 v10 h-8 Z");
upper += ink("M-13,-154 L15,-154 L9,-104 L-5,-104 Z");
if (p.robe) {
upper += ink("M-9,-108 L11,-108 L28,-2 L-24,-2 Z");
upper += inc("M1,-100 L2,-8 M-5,-96 L-14,-8 M7,-96 L18,-8 M-20,-30 L24,-30");
} else {
upper += ink("M-8,-108 L10,-108 L20,-74 L-18,-74 Z");
upper += inc("M1,-104 L1,-77 M-5,-100 L-10,-77 M7,-100 L12,-77");
}
upper += inc("M-9,-146 L11,-146 M-2,-150 L1,-112");
// head
upper += `<circle class="ink" cx="2" cy="-168" r="11"/>`;
upper += ink("M10,-173 L18,-166 L10,-162 Z");
if (p.beard) upper += ink("M-2,-162 L13,-162 L5,-146 Z") + inc("M2,-158 L5,-150", 1.2);
if (p.cyclops) {
upper += `<circle class="clay" cx="7" cy="-171" r="5"/><circle class="ink" cx="8.5" cy="-171" r="2.4"/>`;
} else {
upper += inc("M5,-171 h5", 1.4);
}
if (p.helmet) {
upper += ink("M-12,-166 A14 14 0 0 1 15,-170 L15,-160 L9,-160 L9,-166 Z");
upper += ink("M-18,-178 Q-8,-214 24,-194 Q4,-200 -8,-176 Z");
upper += inc("M-10,-166 L13,-168 M4,-176 L4,-166");
} else if (p.crown) {
upper += ink("M-9,-176 L-7,-190 L-2,-180 L2,-194 L6,-180 L11,-190 L13,-176 Z");
} else if (!p.cyclops) {
upper += inc("M-6,-176 Q2,-182 10,-176", 1.3);
}
if (p.shield) {
upper += `<circle class="ink" cx="12" cy="-124" r="32"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="26" stroke-width="1.6"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="7" stroke-width="1.6"/>`;
}
// front arm
switch (arms) {
case "spear":
upper += limb("M10,-148 L30,-132 L40,-150");
upper += limb("M-4,-52 L72,-222", 3.5);
upper += p.trident
? ink("M72,-222 L80,-246 L74,-224 Z M64,-226 L58,-248 L70,-228 Z M80,-218 L96,-238 L82,-214 Z") + limb("M62,-228 L84,-214", 3)
: ink("M72,-222 L66,-244 L80,-226 Z");
break;
case "open": upper += limb("M10,-148 L40,-178 L48,-198"); break;
case "raise": upper += limb("M10,-148 L22,-182 L26,-214"); break;
case "reach": upper += limb("M10,-148 L36,-140 L56,-146"); break;
case "mourn": upper += limb("M10,-148 L26,-136 L12,-172"); break;
case "club":
upper += limb("M10,-148 L30,-130 L40,-142");
upper += limb("M18,-96 L66,-214", 10) + ink("M60,-218 Q66,-236 78,-214 Z");
break;
case "throw":
upper += limb("M10,-148 L-6,-182 L-12,-214");
upper += `<circle class="ink" cx="-16" cy="-232" r="18"/>` + inc("M-26,-238 Q-16,-246 -6,-236 M-24,-226 l10,2", 1.4);
break;
case "bow":
upper += limb("M10,-148 L38,-150 L60,-152");
upper += `<path class="limb" d="M54,-206 Q86,-152 54,-98" stroke-width="4.5"/>`;
upper += `<path class="rig" d="M54,-206 L18,-152 L54,-98"/>` + `<path class="rig" d="M18,-152 L92,-152" stroke-width="2.2"/>`;
upper += ink("M92,-152 L82,-157 L84,-152 L82,-147 Z");
break;
case "bolt":
upper += limb("M10,-148 L22,-182 L26,-210");
upper += g("translate(14,-262) scale(0.62)", ink("M0,0 L22,0 L10,34 L24,34 L-6,92 L2,48 L-12,48 Z"));
break;
default: upper += limb("M10,-148 L16,-104");
}
if (p.staff) upper += limb("M14,-100 L22,-236", 3.5) + `<circle class="ink" cx="22" cy="-240" r="6"/>`;
if (p.infant) {
upper += `<ellipse class="ink" cx="58" cy="-150" rx="7.5" ry="17" transform="rotate(-14 58 -150)"/><circle class="ink" cx="63" cy="-174" r="7"/>`;
upper += inc("M52,-158 l12,-3 M53,-148 l12,-3 M54,-138 l11,-3", 1.3);
}
if (p.cup) {
upper += ink("M48,-162 h26 q-2,12 -13,12 q-11,0 -13,-12 Z M60,-150 v5 h-5 h10");
upper += inc("M52,-158 h18", 1.2);
}
if (p.bag) {
upper += ink("M54,-146 Q46,-138 50,-116 Q58,-96 76,-104 Q88,-118 76,-138 Q70,-146 60,-146 Z M55,-150 h8 l-2,6 h-5 Z");
upper += inc("M58,-130 q8,6 16,0 M56,-118 q10,6 20,-2", 1.3);
}
const body = legs + (lift ? g(`translate(0,${lift})`, upper) : upper);
return g(`translate(${x},${y}) scale(${f * s},${s})`, body);
}
export function trojanHorse(x: number, s: number): string {
let b = "";
b += ink("M-92,-32 h176 v12 h-176 Z");
for (const lx of [-60, -42, 30, 48]) b += limb(`M${lx},-112 L${lx},-30`, 10);
b += limb("M-80,-150 Q-108,-128 -98,-88", 6);
b += ink("M-84,-164 h150 q10,0 10,12 v36 q0,14 -14,14 h-132 q-14,0 -14,-14 v-36 q0,-12 12,-12 Z");
b += ink("M40,-156 L60,-236 L88,-230 L68,-140 Z");
b += ink("M58,-238 Q70,-256 100,-244 L124,-208 Q120,-196 106,-202 L84,-224 Z M62,-246 L68,-266 L76,-244 Z");
b += inc("M-76,-146 H62 M-76,-130 H62 M-76,-116 H62 M-40,-160 V-104 M0,-160 V-104 M36,-160 V-104");
b += inc("M62,-228 l6,-6 l2,8 l6,-6 l2,8 l6,-6 M50,-196 l6,-6 l2,8 l6,-6", 1.4);
b += `<circle class="inc" cx="90" cy="-232" r="3" stroke-width="1.6"/>`;
b += inc("M-6,-150 h28 v22 h-28 Z", 1.4);
for (const wx of [-66, 56]) {
b += `<circle class="ink" cx="${wx}" cy="-12" r="13"/><circle class="inc" cx="${wx}" cy="-12" r="8" stroke-width="1.4"/>`;
b += inc(`M${wx - 8},-12 h16 M${wx},-20 v16`, 1.2);
}
return g(`translate(${x},${GROUND}) scale(${s})`, b);
}
export function ship(x: number, y: number, s: number, o: { sail?: boolean; tilt?: number; f?: 1 | -1; broken?: boolean } = {}): string {
const f = o.f ?? 1;
let b = "";
for (let i = 0; i < 9; i++) {
const ox = -96 + i * 24;
b += `<path class="oar" d="M${ox},-44 L${ox - 18},6" />`;
}
if (o.broken) {
b += limb("M0,-50 L0,-150", 5) + limb("M0,-150 L40,-196", 5);
} else {
b += `<path class="rig" d="M-84,-218 L-130,-60 M84,-218 L120,-48 M0,-232 L150,-90"/>`;
b += limb("M0,-50 L0,-236", 5);
if (o.sail) {
b += ink("M-78,-222 h156 v104 q-78,-18 -156,0 Z");
let grid = "";
for (let gx = -58; gx < 78; gx += 20) grid += `M${gx},-220 V-122 `;
grid += "M-76,-196 H76 M-76,-172 H76 M-76,-148 H76";
b += inc(grid, 1.3);
}
b += limb("M-86,-222 L86,-222", 4);
}
for (let i = 0; i < 8; i++) b += `<circle class="ink" cx="${-84 + i * 24}" cy="-62" r="6.5"/>`;
b += ink("M-160,-104 Q-150,-52 -108,-30 L118,-30 L178,-36 L132,-46 L140,-84 L128,-86 Q122,-54 100,-54 L-100,-54 Q-140,-58 -150,-104 Z");
b += inc("M-112,-40 H120 M-118,-48 H104", 1.4);
b += `<circle class="clay" cx="122" cy="-44" r="4.5"/><circle class="ink" cx="123" cy="-44" r="2"/>`;
return g(`translate(${x},${y}) rotate(${o.tilt ?? 0}) scale(${f * s},${s})`, b);
}
export function waves(x0: number, x1: number, y: number, size = 1): string {
let d = "";
const step = 36 * size;
for (let x = x0; x < x1 - step / 2; x += step) {
d += `M${x},${y} C${x + 8 * size},${y - 22 * size} ${x + 34 * size},${y - 24 * size} ${x + 34 * size},${y - 8 * size} C${x + 34 * size},${y} ${x + 22 * size},${y + 2 * size} ${x + 20 * size},${y - 6 * size} `;
}
return `<path class="wave" d="${d}" stroke-width="${4 * size}"/><path class="wave" d="M${x0},${y + 2} H${x1}" stroke-width="3"/>`;
}
export function stele(x: number): string {
return g(`translate(${x},${GROUND})`,
ink("M-36,0 v-12 h72 v12 Z M-24,-12 V-150 L0,-178 L24,-150 V-12 Z") +
inc("M-18,-146 H18 M-14,-60 H14 M-14,-48 H14 M-14,-36 H14") +
`<circle class="inc" cx="0" cy="-112" r="12" stroke-width="1.6"/><circle class="inc" cx="0" cy="-112" r="5" stroke-width="1.2"/>`);
}
export const bolt = (x: number, y: number, s: number) =>
g(`translate(${x},${y}) scale(${s})`, ink("M0,0 L22,0 L10,34 L24,34 L-6,92 L2,48 L-12,48 Z"));
export function sheep(x: number, s: number, f: 1 | -1 = 1): string {
let b = "";
b += limb("M-14,-14 L-16,0", 4) + limb("M-4,-14 L-3,0", 4) + limb("M12,-14 L13,0", 4) + limb("M20,-14 L22,0", 4);
b += ink("M-24,-30 Q-26,-46 -10,-48 Q2,-58 14,-48 Q30,-50 28,-32 Q32,-16 16,-14 L-14,-14 Q-30,-14 -24,-30 Z");
b += inc("M-14,-38 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0 M-16,-26 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0", 1.3);
b += ink("M26,-40 L40,-44 Q46,-38 40,-30 L28,-30 Z");
b += `<circle class="clay" cx="38" cy="-39" r="1.6"/>`;
return g(`translate(${x},${GROUND}) scale(${f * s},${s})`, b);
}
/** Circe's work: a pig, snout forward, curly tail. */
export function pig(x: number, s: number, f: 1 | -1 = 1): string {
let b = "";
b += limb("M-16,-12 L-18,0", 5) + limb("M-6,-12 L-5,0", 5) + limb("M12,-12 L13,0", 5) + limb("M20,-12 L22,0", 5);
b += ink("M-30,-26 Q-30,-46 -6,-48 Q22,-50 30,-36 L42,-34 Q46,-28 42,-22 L30,-20 Q22,-10 -6,-10 Q-30,-10 -30,-26 Z");
b += ink("M22,-46 L30,-58 L32,-44 Z");
b += `<path class="limb" d="M-30,-30 q-10,-2 -8,-10 q2,-6 6,-2" stroke-width="2.4"/>`;
b += `<circle class="clay" cx="30" cy="-38" r="1.8"/>` + inc("M40,-30 v4", 1.4) + inc("M-14,-22 Q4,-16 18,-24", 1.3);
return g(`translate(${x},${GROUND}) scale(${f * s},${s})`, b);
}
/** Helios' cattle: a broad bull with lyre horns. */
export function bull(x: number, s: number, f: 1 | -1 = 1): string {
let b = "";
b += limb("M-30,-24 L-32,0", 7) + limb("M-16,-24 L-14,0", 7) + limb("M22,-24 L24,0", 7) + limb("M34,-24 L36,0", 7);
b += ink("M-44,-46 Q-44,-70 -14,-72 L30,-72 Q48,-70 50,-52 L62,-60 Q74,-58 72,-44 L60,-36 Q50,-22 30,-22 L-30,-22 Q-44,-24 -44,-46 Z");
b += `<path class="limb" d="M-44,-54 q-12,10 -8,30" stroke-width="3"/>`;
b += `<path class="limb" d="M62,-60 q-4,-14 6,-20 M66,-58 q10,-10 18,-6" stroke-width="3.2"/>`;
b += `<circle class="clay" cx="64" cy="-50" r="2"/>` + inc("M-30,-60 Q0,-52 34,-60 M-24,-36 Q4,-30 30,-38", 1.4);
return g(`translate(${x},${GROUND}) scale(${f * s},${s})`, b);
}
/** A tree: Calypso's palm or Ithaca's olive. */
export function tree(x: number, s: number, kind: "palm" | "olive"): string {
let b = "";
if (kind === "palm") {
b += limb("M0,0 Q-6,-80 6,-170", 9);
for (let i = -3; i <= 3; i++) b += `<ellipse class="ink" cx="6" cy="-200" rx="7" ry="36" transform="rotate(${i * 28} 6 -170)"/>`;
b += inc("M-2,-30 h8 M-3,-60 h8 M-2,-90 h8 M0,-120 h8 M3,-150 h7", 1.4);
} else {
b += limb("M0,0 Q-10,-50 4,-100 M2,-90 Q-24,-110 -34,-128 M4,-100 Q26,-112 34,-134", 8);
b += ink("M-58,-130 Q-64,-170 -30,-176 Q-16,-206 16,-196 Q50,-200 54,-166 Q72,-140 44,-124 Q20,-112 -4,-122 Q-40,-108 -58,-130 Z");
b += inc("M-40,-150 q8,-6 14,0 M-10,-170 q8,-6 14,0 M20,-156 q8,-6 14,0 M-24,-132 q8,-6 14,0 M28,-136 q8,-6 14,0", 1.4);
}
return g(`translate(${x},${GROUND}) scale(${s})`, b);
}
/** A Doric column, base on the ground line. */
export function column(x: number, h: number, w = 26): string {
const half = w / 2;
let b = ink(`M${-half - 6},0 h${w + 12} v-8 h${-w - 12} Z`);
b += ink(`M${-half},-8 L${-half + 2},${-h + 14} h${w - 4} L${half},-8 Z`);
b += ink(`M${-half - 8},${-h + 14} h${w + 16} v-8 h${-w - 16} Z M${-half - 4},${-h + 6} h${w + 8} v-6 h${-w - 8} Z`);
b += inc(`M${-half / 2},-12 V${-h + 18} M0,-12 V${-h + 18} M${half / 2},-12 V${-h + 18}`, 1.3);
return g(`translate(${x},${GROUND})`, b);
}
/** A jagged sea cliff rising from the ground line. */
export function cliff(x: number, w: number, h: number, f: 1 | -1 = 1): string {
const b = ink(`M0,0 L0,${-h * 0.7} L${w * 0.18},${-h} L${w * 0.42},${-h * 0.86} L${w * 0.56},${-h * 0.62} L${w * 0.8},${-h * 0.5} L${w},0 Z`) +
inc(`M${w * 0.14},${-h * 0.62} q${w * 0.1},${-h * 0.12} ${w * 0.2},${-h * 0.1} M${w * 0.3},${-h * 0.34} q${w * 0.16},${-h * 0.08} ${w * 0.3},0 M${w * 0.62},${-h * 0.4} q${w * 0.1},${h * 0.04} ${w * 0.16},${h * 0.16}`, 1.8);
return g(`translate(${x},${GROUND}) scale(${f},1)`, b);
}
/** Scylla: long necks out of a cave in the cliff, each ending in a snapping head. */
export function scyllaHeads(x: number, y: number, s: number): string {
let b = "";
const necks: [string, number, number, number][] = [
["M0,0 Q40,-60 90,-40", 90, -40, -10], ["M0,0 Q60,-10 110,24", 110, 24, 20], ["M0,0 Q20,-110 70,-110", 70, -110, -24], ["M0,0 Q70,40 120,80", 120, 80, 34],
];
for (const [d, hx, hy, r] of necks) {
b += limb(d, 9);
b += g(`translate(${hx},${hy}) rotate(${r})`, ink("M-6,-9 L22,-6 L30,-1 L16,2 L28,7 L-6,9 Z") + `<circle class="clay" cx="8" cy="-3" r="2"/>`);
}
return g(`translate(${x},${y}) scale(${s})`, b);
}
/** Charybdis: a spiral whirlpool seen from the side, as stacked scrolls. */
export function whirlpool(cx: number, y: number, r: number): string {
let d = "";
for (let k = 0; k <= 64; k++) {
const a = (k / 64) * Math.PI * 6;
const rr = r * (1 - k / 70);
d += `${k ? "L" : "M"}${(cx + Math.cos(a) * rr).toFixed(1)},${(y - 30 + Math.sin(a) * rr * 0.32).toFixed(1)}`;
}
return `<path class="wave" d="${d}" stroke-width="4"/>` + ink(`M${cx - 10},${y - 34} L${cx},${y - 6} L${cx + 10},${y - 34} Z`) + waves(cx - r - 20, cx + r + 20, y, 1.1);
}
export function palmette(cx: number, cy: number, flip = false): string {
let leaves = "";
for (let i = -3; i <= 3; i++) {
leaves += `<ellipse class="ink" cx="0" cy="-26" rx="5.5" ry="18" transform="rotate(${i * 22})"/>`;
}
const volutes = `<path class="vol" d="M0,0 C-10,10 -28,8 -28,-4 C-28,-12 -18,-14 -16,-6 M0,0 C10,10 28,8 28,-4 C28,-12 18,-14 16,-6"/>`;
return g(`translate(${cx},${cy}) scale(1,${flip ? -1 : 1})`, leaves + `<circle class="ink" cx="0" cy="-2" r="7"/>` + volutes);
}
/** A shade of the dead: a robed, veiled figure with mourning arms. */
export const shade = (x: number, s: number, f: 1 | -1 = 1) => person({ x, s, f, arms: "mourn", robe: true, hair: true });
+300
View File
@@ -0,0 +1,300 @@
// One frieze per saga: the saga's name in a painted column, then one vignette per song with its title
// inscribed above it, on the chrome of mockup 17 (palmette neck, meander, tongues, wave scroll, rays).
// The Troy and Cyclops vignettes are 17's; the rest are composed from the same kit. The three landing
// sagas have a doorway (cave mouth, arena gate, palace door) masked out of the frieze, so the landing's 3D
// scene shows through it as the curtain zooms in.
import { sagas, songsInSaga } from "../../data/album";
import type { LandingId, PlaceId } from "../../data/album";
import {
GROUND, PANEL_H, bolt, bull, cliff, column, esc, g, inc, ink, palmette, person, pig, scyllaHeads, shade,
sheep, ship, stele, tree, trojanHorse, waves, whirlpool,
} from "./kit";
export const VB_W = 1440;
export const VB_H = 850;
/** Band layout (stage units): top border, painted field (panels are 300 units tall, drawn at SCALE), bottom border. */
const BAND_TOP = 146;
const FIELD_Y = 222;
const SCALE = 1.24;
/** Where the painted ground line falls: also the 3D horizon when the curtain lifts. */
export const GROUND_Y = FIELD_Y + GROUND * SCALE;
const NECK_H = 96;
const REG_H = 78;
const BOT_Y = GROUND_Y + REG_H;
const BAND_BOT = BOT_Y + 100;
/** The cropped viewBox: the band plus a margin of glaze. */
export const VB_Y = BAND_TOP - NECK_H - 20;
export const VB_FIT_H = BAND_BOT + 22 - VB_Y;
/** Inscriptions: font size in frieze units (CSS sets the same), line height, and an estimate of a capital's advance. */
export const INSCR_SIZE = 28;
const LINE_H = 30;
const CHAR_W = 18;
/** Which saga frieze has the doorway for each landing. */
export const DOOR_SAGA: Record<LandingId, PlaceId> = { cave: "cyclops", arena: "wisdom", hall: "ithaca" };
interface Door { path: string; glow: LandingId }
interface Vignette { songId: string; w: number; art: string; door?: Door }
/* ---------- the chrome (from 17) ---------- */
function defs(id: string): string {
const meander = (key: string, y: number) =>
`<pattern id="${id}-${key}" y="${y}" width="28" height="28" patternUnits="userSpaceOnUse"><path class="mean" d="M0 25.2H28M4.2 25.2V3H24V20.3H9.8V8.4H18.2V14.7" /></pattern>`;
return `<defs>
${meander("mt", BAND_TOP + 14)}${meander("mb", BOT_Y + 8)}
<pattern id="${id}-tongues" y="${BAND_TOP + 50}" width="18" height="22" patternUnits="userSpaceOnUse">
<path class="ink" d="M2 0H16V13A7 7 0 0 1 2 13Z" />
</pattern>
<pattern id="${id}-chain" x="0" y="${BAND_TOP - NECK_H + 10}" width="84" height="78" patternUnits="userSpaceOnUse">
<path class="vol" d="M0 39 Q10.5 62 21 62 Q31.5 62 42 39 Q52.5 16 63 16 Q73.5 16 84 39"/>
${g("translate(21,60) scale(0.66)", palmette(0, 0))}${g("translate(63,18) scale(0.66)", palmette(0, 0, true))}
</pattern>
<pattern id="${id}-scroll" x="0" y="${GROUND_Y + 14}" width="56" height="52" patternUnits="userSpaceOnUse">
<path class="scroll" d="M0 44 C20 44 34 36 34 20 C34 9 24 5 18 11 C13 16 18 23 24 19" />
<path class="scroll" d="M34 20 C34 34 44 44 56 44" />
</pattern>
<pattern id="${id}-rays" y="${BOT_Y + 46}" width="32" height="54" patternUnits="userSpaceOnUse">
<path class="ink" d="M2 54L16 6L30 54Z" />
</pattern>
<radialGradient id="${id}-glow" cx="0.5" cy="0.92" r="0.75">
<stop offset="0" class="glow-a"/><stop offset="0.55" class="glow-b"/><stop offset="1" class="glow-c"/>
</radialGradient>
</defs>`;
}
/** Split at the ground line into a top half and a bottom half, so the lift can part them along the horizon. */
function chrome(id: string) {
const X0 = -600, W = VB_W + 1200;
const rule = (y: number) => `<path class="rule" d="M${X0} ${y}H${X0 + W}"/>`;
const top = `<g class="f-top">
<rect class="f-surround" x="${X0}" y="-600" width="${W}" height="${GROUND_Y + 600}"/>
<rect class="f-band" x="${X0}" y="${BAND_TOP - NECK_H}" width="${W}" height="${GROUND_Y - BAND_TOP + NECK_H}"/>
${rule(BAND_TOP - NECK_H + 4)}
<rect x="${X0}" y="${BAND_TOP - NECK_H + 10}" width="${W}" height="78" fill="url(#${id}-chain)"/>
${rule(BAND_TOP + 8)}
<rect x="${X0}" y="${BAND_TOP + 14}" width="${W}" height="28" fill="url(#${id}-mt)"/>
${rule(BAND_TOP + 46)}
<rect x="${X0}" y="${BAND_TOP + 50}" width="${W}" height="22" fill="url(#${id}-tongues)"/>
</g>`;
const bottom = `<g class="f-bottom">
<rect class="f-surround" x="${X0}" y="${GROUND_Y}" width="${W}" height="${VB_H - GROUND_Y + 600}"/>
<rect class="f-band" x="${X0}" y="${GROUND_Y}" width="${W}" height="${BAND_BOT - GROUND_Y}"/>
<rect x="${X0}" y="${GROUND_Y + 14}" width="${W}" height="52" fill="url(#${id}-scroll)"/>
${rule(BOT_Y + 2)}
<rect x="${X0}" y="${BOT_Y + 8}" width="${W}" height="28" fill="url(#${id}-mb)"/>
${rule(BOT_Y + 40)}
<rect x="${X0}" y="${BOT_Y + 46}" width="${W}" height="54" fill="url(#${id}-rays)"/>
</g>`;
return { top, bottom };
}
/** The painted saga column: palmettes framing the place's word of the saga's name ("UNDERWORLD"), written
* vertically (as in 04); the full name is the frieze's title. One word keeps it at 20 units (14px). */
function sagaColumn(name: string): string {
const short = name.replace(/^The /, "").replace(/ Saga$/, "").toUpperCase();
return g(`translate(22,${FIELD_Y}) scale(${SCALE})`,
`<path class="rule" d="M4,0 V${PANEL_H} M128,0 V${PANEL_H}"/>${palmette(66, 52)}${palmette(66, 248, true)}
<text class="saga-name" transform="translate(71,150) rotate(-90)" text-anchor="middle">${esc(short)}</text>`);
}
const COLUMN_W = 132 * SCALE + 22 + 14;
/** A narrow divider between two songs: a double rule with a run of dots, as between vase panels. */
function divider(x: number): string {
let dots = "";
for (let y = FIELD_Y + 16; y < GROUND_Y - 8; y += 22) dots += `<circle class="ink" cx="${x}" cy="${y}" r="3"/>`;
return `<path class="rule" d="M${x - 8} ${FIELD_Y}V${GROUND_Y}M${x + 8} ${FIELD_Y}V${GROUND_Y}"/>${dots}`;
}
const DIVIDER_W = 26;
/** Break an uppercase title into lines of at most `max` characters, at spaces. */
export function wrap(title: string, max: number): string[] {
const lines: string[] = [];
for (const word of title.split(" ")) {
const last = lines.at(-1);
if (last !== undefined && (last + " " + word).length <= max) lines[lines.length - 1] = last + " " + word;
else lines.push(word);
}
return lines;
}
/* ---------- doorways ---------- */
/** The cave mouth in Polyphemus' rock (17's caveRock, narrowed to one vignette). */
function caveDoor(cx: number): { art: string; door: string } {
const W = 66, H = 132, r = W + 40;
const door = `M${cx - W},${GROUND} V${GROUND - 70} Q${cx - W},${GROUND - H} ${cx},${GROUND - H} Q${cx + W},${GROUND - H} ${cx + W},${GROUND - 70} V${GROUND} Z`;
let art = ink(`M${cx - r - 30},${GROUND} Q${cx - r - 24},${GROUND - 120} ${cx - r + 16},${GROUND - 160} Q${cx - 50},${GROUND - 196} ${cx + 8},${GROUND - 190} Q${cx + r - 10},${GROUND - 198} ${cx + r + 14},${GROUND - 146} Q${cx + r + 34},${GROUND - 80} ${cx + r + 30},${GROUND} Z`);
art += `<path class="inc" d="${door}" stroke-width="5" transform="translate(${cx},${GROUND}) scale(1.07) translate(${-cx},${-GROUND})"/>`;
art += inc(`M${cx - r - 14},${GROUND - 90} q14,-24 36,-30 M${cx - r + 6},${GROUND - 140} q26,-18 54,-20 M${cx + r - 24},${GROUND - 160} q24,4 34,30 M${cx + r + 8},${GROUND - 60} q6,-24 3,-46 M${cx - 30},${GROUND - 178} q30,-6 64,2`, 2);
return { art, door };
}
/** The God Games gate: a stone pylon between two columns with an arched opening onto the arena. */
function gateDoor(cx: number): { art: string; door: string } {
const W = 62, H = 150;
const door = `M${cx - W},${GROUND} V${GROUND - H + W} A${W} ${W} 0 0 1 ${cx + W},${GROUND - H + W} V${GROUND} Z`;
let art = ink(`M${cx - 96},${GROUND} V${GROUND - 176} H${cx + 96} V${GROUND} Z`);
art += ink(`M${cx - 132},${GROUND - 186} H${cx + 132} V${GROUND - 200} H${cx - 132} Z`);
art += inc(`M${cx - 96},${GROUND - 40} H${cx - W - 6} M${cx + W + 6},${GROUND - 40} H${cx + 96} M${cx - 96},${GROUND - 84} H${cx - W - 6} M${cx + W + 6},${GROUND - 84} H${cx + 96} M${cx - 96},${GROUND - 128} H${cx - 50} M${cx + 50},${GROUND - 128} H${cx + 96} M${cx - 80},${GROUND - 166} H${cx + 80}`, 1.8);
art += `<path class="inc" d="${door}" stroke-width="4" transform="translate(${cx},${GROUND}) scale(1.08) translate(${-cx},${-GROUND})"/>`;
art += column(cx - 116, 186, 22) + column(cx + 116, 186, 22);
return { art, door };
}
/** Odysseus' palace: a façade under a pediment with the door to the hall. */
function palaceDoor(cx: number): { art: string; door: string } {
const W = 46, H = 128;
const door = `M${cx - W},${GROUND} V${GROUND - H} H${cx + W} V${GROUND} Z`;
let art = ink(`M${cx - 120},${GROUND} V${GROUND - 160} H${cx + 120} V${GROUND} Z`);
art += ink(`M${cx - 136},${GROUND - 160} H${cx + 136} L${cx},${GROUND - 204} Z`);
art += inc(`M${cx - 112},${GROUND - 166} H${cx + 112} M${cx - 96},${GROUND - 170} L${cx},${GROUND - 196} L${cx + 96},${GROUND - 170}`, 1.8);
for (const x of [cx - 100, cx - 70, cx + 70, cx + 100]) art += inc(`M${x},${GROUND - 6} V${GROUND - 150} M${x - 8},${GROUND - 150} h16`, 2.2);
art += `<path class="inc" d="${door}" stroke-width="4" transform="translate(${cx},${GROUND}) scale(1.08) translate(${-cx},${-GROUND})"/>`;
return { art, door };
}
/* ---------- the vignettes ---------- */
const P = person;
function vignettes(sagaId: PlaceId): Vignette[] {
switch (sagaId) {
case "troy": return [
{ songId: "the-horse-and-the-infant", w: 300, art: trojanHorse(100, 0.62) + P({ x: 238, f: -1, s: 0.85, arms: "reach", helmet: true, infant: true }) },
{ songId: "just-a-man", w: 220, art: P({ x: 76, s: 0.88, arms: "mourn", helmet: true, beard: true }) + `<path class="limb" d="M168,284 L180,104" stroke-width="3.5"/>` + ink("M180,104 L174,84 L188,100 Z") + `<circle class="ink" cx="160" cy="252" r="30"/><circle class="inc" cx="160" cy="252" r="24" stroke-width="1.6"/>` + inc("M147,239 l26,26 M173,239 l-26,26", 1.4) },
{ songId: "full-speed-ahead", w: 320, art: waves(0, 320, 290) + ship(160, 276, 0.72, { sail: true }) },
{ songId: "open-arms", w: 240, art: P({ x: 76, s: 0.86, arms: "open", beard: true }) + P({ x: 178, f: -1, s: 0.86, arms: "reach", robe: true }) },
{ songId: "warrior-of-the-mind", w: 250, art: P({ x: 70, s: 0.82, arms: "spear", helmet: true, shield: true, robe: true, hair: true }) + P({ x: 190, f: -1, s: 0.82, arms: "spear", helmet: true, shield: true, beard: true }) },
];
case "cyclops": {
const cave = caveDoor(150);
return [
{ songId: "polyphemus", w: 270, art: P({ x: 56, s: 0.66, arms: "reach", beard: true, cup: true }) + P({ x: 196, f: -1, s: 0.86, arms: "club", cyclops: true, beard: true }) },
{ songId: "survive", w: 300, art: cave.art + sheep(272, 0.62, -1), door: { path: cave.door, glow: "cave" } },
{ songId: "remember-them", w: 250, art: stele(125) + P({ x: 48, s: 0.82, arms: "mourn", kneel: true, beard: true }) + P({ x: 206, f: -1, s: 0.82, arms: "mourn", robe: true }) },
{ songId: "my-goodbye", w: 300, art: waves(130, 300, 290) + P({ x: 60, s: 0.8, arms: "throw", cyclops: true, beard: true }) + ship(232, 276, 0.42, { sail: true }) },
];
}
case "ocean": return [
{ songId: "storm", w: 290, art: bolt(36, 96, 0.8) + bolt(240, 104, 0.6) + ship(150, 262, 0.62, { tilt: -9 }) + waves(0, 290, 278, 1.2) + waves(14, 290, 296, 0.8) },
{ songId: "luck-runs-out", w: 270, art: ship(130, 268, 0.62, { broken: true, tilt: 7 }) + waves(0, 270, 284, 1.3) + waves(20, 270, 298, 0.8) },
{ songId: "keep-your-friends-close", w: 250, art: P({ x: 66, s: 0.84, arms: "reach", robe: true, beard: true, crown: true, bag: true }) + P({ x: 196, f: -1, s: 0.84, arms: "reach", beard: true }) },
{ songId: "ruthlessness", w: 290, art: P({ x: 84, s: 0.86, arms: "spear", trident: true, robe: true, beard: true, crown: true }) + ship(226, 276, 0.4) + waves(0, 290, 290, 1.1) },
];
case "circe": return [
{ songId: "puppeteer", w: 280, art: P({ x: 64, s: 0.84, arms: "raise", robe: true, hair: true, crown: true }) + pig(170, 0.85) + pig(240, 0.7, -1) },
{ songId: "wouldnt-you-like", w: 260, art: P({ x: 76, s: 0.82, arms: "open", wings: true, staff: true }) + P({ x: 196, f: -1, s: 0.82, arms: "reach", beard: true }) },
{ songId: "done-for", w: 250, art: P({ x: 66, s: 0.82, arms: "open", robe: true, hair: true }) + P({ x: 186, f: -1, s: 0.82, arms: "spear", helmet: true, beard: true }) },
{ songId: "there-are-other-ways", w: 250, art: P({ x: 66, s: 0.82, arms: "reach", robe: true, hair: true }) + palmette(126, 250) + P({ x: 190, f: -1, s: 0.82, arms: "reach", beard: true }) },
];
case "underworld": return [
{ songId: "the-underworld", w: 320, art: column(36, 200) + column(284, 200) + ink(`M14,${GROUND - 200} H306 V${GROUND - 214} H14 Z`) + shade(120, 0.7) + shade(170, 0.66, -1) + shade(220, 0.7, -1) },
{ songId: "no-longer-you", w: 250, art: P({ x: 70, s: 0.86, arms: "reach", robe: true, beard: true, staff: true }) + P({ x: 190, f: -1, s: 0.82, arms: "mourn", kneel: true, beard: true }) },
{ songId: "monster", w: 290, art: P({ x: 76, s: 0.88, arms: "spear", helmet: true, shield: true, beard: true }) + shade(200, 0.64, -1) + shade(256, 0.6, -1) },
];
case "thunder": return [
{ songId: "suffering", w: 300, art: ink(`M0,${GROUND} V${GROUND - 64} Q30,${GROUND - 92} 74,${GROUND - 70} L84,${GROUND} Z`) + P({ x: 38, y: GROUND - 74, s: 0.56, arms: "open", wings: true, hair: true, robe: true }) + ship(196, 276, 0.52, { sail: true }) + waves(90, 300, 290) },
{ songId: "different-beast", w: 260, art: P({ x: 76, s: 0.86, arms: "spear", helmet: true, shield: true, beard: true }) + P({ x: 206, f: -1, s: 0.72, arms: "reach", wings: true, hair: true, robe: true }) },
{ songId: "scylla", w: 310, art: cliff(0, 124, 196) + scyllaHeads(84, 150, 0.9) + ship(236, 276, 0.42) + waves(110, 310, 290) },
{ songId: "mutiny", w: 300, art: bull(66, 0.72) + P({ x: 170, s: 0.8, arms: "spear", helmet: true }) + P({ x: 254, f: -1, s: 0.8, arms: "spear", helmet: true, beard: true, shield: true }) },
{ songId: "thunder-bringer", w: 290, art: P({ x: 76, s: 0.8, arms: "bolt", crown: true, beard: true, robe: true }) + ship(214, 266, 0.44, { tilt: 12 }) + waves(110, 290, 290, 1.1) },
];
case "wisdom": {
const gate = gateDoor(150);
return [
{ songId: "legendary", w: 240, art: P({ x: 76, s: 0.8, arms: "spear" }) + P({ x: 184, f: -1, s: 0.82, arms: "raise", beard: true }) },
{ songId: "little-wolf", w: 250, art: P({ x: 66, s: 0.84, arms: "spear", robe: true, hair: true, helmet: true, shield: true }) + P({ x: 186, f: -1, s: 0.76, arms: "spear" }) },
{ songId: "well-be-fine", w: 240, art: P({ x: 66, s: 0.84, arms: "open", robe: true, hair: true, helmet: true }) + P({ x: 176, f: -1, s: 0.76, arms: "open" }) },
{ songId: "love-in-paradise", w: 270, art: tree(40, 0.78, "palm") + P({ x: 128, s: 0.82, arms: "reach", robe: true, hair: true }) + P({ x: 230, f: -1, s: 0.82, arms: "mourn", beard: true }) },
{ songId: "god-games", w: 300, art: gate.art, door: { path: gate.door, glow: "arena" } },
];
}
case "vengeance": return [
{ songId: "not-sorry-for-loving-you", w: 250, art: P({ x: 70, s: 0.82, arms: "mourn", robe: true, hair: true }) + P({ x: 176, s: 0.82, arms: "down", beard: true }) },
{ songId: "dangerous", w: 270, art: P({ x: 66, s: 0.8, arms: "reach", wings: true, staff: true }) + ship(196, 276, 0.42, { sail: true }) + waves(110, 270, 290) },
{ songId: "charybdis", w: 300, art: whirlpool(150, 288, 112) + ship(150, 226, 0.34, { tilt: -18 }) },
{ songId: "get-in-the-water", w: 270, art: P({ x: 70, s: 0.82, arms: "spear", beard: true }) + P({ x: 196, f: -1, s: 0.9, arms: "spear", trident: true, robe: true, crown: true, beard: true }) + waves(0, 270, 292, 1.1) },
{ songId: "six-hundred-strike", w: 290, art: ship(96, 276, 0.5, { sail: true }) + bolt(150, 104, 0.7) + P({ x: 232, f: -1, s: 0.86, arms: "spear", trident: true, robe: true, crown: true, beard: true }) + waves(0, 290, 292) },
];
case "ithaca": {
const palace = palaceDoor(150);
return [
{ songId: "the-challenge", w: 300, art: palace.art, door: { path: palace.door, glow: "hall" } },
{ songId: "hold-them-down", w: 290, art: P({ x: 64, s: 0.82, arms: "spear", helmet: true, shield: true, beard: true }) + P({ x: 166, s: 0.76, arms: "spear" }) + P({ x: 262, f: -1, s: 0.7, arms: "mourn", kneel: true }) },
{ songId: "odysseus", w: 270, art: P({ x: 60, s: 0.86, arms: "bow", beard: true }) + P({ x: 220, f: -1, s: 0.72, arms: "mourn", kneel: true, beard: true }) },
{ songId: "i-cant-help-but-wonder", w: 240, art: P({ x: 70, s: 0.84, arms: "open", beard: true }) + P({ x: 176, f: -1, s: 0.78, arms: "open" }) },
{ songId: "would-you-fall-in-love-with-me-again", w: 270, art: tree(135, 0.72, "olive") + P({ x: 48, s: 0.82, arms: "reach", beard: true }) + P({ x: 226, f: -1, s: 0.82, arms: "reach", robe: true, hair: true }) },
];
}
}
}
/* ---------- assembly ---------- */
export interface FriezeLayout {
/** Centre of the doorway in frieze units (viewBox space), when the saga has one. */
door: { x: number; y: number; landing: LandingId } | null;
}
function build(sagaId: PlaceId): { svg: string; layout: FriezeLayout } {
const saga = sagas.find((s) => s.id === sagaId)!;
const songs = songsInSaga(sagaId);
const vs = vignettes(sagaId);
const id = `fz-${sagaId}`;
const x0 = COLUMN_W;
const room = VB_W - 18 - x0 - (vs.length - 1) * DIVIDER_W;
const slot = room / vs.length;
let figs = sagaColumn(saga.name);
let doorMask = "";
let doorFill = "";
let door: FriezeLayout["door"] = null;
vs.forEach((v, i) => {
const song = songs.find((s) => s.id === v.songId);
if (!song) throw new Error(`frieze ${sagaId}: unknown song ${v.songId}`);
const sx = x0 + i * (slot + DIVIDER_W);
if (i) figs += divider(sx - DIVIDER_W / 2);
const k = Math.min(1, slot / (v.w * SCALE));
const left = sx + (slot - v.w * k * SCALE) / 2;
const t = `translate(${left.toFixed(1)},${FIELD_Y}) scale(${SCALE}) translate(0,${(GROUND * (1 - k)).toFixed(1)}) scale(${k.toFixed(4)})`;
figs += `<g class="f-song" data-song="${esc(song.id)}"><title>${esc(song.title)}</title>${g(t, v.art)}</g>`;
const lines = wrap(song.title.toUpperCase(), Math.floor(slot / CHAR_W));
const cx = sx + slot / 2;
figs += `<text class="inscr" x="${cx.toFixed(1)}" y="${FIELD_Y + 34}" text-anchor="middle">${lines
.map((l, j) => `<tspan x="${cx.toFixed(1)}" dy="${j ? LINE_H : 0}">${esc(l)}</tspan>`).join("")}</text>`;
if (v.door) {
doorMask += `<path d="${v.door.path}" transform="${t}" fill="#000"/>`;
doorFill += `<path class="door-fill door-fill--${v.door.glow}" d="${v.door.path}" transform="${t}" fill="url(#${id}-glow)"/>`;
// the door's centre: half-way up a vignette whose tallest door is ~140 units
door = { x: left + 150 * k * SCALE, y: FIELD_Y + (GROUND * (1 - k) + (GROUND - 62) * k) * SCALE, landing: v.door.glow };
}
});
const c = chrome(id);
const body = `${c.top}${c.bottom}<g class="f-figs">${figs}</g><path class="ground horizon-line" d="M-600 ${GROUND_Y}H${VB_W + 600}"/>`;
const masked = doorMask
? `<mask id="${id}-door" maskUnits="userSpaceOnUse" x="-610" y="-610" width="${VB_W + 1220}" height="${VB_H + 1220}">
<rect x="-610" y="-610" width="${VB_W + 1220}" height="${VB_H + 1220}" fill="#fff"/>${doorMask}
</mask><g mask="url(#${id}-door)">${body}</g>${doorFill}`
: body;
const svg = `<svg class="frieze frieze--${sagaId}" viewBox="0 ${VB_Y} ${VB_W} ${VB_FIT_H}" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">
<title>${esc(saga.name)}</title>
${defs(id)}
<g class="f-zoom">${masked}</g>
</svg>`;
return { svg, layout: { door } };
}
const cache = new Map<PlaceId, { svg: string; layout: FriezeLayout }>();
const built = (id: PlaceId) => {
let b = cache.get(id);
if (!b) cache.set(id, (b = build(id)));
return b;
};
/** The saga's frieze as an SVG string: the saga's name and its song titles as inscriptions. */
export function friezeSvg(sagaId: PlaceId): string {
return built(sagaId).svg;
}
export function friezeLayout(sagaId: PlaceId): FriezeLayout {
return built(sagaId).layout;
}
+290
View File
@@ -0,0 +1,290 @@
// The nine places of the voyage, each a distinct stylised silhouette in the toon/ink look: Troy's walls,
// the Cyclops' island with its cave mouth, a storm spiral at sea, Aeaea with Circe's palace, the dark gate
// of the Underworld, Scylla's cliffs under thunder, Olympus' peak, Charybdis' whirlpool, and Ithaca with
// Odysseus' palace. Each is built around its own origin with local +z facing the route.
import * as THREE from "three";
import type { PlaceId } from "../../data/album";
import { PAL, edgeLines, hash, inkBack, islandGeometry, outline, toon, flat, type IslandColors, type IslandOpts } from "./materials";
/** Charybdis' water: bands spiralling down into the funnel, drawn in the sea's flat inks. */
function whirlMaterial(time: { value: number }) {
return new THREE.ShaderMaterial({
side: THREE.DoubleSide,
uniforms: { uTime: time, uDeep: { value: new THREE.Color(PAL.deep) }, uMid: { value: new THREE.Color(PAL.mid) }, uLine: { value: new THREE.Color(PAL.contour) }, uDark: { value: new THREE.Color("#123f4e") } },
vertexShader: /* glsl */ `varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: /* glsl */ `
uniform float uTime; uniform vec3 uDeep; uniform vec3 uMid; uniform vec3 uLine; uniform vec3 uDark; varying vec3 vP;
void main(){
float r = length(vP.xz);
float a = atan(vP.z, vP.x) / 6.28318;
float band = fract(a * 6.0 + 30.0 / (r + 4.0) - uTime * 0.35);
vec3 c = mix(uMid, uDeep, step(0.5, band));
float fw = fwidth(band) * 1.5;
c = mix(c, uLine, smoothstep(0.0, fw, band) * (1.0 - smoothstep(0.07, 0.07 + fw, band)));
c = mix(c, uDark, smoothstep(-3.0, -7.0, vP.y));
gl_FragColor = vec4(c, 1.0);
#include <colorspace_fragment>
}`,
});
}
/** Materials shared by every place (built once per world, disposed with it). */
export function placeKit(time: { value: number }) {
return {
island: toon("#ffffff", { vertexColors: true }),
ink: inkBack(),
edge: edgeLines(0.4),
mouth: flat(PAL.ink, { side: THREE.DoubleSide }),
stone: toon(PAL.stone),
stoneDark: toon(PAL.stoneDark),
wall: toon(PAL.stone, { side: THREE.DoubleSide }),
clay: toon(PAL.clay),
wood: toon(PAL.wood),
green: toon("#4f6b34"),
cypress: toon("#34502e"),
olive: toon("#7d8a5a"),
basalt: toon(PAL.basalt),
storm: toon(PAL.storm),
stormDark: toon(PAL.stormDark),
cloud: toon("#f4eee2"),
bolt: flat("#fff1b8", { side: THREE.DoubleSide }),
spray: flat(PAL.contour, { side: THREE.DoubleSide, transparent: true, opacity: 0.85 }),
serpent: toon("#3f5a4c"),
gold: toon("#d9a441"),
void: flat("#0c0705", { side: THREE.DoubleSide }),
glow: flat("#7a4a8c", { side: THREE.DoubleSide }),
whirl: whirlMaterial(time),
};
}
export type PlaceKit = ReturnType<typeof placeKit>;
const GREEN: IslandColors = { sand: PAL.sand, rock: PAL.rock, top: PAL.top };
const LUSH: IslandColors = { sand: PAL.sand, rock: "#8f7a4a", top: "#4f6b34" };
const DARK: IslandColors = { sand: "#5d5560", rock: PAL.basalt, top: PAL.basaltTop };
const PEAK: IslandColors = { sand: PAL.sand, rock: "#9a8a72", top: PAL.snow };
const CLIFF: IslandColors = { sand: "#7c6a58", rock: "#8a7058", top: "#5e6b48" };
function island(k: PlaceKit, parent: THREE.Object3D, o: IslandOpts, cols: IslandColors, x: number, z: number, inkT = 0.4) {
const geo = islandGeometry(o, cols);
const m = outline(new THREE.Mesh(geo, k.island), inkT, k.ink);
m.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo, 32), k.edge));
m.position.set(x, 0, z);
parent.add(m);
return m;
}
function mesh(parent: THREE.Object3D, geo: THREE.BufferGeometry, mat: THREE.Material, x: number, y: number, z: number, ink?: { t: number; mat: THREE.Material }) {
const m = new THREE.Mesh(geo, mat);
m.position.set(x, y, z);
if (ink) outline(m, ink.t, ink.mat);
parent.add(m);
return m;
}
/** A small temple or palace: a stepped base, a row of columns, a roof with a pediment. */
function temple(k: PlaceKit, parent: THREE.Object3D, o: { w: number; d: number; h: number; cols: number; roof: THREE.Material; x: number; y: number; z: number }) {
const g = new THREE.Group();
const ink = { t: 0.18, mat: k.ink };
mesh(g, new THREE.BoxGeometry(o.w + 2, 1.2, o.d + 2), k.stoneDark, 0, 0.6, 0, ink);
mesh(g, new THREE.BoxGeometry(o.w * 0.86, o.h, o.d * 0.7), k.stone, 0, 1.2 + o.h / 2, -o.d * 0.08, ink);
const colGeo = new THREE.CylinderGeometry(0.45, 0.55, o.h, 6);
for (let i = 0; i < o.cols; i++) {
const x = -o.w / 2 + 0.8 + (i * (o.w - 1.6)) / (o.cols - 1);
mesh(g, colGeo, k.stone, x, 1.2 + o.h / 2, o.d / 2 - 0.4, ink);
}
mesh(g, new THREE.BoxGeometry(o.w + 1, 1, o.d + 1), k.stoneDark, 0, 1.7 + o.h, 0, ink);
const roof = new THREE.CylinderGeometry(1, 1, o.d + 1.2, 3);
roof.rotateX(Math.PI / 2);
roof.rotateZ(Math.PI);
roof.scale((o.w + 1.4) / 1.73, 1.6, 1);
mesh(g, roof, o.roof, 0, 2.2 + o.h + 0.8, 0, ink);
g.position.set(o.x, o.y, o.z);
parent.add(g);
return g;
}
function cloudBank(k: PlaceKit, parent: THREE.Object3D, mat: THREE.Material, puffs: [number, number, number, number][]) {
const geo = new THREE.IcosahedronGeometry(1, 1);
for (const [x, y, z, r] of puffs) {
const m = mesh(parent, geo, mat, x, y, z, { t: 0.06, mat: k.ink });
m.scale.set(r * 1.5, r * 0.62, r);
}
}
function boltMesh(k: PlaceKit, parent: THREE.Object3D, x: number, y: number, z: number, s: number) {
const sh = new THREE.Shape();
[[0, 0], [5, 0], [2, -8], [6, -8], [-2, -22], [0, -12], [-4, -12]].forEach(([a, b], i) => (i ? sh.lineTo(a!, b!) : sh.moveTo(a!, b!)));
sh.closePath();
const m = mesh(parent, new THREE.ShapeGeometry(sh), k.bolt, x, y, z);
m.scale.setScalar(s);
const edge = new THREE.LineSegments(new THREE.EdgesGeometry(m.geometry), k.edge);
m.add(edge);
return m;
}
/* ---------- the places ---------- */
function troy(k: PlaceKit, g: THREE.Group) {
// a low plateau with a ring of walls and towers, the wooden horse on the beach
const base = island(k, g, { radius: 30, height: 15, seed: 11, segments: 10 }, GREEN, 0, -4);
base.scale.set(1, 1, 0.9);
const ink = { t: 0.2, mat: k.ink };
const wall = new THREE.CylinderGeometry(14, 14.6, 5, 18, 1, true);
const w = mesh(g, wall, k.wall, 0, 11, -4);
w.add(new THREE.LineSegments(new THREE.EdgesGeometry(wall), k.edge));
const tower = new THREE.CylinderGeometry(2.3, 2.6, 9, 7);
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2 + 0.3;
mesh(g, tower, k.stoneDark, Math.sin(a) * 14.3, 12.5, -4 + Math.cos(a) * 14.3, ink);
}
mesh(g, new THREE.BoxGeometry(4, 4.4, 1.2), k.mouth, 0, 10.6, -4 + 14.7);
const roofs = new THREE.BoxGeometry(3.5, 3, 3.5);
for (let i = 0; i < 7; i++) mesh(g, roofs, i % 2 ? k.clay : k.stone, (hash(i, 1, 2) - 0.5) * 18, 13, -4 + (hash(2, i, 1) - 0.5) * 18, ink);
// the wooden horse on the shore
const horse = new THREE.Group();
mesh(horse, new THREE.BoxGeometry(5, 2.4, 2), k.wood, 0, 4, 0, ink);
for (const [lx, lz] of [[-1.8, -0.6], [-1.8, 0.6], [1.8, -0.6], [1.8, 0.6]] as const) mesh(horse, new THREE.BoxGeometry(0.5, 3, 0.5), k.wood, lx, 1.6, lz);
const neck = mesh(horse, new THREE.BoxGeometry(1.2, 4, 1.2), k.wood, 2.4, 6.4, 0, ink);
neck.rotation.z = -0.4;
mesh(horse, new THREE.BoxGeometry(2.4, 1.1, 1.1), k.wood, 3.6, 8.4, 0, ink);
horse.position.set(14, 0.6, 22);
horse.rotation.y = -0.6;
g.add(horse);
}
function cyclops(k: PlaceKit, g: THREE.Group) {
island(k, g, { radius: 26, height: 34, seed: 23 }, GREEN, 0, 0, 0.45);
island(k, g, { radius: 17, height: 22, seed: 41 }, GREEN, -20, -14, 0.4);
island(k, g, { radius: 11, height: 13, seed: 57 }, GREEN, 21, -6, 0.35);
// the cave mouth: a black arch facing the route
const arch = new THREE.Shape();
arch.moveTo(-6, -1); arch.lineTo(-6, 4); arch.absarc(0, 4, 6, Math.PI, 0, true); arch.lineTo(6, -1); arch.closePath();
mesh(g, new THREE.ShapeGeometry(arch, 8), k.mouth, 0, 0.6, 22);
}
function ocean(k: PlaceKit, g: THREE.Group) {
// a storm: a dark cloud bank wheeling over a spiral drawn on the sea, a bolt between them
const puffs: [number, number, number, number][] = [];
for (let i = 0; i < 9; i++) {
const a = (i / 9) * Math.PI * 2;
puffs.push([Math.cos(a) * 18, 34 + (i % 3) * 3, Math.sin(a) * 18, 7 + (i % 2) * 2]);
}
puffs.push([0, 40, 0, 10]);
cloudBank(k, g, k.stormDark, puffs);
cloudBank(k, g, k.storm, [[-10, 30, 14, 6], [12, 31, 10, 6], [4, 46, -6, 7]]);
const pts: THREE.Vector3[] = [];
for (let i = 0; i <= 120; i++) {
const a = (i / 120) * Math.PI * 5;
const r = 4 + (i / 120) * 26;
pts.push(new THREE.Vector3(Math.cos(a) * r, 1.4, Math.sin(a) * r));
}
const tube = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 160, 0.55, 3, false);
mesh(g, tube, k.spray, 0, 0, 0);
boltMesh(k, g, 4, 30, 6, 1.35);
boltMesh(k, g, -12, 30, -4, 1.1);
island(k, g, { radius: 5, height: 7, seed: 71 }, DARK, -24, -16, 0.3);
}
function circe(k: PlaceKit, g: THREE.Group) {
island(k, g, { radius: 30, height: 18, seed: 31, top: 11 }, LUSH, 0, -2, 0.45);
island(k, g, { radius: 14, height: 16, seed: 37 }, LUSH, 22, -8, 0.35);
temple(k, g, { w: 13, d: 8, h: 4.5, cols: 6, roof: k.glow, x: 0, y: 14.4, z: 0 });
const cone = new THREE.ConeGeometry(1.3, 7, 5);
for (let i = 0; i < 9; i++) {
const a = 0.5 + i * 0.55;
const r = 12 + hash(i, 4, 4) * 6;
mesh(g, cone, k.cypress, Math.cos(a) * r, 2 + 15 * (30 - r) / 19 + 2.5, Math.sin(a) * r - 2, { t: 0.12, mat: k.ink });
}
}
function underworld(k: PlaceKit, g: THREE.Group) {
island(k, g, { radius: 28, height: 26, seed: 47, segments: 7 }, DARK, 0, -6, 0.45);
island(k, g, { radius: 12, height: 18, seed: 53, segments: 6 }, DARK, -22, 2, 0.35);
// the gate of the dead: two black pillars, a lintel, a void with a cold glow at its heart
const ink = { t: 0.2, mat: k.ink };
const pillar = new THREE.BoxGeometry(3, 18, 3);
mesh(g, pillar, k.basalt, -6.5, 9, 18, ink);
mesh(g, pillar, k.basalt, 6.5, 9, 18, ink);
mesh(g, new THREE.BoxGeometry(18, 2.6, 3.6), k.basalt, 0, 19, 18, ink);
mesh(g, new THREE.PlaneGeometry(10, 17), k.void, 0, 8.6, 17.6);
const twig = new THREE.CylinderGeometry(0.15, 0.3, 7, 4);
for (let i = 0; i < 5; i++) {
const t = mesh(g, twig, k.mouth, -16 + i * 7, 8 + hash(i, 2, 2) * 6, 4 - hash(i, 5, 1) * 10);
t.rotation.z = (hash(i, 7, 7) - 0.5) * 0.8;
}
}
function thunder(k: PlaceKit, root: THREE.Group) {
const g = new THREE.Group();
g.position.x = 14;
root.add(g);
// Scylla's cliffs: two sheer crags with a strait between, necks reaching out of a cave; thunder above
island(k, g, { radius: 14, height: 36, seed: 61, segments: 6 }, CLIFF, -10, -10, 0.45);
island(k, g, { radius: 11, height: 27, seed: 67, segments: 6 }, CLIFF, 16, -22, 0.4);
mesh(g, new THREE.CircleGeometry(2.6, 10), k.mouth, -8, 12, 1.4);
const ink = { t: 0.14, mat: k.ink };
const necks: [number, number, number][] = [[-4, 21, 10], [3, 16, 13], [5, 8, 15], [-11, 15, 13]];
for (const [x, y, z] of necks) {
const curve = new THREE.CatmullRomCurve3([new THREE.Vector3(-8, 12, 1), new THREE.Vector3((x - 8) / 2, y + 4, (z + 1) / 2), new THREE.Vector3(x, y, z)]);
mesh(g, new THREE.TubeGeometry(curve, 16, 0.8, 5, false), k.serpent, 0, 0, 0, ink);
const head = mesh(g, new THREE.ConeGeometry(1.4, 3.6, 5), k.serpent, x, y, z, ink);
head.lookAt(new THREE.Vector3(x * 2, y - 4, z + 10));
head.rotateX(Math.PI / 2);
}
cloudBank(k, g, k.stormDark, [[18, 42, -40, 8], [30, 45, -34, 6], [6, 44, -44, 7]]);
boltMesh(k, g, 22, 37, -36, 1.2);
}
function wisdom(k: PlaceKit, g: THREE.Group) {
// Olympus: a snow-capped peak with a golden temple at the summit, ringed by cloud
island(k, g, { radius: 30, height: 40, seed: 79, segments: 9, top: 4 }, PEAK, 0, -18, 0.5);
temple(k, g, { w: 7, d: 5, h: 2.6, cols: 4, roof: k.gold, x: 0, y: 36.6, z: -18 });
const puffs: [number, number, number, number][] = [];
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
puffs.push([Math.cos(a) * 19, 17 + (i % 2) * 3, -18 + Math.sin(a) * 19, 4 + (i % 3)]);
}
cloudBank(k, g, k.cloud, puffs);
}
function vengeance(k: PlaceKit, g: THREE.Group) {
// Charybdis: a funnel cut into the sea (the sea has a hole here), spiral-striped, spray at the rim;
// the fig tree on its rock beside it
const funnel = new THREE.CylinderGeometry(26, 3, 7, 48, 8, true);
funnel.translate(0, -3.5, 0);
mesh(g, funnel, k.whirl, 0, 0.2, 0);
const rim = mesh(g, new THREE.TorusGeometry(26.5, 0.7, 4, 48), k.spray, 0, 0.6, 0);
rim.rotation.x = Math.PI / 2;
island(k, g, { radius: 8, height: 12, seed: 83 }, GREEN, -34, -6, 0.3);
mesh(g, new THREE.CylinderGeometry(0.4, 0.6, 6, 5), k.wood, -34, 11, -6);
mesh(g, new THREE.IcosahedronGeometry(3.4, 0), k.green, -34, 15, -6, { t: 0.15, mat: k.ink });
}
function ithaca(k: PlaceKit, g: THREE.Group) {
island(k, g, { radius: 32, height: 16, seed: 89, segments: 10, top: 14 }, GREEN, 0, -6, 0.45);
island(k, g, { radius: 15, height: 15, seed: 97 }, GREEN, -24, -2, 0.35);
temple(k, g, { w: 16, d: 10, h: 4.6, cols: 7, roof: k.clay, x: 1, y: 12.4, z: -2 });
const ink = { t: 0.18, mat: k.ink };
mesh(g, new THREE.BoxGeometry(7, 5, 7), k.stone, -9, 14.5, -10, ink);
mesh(g, new THREE.BoxGeometry(6, 4, 6), k.stone, 11, 14, -12, ink);
const puff = new THREE.IcosahedronGeometry(2.2, 0);
for (let i = 0; i < 8; i++) {
const a = 1.2 + i * 0.5;
mesh(g, puff, k.olive, Math.cos(a) * 20, 1 + 13 * 12 / 18 + 1.5, Math.sin(a) * 20 - 6, ink);
}
}
const BUILDERS: Record<PlaceId, (k: PlaceKit, g: THREE.Group) => void> = {
troy, cyclops, ocean, circe, underworld, thunder, wisdom, vengeance, ithaca,
};
/** A place's silhouette, built around its origin with local +z facing the route. */
export function buildPlace(id: PlaceId, k: PlaceKit): THREE.Group {
const g = new THREE.Group();
g.name = id;
BUILDERS[id](k, g);
return g;
}
/** Where a place cuts a hole in the sea (Charybdis), in local units: radius or 0. */
export const SEA_HOLE: Partial<Record<PlaceId, number>> = { vengeance: 26 };
+108
View File
@@ -0,0 +1,108 @@
// Landing: God Games (Wisdom Saga). Athena and Odysseus as black-figure cut-outs facing off on a sandy
// ring, a colonnade and tiers of stone seats behind, clouds of Olympus below the horizon.
import * as THREE from "three";
import type { Landing } from "../materials";
import { PAL, disposeTree, easeInOut, edgeLines, hash, inkBack, lerp, outline, skyDome, toon } from "../materials";
import { cutout } from "./figures";
export function buildArena(): Landing {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 16 / 9, 0.5, 1400);
const sky = skyDome(["#f3dfb6", "#e9d3b0", "#c9cfc0", "#9cbcc6"], PAL.sun, PAL.sunRing);
sky.uniforms.uSunDir.value.set(-0.5, 0.32, -0.8).normalize();
scene.add(sky.mesh);
scene.fog = new THREE.Fog("#efd9b0", 120, 520);
const sun = new THREE.DirectionalLight("#fff6e6", 2.3);
sun.position.set(-40, 80, 50);
scene.add(sun, new THREE.AmbientLight("#b9b4c2", 1.3));
const ink = inkBack(), edge = edgeLines(0.4);
const sand = toon("#e3c58c"), sandDark = toon("#c9a46a"), stone = toon(PAL.stone), stoneDark = toon(PAL.stoneDark);
const seats = toon("#d8c7a2", { side: THREE.DoubleSide }), cloud = toon("#f6f0e4");
const clay = new THREE.MeshBasicMaterial({ color: PAL.clay, side: THREE.DoubleSide });
const fig = { glaze: new THREE.MeshBasicMaterial({ color: "#16100c" }), clay, clayHull: new THREE.MeshBasicMaterial({ color: PAL.clay, side: THREE.BackSide }), eye: new THREE.MeshBasicMaterial({ color: PAL.cream }) };
// the ring: sand with a raked darker band and an inked boundary
const floor = new THREE.Mesh(new THREE.CircleGeometry(60, 40), sandDark);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
const ring = new THREE.Mesh(new THREE.CircleGeometry(30, 40), sand);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.02;
scene.add(ring);
const line = new THREE.Mesh(new THREE.RingGeometry(29.6, 30.6, 48), new THREE.MeshBasicMaterial({ color: PAL.ink }));
line.rotation.x = -Math.PI / 2;
line.position.y = 0.04;
scene.add(line);
// tiers of seats on the far half, and a colonnade in front of them
for (const [r, h] of [[40, 3], [44, 6], [48, 9], [52, 12]] as const) {
const geo = new THREE.CylinderGeometry(r, r, h, 48, 1, true, Math.PI * 0.55, Math.PI * 0.9);
geo.translate(0, h / 2, 0);
const m = new THREE.Mesh(geo, seats);
m.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo, 30), edge));
scene.add(m);
}
const colGeo = new THREE.CylinderGeometry(0.9, 1.1, 15, 8), capGeo = new THREE.BoxGeometry(3, 1, 3), baseGeo = new THREE.BoxGeometry(2.8, 0.8, 2.8);
for (let i = 0; i < 11; i++) {
const a = Math.PI * (0.12 + (i / 10) * 0.76);
const x = Math.cos(a) * 35, z = -Math.sin(a) * 35;
const c = outline(new THREE.Mesh(colGeo, stone), 0.14, ink);
c.position.set(x, 8.3, z);
const cap = outline(new THREE.Mesh(capGeo, stoneDark), 0.12, ink);
cap.position.set(x, 16.3, z);
const base = outline(new THREE.Mesh(baseGeo, stoneDark), 0.12, ink);
base.position.set(x, 0.4, z);
scene.add(c, cap, base);
}
// an architrave over the middle columns, with a terracotta frieze band
const beam = outline(new THREE.Mesh(new THREE.BoxGeometry(40, 2, 2.4), stoneDark), 0.14, ink);
beam.position.set(0, 17.8, -35.6);
const band = new THREE.Mesh(new THREE.BoxGeometry(40, 0.8, 0.2), clay);
band.position.set(0, 17.8, -34.3);
scene.add(beam, band);
// clouds of Olympus beyond
const puff = new THREE.IcosahedronGeometry(1, 1);
for (let i = 0; i < 14; i++) {
const a = Math.PI * (0.2 + (i / 13) * 0.6);
const r = 190 + hash(i, 2, 2) * 80;
const m = outline(new THREE.Mesh(puff, cloud), 0.04, ink);
m.position.set(Math.cos(a) * r * (i % 2 ? 1 : -1), -14 + hash(i, 3, 1) * 6, -Math.sin(a) * r);
const s = 10 + hash(1, i, 5) * 10;
m.scale.set(s * 1.6, s * 0.5, s);
scene.add(m);
}
// Athena and Odysseus, facing off across the ring
const athena = cutout({ robe: true, crest: true, hair: true, shield: true, spear: "thrust" }, fig);
athena.position.set(-9, 0, 0);
athena.scale.setScalar(0.5);
athena.rotation.y = 0.12;
const odysseus = cutout({ crest: true, beard: true, shield: true, spear: "raised" }, fig);
odysseus.position.set(9, 0, -1);
odysseus.scale.set(-0.5, 0.5, 0.5);
odysseus.rotation.y = -0.12;
scene.add(athena, odysseus);
const shadow = new THREE.MeshBasicMaterial({ color: "#8a6a3c", transparent: true, opacity: 0.45 });
for (const x of [-9, 9]) {
const s = new THREE.Mesh(new THREE.CircleGeometry(4, 20), shadow);
s.rotation.x = -Math.PI / 2;
s.scale.set(1.4, 0.6, 1);
s.position.set(x, 0.06, -0.5);
scene.add(s);
}
return {
scene, camera,
update(k, t) {
const e = easeInOut(k);
camera.position.set(lerp(0, 3, e), lerp(16, 9, e), lerp(70, 44, e));
camera.lookAt(lerp(0, 1, e), lerp(9, 11, e), 0);
// a slow sway, as if circling each other
athena.position.x = -9 + Math.sin(t * 0.7) * 0.6;
odysseus.position.x = 9 + Math.sin(t * 0.7 + 1.2) * 0.6;
},
dispose() { disposeTree(scene); },
};
}
+138
View File
@@ -0,0 +1,138 @@
// Landing: the Cyclops' cave (ported from mockup 17). A faceted, firelit dome; the fire as layered flat
// flames; Polyphemus as a black-figure cut-out incised in clay, with one great eye; his flock.
import * as THREE from "three";
import type { Landing } from "../materials";
import { disposeTree, easeInOut, fire, flicker, hash, lerp, outline, shape, strokeGeo, toon, v3 } from "../materials";
export function buildCave(): Landing {
const scene = new THREE.Scene();
scene.background = new THREE.Color("#140c08");
const camera = new THREE.PerspectiveCamera(54, 16 / 9, 0.3, 400);
const m = {
rock: toon("#7a4a2c", { side: THREE.BackSide }), floor: toon("#4a2c1a"), boulder: toon("#4a2c1a"),
ink: new THREE.MeshBasicMaterial({ color: "#1d140e", side: THREE.BackSide }), edge: new THREE.LineBasicMaterial({ color: "#1d140e", transparent: true, opacity: 0.55 }),
giant: new THREE.MeshBasicMaterial({ color: "#16100c" }), giantInc: new THREE.MeshBasicMaterial({ color: "#c46a33", side: THREE.DoubleSide }), giantHull: new THREE.MeshBasicMaterial({ color: "#c46a33", side: THREE.BackSide }),
eye: new THREE.MeshBasicMaterial({ color: "#f6ead0" }),
wool: toon("#efe6d2"), face: new THREE.MeshBasicMaterial({ color: "#1d140e" }),
fire: new THREE.MeshBasicMaterial({ color: "#f08a3a", side: THREE.DoubleSide }), core: new THREE.MeshBasicMaterial({ color: "#ffe08a", side: THREE.DoubleSide }),
};
// a faceted dome seen from inside, its facets inked
let dome: THREE.BufferGeometry = new THREE.IcosahedronGeometry(1, 2);
const dp = dome.getAttribute("position") as THREE.BufferAttribute;
for (let i = 0; i < dp.count; i++) {
const x = dp.getX(i), y = dp.getY(i), z = dp.getZ(i);
const n = 1 + (hash(Math.round(x * 40), Math.round(y * 40), Math.round(z * 40)) - 0.5) * 0.24;
dp.setXYZ(i, x * n, y * n, z * n);
}
dome = dome.toNonIndexed();
dome.computeVertexNormals();
const shell = new THREE.Mesh(dome, m.rock);
shell.add(new THREE.LineSegments(new THREE.EdgesGeometry(dome, 1), m.edge));
shell.scale.set(44, 30, 56);
shell.position.set(0, 4, -16);
scene.add(shell);
const floor = new THREE.Mesh(new THREE.CircleGeometry(70, 24), m.floor);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -9;
scene.add(floor);
for (let i = 0; i < 8; i++) {
const r = 1.6 + hash(i, 2, 9) * 3.2;
const b = new THREE.Mesh(new THREE.DodecahedronGeometry(r, 0), m.boulder);
const side = i % 2 ? 1 : -1;
b.position.set(side * (16 + hash(i, 4, 1) * 16), -9 + r * 0.45, -hash(1, i, 4) * 44 + 6);
scene.add(outline(b, 0.18, m.ink));
}
const f = fire({ fire: m.fire, core: m.core, log: m.face });
f.group.position.set(-12, -9, -30);
scene.add(f.group);
const fireLight = new THREE.PointLight(0xf08a3a, 900, 0, 1.7);
fireLight.position.set(-12, -2, -26);
scene.add(fireLight, new THREE.AmbientLight(0xffffff, 0.55));
// Polyphemus: one profile cut-out, facing his flock and the mouth, striding, club raised behind
const giant = new THREE.Group();
const D = 2.4;
const part = (s: THREE.Shape, segs = 12) => {
const geo = new THREE.ExtrudeGeometry(s, { depth: D, bevelEnabled: false, curveSegments: segs });
geo.translate(0, 0, -D / 2);
giant.add(outline(new THREE.Mesh(geo, m.giant), 0.15, m.giantHull));
};
part(shape([[-9.5, 0], [-5.2, 0], [-4.4, 1.2], [-1.6, 12.5], [-4.6, 13], [-7.4, 1.6], [-9.6, 1.2]]));
part(shape([[3.4, 0], [8.2, 0], [8.2, 1.3], [6.0, 1.6], [4.0, 12.8], [0.8, 12.4], [3.6, 1.4]]));
part(shape([[-5.6, 11.6], [5.2, 11.4], [4.2, 15.2], [3.4, 25.6], [1.6, 27.4], [-3.4, 27.4], [-4.8, 25.6], [-4.6, 15.2]]));
part(shape([[-4.2, 25.8], [-10.4, 19.4], [-11.6, 17.2], [-10.4, 16.4], [-8.4, 18.4], [-2.4, 22.6]]));
part(shape([[1.6, 27.0], [6.0, 31.4], [7.6, 34.6], [6.4, 35.6], [4.4, 33.0], [0.4, 24.4]]));
part(shape([[5.0, 33.6], [6.6, 32.8], [14.6, 47.4], [14.6, 49.8], [12.6, 50.6], [10.6, 48.6]]));
const head = new THREE.Shape();
head.moveTo(-0.8, 35.4);
head.quadraticCurveTo(-4.4, 35.4, -5.0, 32.8);
head.lineTo(-6.8, 30.6); head.lineTo(-5.2, 30.2); head.lineTo(-5.3, 28.8); head.lineTo(-6.0, 24.6);
head.lineTo(-2.0, 26.6); head.lineTo(1.0, 27.2);
head.quadraticCurveTo(3.2, 29.0, 2.8, 32.0);
head.quadraticCurveTo(2.4, 35.4, -0.8, 35.4);
part(head, 8);
const z = D / 2 + 0.03;
const inc = (pts: [number, number][], w = 0.26) => giant.add(new THREE.Mesh(strokeGeo(pts, w, z), m.giantInc));
inc([[-4.7, 16.2], [4.2, 16.0]]);
inc([[-5.4, 12.6], [-4.2, 13.6], [-3.0, 12.6], [-1.8, 13.6], [-0.6, 12.6], [0.6, 13.6], [1.8, 12.6], [3.0, 13.6], [4.4, 12.6]], 0.2);
inc([[-4.6, 6.6], [-3.2, 7.2]]);
inc([[3.0, 7.2], [4.6, 6.8]]);
inc([[-3.8, 24.0], [-0.6, 21.2], [2.4, 23.6]], 0.2);
inc([[-4.4, 28.6], [-5.0, 25.8]], 0.18);
inc([[-3.0, 28.6], [-3.4, 26.6]], 0.18);
inc([[-0.6, 28.4], [0.4, 31.6], [-0.2, 34.2]], 0.2);
inc([[-1.0, 33.8], [-2.8, 34.2], [-4.6, 33.2]], 0.3);
const eye = new THREE.Mesh(new THREE.CircleGeometry(1.15, 20), m.eye);
eye.position.set(-2.7, 31.9, z + 0.01);
const pupil = new THREE.Mesh(new THREE.CircleGeometry(0.55, 14), m.face);
pupil.position.set(-0.4, 0, 0.02);
eye.add(pupil);
giant.add(eye);
giant.position.set(7, -9, -34);
giant.scale.setScalar(0.72);
giant.rotation.y = -0.18;
scene.add(giant);
// the flock: wool puffs with black heads and legs
const bodyGeo = new THREE.IcosahedronGeometry(1.35, 1), puffGeo = new THREE.IcosahedronGeometry(0.8, 1);
const headGeo = new THREE.ConeGeometry(0.55, 1.4, 6), legGeo = new THREE.BoxGeometry(0.24, 1.1, 0.24);
for (let i = 0; i < 6; i++) {
const sheep = new THREE.Group();
const body = new THREE.Mesh(bodyGeo, m.wool);
body.scale.set(1.4, 1, 1);
sheep.add(outline(body, 0.06, m.ink));
for (const [px, py] of [[-0.8, 0.8], [0.3, 1.0], [-0.2, -0.1]] as const) {
const puff = new THREE.Mesh(puffGeo, m.wool);
puff.position.set(px, py, 0);
sheep.add(outline(puff, 0.05, m.ink));
}
const h = new THREE.Mesh(headGeo, m.face);
h.rotation.z = Math.PI / 2 + 0.4;
h.position.set(2.0, 0.35, 0);
sheep.add(h);
for (const [lx, lz] of [[-0.8, -0.45], [-0.8, 0.45], [0.8, -0.45], [0.8, 0.45]] as const) {
const leg = new THREE.Mesh(legGeo, m.face);
leg.position.set(lx, -1.15, lz);
sheep.add(leg);
}
sheep.position.set(-24 + i * 3.8 + hash(i, 3, 3) * 2, -7.4, -1 - hash(3, i, 3) * 9);
sheep.rotation.y = (hash(i, 9, 2) - 0.5) * 1.2 + (i % 2 ? Math.PI : 0);
scene.add(sheep);
}
const eyeBase = new THREE.Color("#f6ead0");
return {
scene, camera,
update(k, t) {
const e = easeInOut(k);
camera.position.copy(v3(lerp(0, 1, e), lerp(1, -1, e), lerp(44, 30, e)));
camera.lookAt(lerp(0, -4, e), lerp(0, 5, e), -40);
flicker(f.flames, t, camera);
fireLight.intensity = 820 * (0.9 + 0.1 * Math.sin(t * 11) * Math.sin(t * 4.3));
m.eye.color.copy(eyeBase).multiplyScalar(0.85 + 0.15 * Math.sin(t * 2));
},
dispose() { disposeTree(scene); },
};
}
+93
View File
@@ -0,0 +1,93 @@
// Black-figure cut-outs for the landings: flat glaze silhouettes, extruded a little, with a clay edge and
// clay incisions, as the giant in the cave (13/17). Built facing +x, about 38 units tall; mirror with
// scale.x = -1 to face the other way.
import * as THREE from "three";
import { outline, shape, strokeGeo } from "../materials";
export interface FigureMats { glaze: THREE.Material; clay: THREE.Material; clayHull: THREE.Material; eye: THREE.Material }
export interface FigureOpts { robe?: boolean; crest?: boolean; beard?: boolean; hair?: boolean; shield?: boolean; spear?: "thrust" | "raised"; bow?: boolean }
const D = 1.6;
/** A straight limb or shaft from a to b, w thick, as a closed outline. */
const bar = (ax: number, ay: number, bx: number, by: number, w: number): [number, number][] => {
const l = Math.hypot(bx - ax, by - ay) || 1, nx = (-(by - ay) / l) * w / 2, ny = ((bx - ax) / l) * w / 2;
return [[ax + nx, ay + ny], [bx + nx, by + ny], [bx - nx, by - ny], [ax - nx, ay - ny]];
};
export function cutout(o: FigureOpts, m: FigureMats): THREE.Group {
const g = new THREE.Group();
const part = (s: THREE.Shape, depth = D, zOff = 0) => {
const geo = new THREE.ExtrudeGeometry(s, { depth, bevelEnabled: false, curveSegments: 10 });
geo.translate(0, 0, -depth / 2 + zOff);
g.add(outline(new THREE.Mesh(geo, m.glaze), 0.14, m.clayHull));
};
const z = D / 2 + 0.03;
const inc = (pts: [number, number][], w = 0.22, dz = 0) => g.add(new THREE.Mesh(strokeGeo(pts, w, z + dz), m.clay));
if (o.robe) {
part(shape([[-5.4, 0], [6, 0], [3.8, 13], [3.4, 18], [3.9, 27.4], [-3.4, 27.6], [-2.8, 18], [-3.4, 13]]));
inc([[-4.6, 3], [5.2, 3]]);
inc([[-0.4, 4], [0, 16]], 0.18);
inc([[2.2, 4], [2.4, 16]], 0.18);
} else {
part(shape([[2, 0], [6.4, 0], [6.4, 1.1], [4.2, 1.3], [3.4, 9], [1.4, 15], [-1.2, 14.6], [1.2, 8.6], [2.2, 1.1]]));
part(shape([[-8.2, 0], [-4.4, 0], [-4.6, 1.2], [-2.6, 8], [0.8, 14.6], [-1.6, 15.2], [-5, 8.6], [-7.2, 1.6], [-8.4, 1.1]]));
part(shape([[-3.6, 13], [4, 13], [3, 18], [3.8, 27.4], [-3.4, 27.6], [-2.8, 18]]));
inc([[-3.4, 13.8], [-2.4, 14.8], [-1.2, 13.8], [0, 14.8], [1.2, 13.8], [2.4, 14.8], [3.6, 13.8]], 0.18);
inc([[2.8, 5.2], [4, 5.6]]);
inc([[-5, 5.4], [-3.8, 6]]);
}
inc([[-2.8, 18.4], [3.2, 18.4]]);
inc([[-2.4, 24.6], [0.4, 22.6], [3, 24.4]], 0.18);
// neck and head
part(shape(bar(0.4, 26.8, 0.8, 29.2, 2)));
const head = new THREE.Shape();
head.absarc(0.8, 30.6, 2.5, 0, Math.PI * 2, false);
part(head);
part(shape([[2.9, 31], [4.4, 30.2], [3, 29.6]])); // nose
if (o.beard) part(shape([[0.2, 29.2], [3.4, 29], [1.4, 26.4]]));
if (o.hair) part(shape([[-1.4, 32], [-3.8, 25.4], [-2, 25], [0.4, 29.6]]));
if (o.crest) {
part(shape([[-2, 30.4], [3.4, 30.8], [3.2, 33.6], [-2, 33.6]]));
part(shape([[-4.6, 33], [-2, 38.2], [4.4, 36.6], [1.6, 33.4]]));
inc([[-1.6, 31], [3, 31.2]], 0.18);
}
const eye = new THREE.Mesh(new THREE.CircleGeometry(0.42, 10), m.eye);
eye.position.set(2.2, 31, z + 0.01);
g.add(eye);
// arms and weapons
if (o.bow) {
part(shape(bar(2.6, 25.8, 10.6, 26.2, 1.4)));
part(shape(bar(-1, 25.6, 4.6, 26.4, 1.3)));
const bow = new THREE.Mesh(new THREE.TorusGeometry(6.4, 0.42, 5, 18, Math.PI * 0.82), m.glaze);
bow.rotation.z = -Math.PI * 0.41;
bow.position.set(6.2, 26.2, 0);
g.add(bow);
inc([[11, 31.6], [4.6, 26.4], [11, 20.6]], 0.14, 0.4);
} else if (o.spear === "thrust") {
part(shape(bar(2.6, 25.8, 8.6, 22.6, 1.4)));
part(shape([...bar(-12, 25.6, 22, 21.4, 0.5)]), 0.5, 0.9);
part(shape([[22, 22.4], [26.4, 20.9], [21.8, 20.4]]), 0.5, 0.9);
} else if (o.spear === "raised") {
part(shape(bar(2.6, 25.8, 5.4, 32, 1.4)));
part(shape(bar(-9, 26.6, 18, 36.4, 0.5)), 0.5, 0.9);
part(shape([[18, 36.9], [21.6, 39], [17.6, 35.6]]), 0.5, 0.9);
}
if (o.shield) {
const disc = new THREE.Shape();
disc.absarc(0, 0, 6.4, 0, Math.PI * 2, false);
const sg = new THREE.ExtrudeGeometry(disc, { depth: 0.8, bevelEnabled: false, curveSegments: 20 });
const sh = outline(new THREE.Mesh(sg, m.glaze), 0.14, m.clayHull);
sh.position.set(4.6, 19.6, D / 2 + 0.6);
g.add(sh);
const ring = (r: number, w: number) => {
const mesh = new THREE.Mesh(new THREE.RingGeometry(r - w / 2, r + w / 2, 28), m.clay);
mesh.position.set(4.6, 19.6, D / 2 + 1.45);
g.add(mesh);
};
ring(5.2, 0.24);
ring(1.4, 0.22);
}
return g;
}
+126
View File
@@ -0,0 +1,126 @@
// Landing: the hall of Odysseus' palace on Ithaca (The Challenge onwards). A megaron: plastered walls
// with a terracotta and black frieze band, red tapering columns around a round hearth with its fire, the
// great bow on its stand, and the row of twelve axes the arrow must pass through. Daylight at the door.
import * as THREE from "three";
import type { Landing } from "../materials";
import { PAL, disposeTree, easeInOut, edgeLines, fire, flicker, inkBack, lerp, outline, toon } from "../materials";
export function buildHall(): Landing {
const scene = new THREE.Scene();
scene.background = new THREE.Color("#1d140e");
const camera = new THREE.PerspectiveCamera(54, 16 / 9, 0.3, 400);
const ink = inkBack(), edge = edgeLines(0.5);
const plaster = toon("#e2cfa8", { side: THREE.BackSide }), floorMat = toon("#8a5a36"), tile = toon("#6e4428");
const red = toon("#a8432a"), glaze = toon(PAL.glaze), stone = toon(PAL.stoneDark), wood = toon("#5b3c20"), bronze = toon("#b8863a");
const clay = new THREE.MeshBasicMaterial({ color: PAL.clay }), black = new THREE.MeshBasicMaterial({ color: PAL.glaze });
const day = new THREE.MeshBasicMaterial({ color: "#fbe9c4" });
// the room
const W = 56, H = 26, L = 80;
const room = new THREE.Mesh(new THREE.BoxGeometry(W, H, L), plaster);
room.position.set(0, H / 2, -L / 2 + 30);
room.add(new THREE.LineSegments(new THREE.EdgesGeometry(room.geometry), edge));
scene.add(room);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(W, L), floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.set(0, 0.01, -L / 2 + 30);
scene.add(floor);
// a painted floor grid, as on Mycenaean floors
const tileGeo = new THREE.PlaneGeometry(7.4, 7.4);
for (let x = -3; x <= 3; x++) for (let z = 0; z < 10; z++) {
if ((x + z) % 2) continue;
const t = new THREE.Mesh(tileGeo, tile);
t.rotation.x = -Math.PI / 2;
t.position.set(x * 8, 0.02, 26 - z * 8);
scene.add(t);
}
// the frieze band round the walls: terracotta between black rules
for (const [w, x, z, ry] of [[L, -W / 2 + 0.1, -10, Math.PI / 2], [L, W / 2 - 0.1, -10, -Math.PI / 2], [W, 0, -L + 30.1, 0]] as const) {
for (const [h, y, mat] of [[2.4, 15, clay], [0.4, 16.4, black], [0.4, 13.6, black]] as const) {
const b = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
b.position.set(x, y, z);
b.rotation.y = ry;
scene.add(b);
}
}
// the door at the far end, with daylight
const door = new THREE.Mesh(new THREE.PlaneGeometry(9, 14), day);
door.position.set(0, 7, -L + 30.2);
scene.add(door);
const lintel = outline(new THREE.Mesh(new THREE.BoxGeometry(13, 1.6, 1.4), stone), 0.14, ink);
lintel.position.set(0, 14.6, -L + 30.6);
scene.add(lintel);
// four red columns, tapering downwards, with black cushion capitals, round the hearth
const colGeo = new THREE.CylinderGeometry(1.3, 0.95, 22, 10), capGeo = new THREE.CylinderGeometry(2, 1.4, 1.6, 10);
for (const [x, z] of [[-11, -2], [11, -2], [-11, -24], [11, -24]] as const) {
const c = outline(new THREE.Mesh(colGeo, red), 0.14, ink);
c.position.set(x, 11, z);
const cap = outline(new THREE.Mesh(capGeo, glaze), 0.1, ink);
cap.position.set(x, 22.6, z);
scene.add(c, cap);
}
// the hearth: a raised stone ring with the fire in it
const hearth = outline(new THREE.Mesh(new THREE.CylinderGeometry(6, 6.4, 1.2, 20), stone), 0.14, ink);
hearth.position.set(0, 0.6, -13);
const hearthRing = new THREE.Mesh(new THREE.TorusGeometry(5.6, 0.3, 4, 24), clay);
hearthRing.rotation.x = Math.PI / 2;
hearthRing.position.set(0, 1.25, -13);
scene.add(hearth, hearthRing);
const flames = fire({ fire: new THREE.MeshBasicMaterial({ color: "#f08a3a", side: THREE.DoubleSide }), core: new THREE.MeshBasicMaterial({ color: "#ffe08a", side: THREE.DoubleSide }), log: black });
flames.group.position.set(0, 1.2, -13);
flames.group.scale.setScalar(0.8);
scene.add(flames.group);
const fireLight = new THREE.PointLight(0xf08a3a, 320, 0, 1.6);
fireLight.position.set(0, 7, -11);
scene.add(fireLight, new THREE.AmbientLight(0xffe2c0, 1.1));
// the great bow on its stand by the left wall
const stand = new THREE.Group();
for (const x of [-4, 4]) {
const post = outline(new THREE.Mesh(new THREE.BoxGeometry(0.7, 9, 0.7), wood), 0.08, ink);
post.position.set(x, 4.5, 0);
stand.add(post);
}
const bar = outline(new THREE.Mesh(new THREE.BoxGeometry(9.6, 0.6, 0.8), wood), 0.08, ink);
bar.position.set(0, 8.4, 0);
stand.add(bar);
const bow = outline(new THREE.Mesh(new THREE.TorusGeometry(6.2, 0.36, 6, 28, Math.PI * 0.86), glaze), 0.1, new THREE.MeshBasicMaterial({ color: PAL.clay, side: THREE.BackSide }));
bow.rotation.z = Math.PI * 0.07;
bow.position.set(0, 4.6, 0.6);
const string = new THREE.Mesh(new THREE.BoxGeometry(12.2, 0.08, 0.08), new THREE.MeshBasicMaterial({ color: "#efe3c8" }));
string.position.set(0, 5.0, 0.6);
stand.add(bow, string);
stand.position.set(-17, 0, -20);
stand.rotation.y = Math.PI / 5;
scene.add(stand);
// the twelve axes in a row, their handles planted in the floor, the heads' rings lined up
const handle = new THREE.BoxGeometry(0.32, 6, 0.32), head = new THREE.TorusGeometry(0.9, 0.28, 5, 14), blade = new THREE.BoxGeometry(2.8, 1.5, 0.22);
for (let i = 0; i < 12; i++) {
const z = 18 - i * 3.6;
const h = new THREE.Mesh(handle, wood);
h.position.set(16, 3, z);
const ringM = new THREE.Mesh(head, bronze);
ringM.position.set(16, 6.6, z);
const b = outline(new THREE.Mesh(blade, bronze), 0.06, ink);
b.position.set(16, 6.6, z);
scene.add(h, ringM, b);
}
// a throne and table by the right wall
const throne = outline(new THREE.Mesh(new THREE.BoxGeometry(4, 8, 3), wood), 0.12, ink);
throne.position.set(22, 4, -30);
scene.add(throne);
return {
scene, camera,
update(k, t) {
const e = easeInOut(k);
camera.position.set(lerp(0, -4, e), lerp(9, 7, e), lerp(40, 27, e));
camera.lookAt(lerp(0, 3, e), lerp(7, 6, e), -30);
flicker(flames.flames, t, camera);
fireLight.intensity = 300 * (0.9 + 0.1 * Math.sin(t * 11) * Math.sin(t * 4.3));
},
dispose() { disposeTree(scene); },
};
}
+221
View File
@@ -0,0 +1,221 @@
// Shared 3D look (design record idea 5; ported from mockup 17's scene.ts): toon shading with three flat
// tones, ink outlines as inverted hulls, a posterised sky with a small flat sun, and a sea of flat colour
// bands with drawn wave strokes. The wave field exists twice, in GLSL and in JS, and they agree.
import * as THREE from "three";
import { mergeVertices } from "three/examples/jsm/utils/BufferGeometryUtils.js";
export const WAVE_GLSL = /* glsl */ `
uniform float uTime;
float seaWave(vec2 p) {
return 0.55 * sin(p.x * 0.09 + uTime * 0.8)
+ 0.40 * sin(p.y * 0.12 - uTime * 0.6 + p.x * 0.03)
+ 0.18 * sin((p.x + p.y) * 0.31 + uTime * 1.7);
}`;
export const seaWave = (x: number, z: number, t: number) =>
0.55 * Math.sin(x * 0.09 + t * 0.8) + 0.4 * Math.sin(z * 0.12 - t * 0.6 + x * 0.03) + 0.18 * Math.sin((x + z) * 0.31 + t * 1.7);
/** Look 2's flat inks: a poster-paint sea, teal and cream, inked in the frieze's black. */
export const PAL = {
sky: ["#f3dfb6", "#dcdcc4", "#b4cdc8", "#88b1c0"] as const, sun: "#fff4dc", sunRing: "#f7e6c0",
deep: "#1d5a6e", mid: "#2b7b88", crest: "#9cd0c4", contour: "#f1efdc", fog: "#f0d7aa", fogNear: 110, fogFar: 420,
ink: "#1d140e", light: "#fff6e6", ambient: "#9fb8c2",
hull: "#2a211b", band: "#c46a33", wood: "#5b3c20", sail: "#f3ead6", sailStripe: "#1d5f86", eye: "#f3ead6",
sand: "#e6cf98", rock: "#b0804e", top: "#6f7d3e", stone: "#e9dcc0", stoneDark: "#c8b48e",
basalt: "#3a3540", basaltTop: "#5a5262", snow: "#f6f1e6", storm: "#4a5866", stormDark: "#2f3a45",
clay: "#c46a33", glaze: "#1d140e", cream: "#f3ead6",
} as const;
export function hash(x: number, y: number, z: number): number {
const s = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453;
return s - Math.floor(s);
}
export const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
export const ease = (x: number) => { const t = clamp01(x); return t * t * (3 - 2 * t); };
export const easeInOut = (x: number) => { const t = clamp01(x); return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
export const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
export const v3 = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z);
/** Three flat tones per surface: shadow, mid, lit. */
function stepMap(): THREE.DataTexture {
const steps = [70, 160, 255];
const data = new Uint8Array(steps.length * 4);
steps.forEach((v, i) => data.set([v, v, v, 255], i * 4));
const t = new THREE.DataTexture(data, steps.length, 1, THREE.RGBAFormat);
t.minFilter = t.magFilter = THREE.NearestFilter;
t.generateMipmaps = false;
t.needsUpdate = true;
return t;
}
let steps: THREE.DataTexture | null = null;
export const toon = (color: THREE.ColorRepresentation, opts: THREE.MeshToonMaterialParameters = {}) =>
new THREE.MeshToonMaterial({ color, gradientMap: (steps ??= stepMap()), ...opts });
export const flat = (color: THREE.ColorRepresentation, opts: THREE.MeshBasicMaterialParameters = {}) =>
new THREE.MeshBasicMaterial({ color, ...opts });
export const inkBack = (color: THREE.ColorRepresentation = PAL.ink) => new THREE.MeshBasicMaterial({ color, side: THREE.BackSide });
export const edgeLines = (opacity = 0.4, color: THREE.ColorRepresentation = PAL.ink) =>
new THREE.LineBasicMaterial({ color, transparent: true, opacity });
/** Inverted hull: the same shape with smoothed normals, pushed out by t and drawn back faces only. */
export function hullOf(src: THREE.BufferGeometry, t: number): THREE.BufferGeometry {
let g = src.clone();
for (const k of Object.keys(g.attributes)) if (k !== "position") g.deleteAttribute(k);
g = mergeVertices(g, 1e-3);
g.computeVertexNormals();
const p = g.getAttribute("position") as THREE.BufferAttribute;
const n = g.getAttribute("normal") as THREE.BufferAttribute;
for (let i = 0; i < p.count; i++) p.setXYZ(i, p.getX(i) + n.getX(i) * t, p.getY(i) + n.getY(i) * t, p.getZ(i) + n.getZ(i) * t);
return g;
}
/** Add an ink outline (inverted hull) to a mesh. */
export function outline<T extends THREE.Mesh>(m: T, t: number, mat: THREE.Material): T {
m.add(new THREE.Mesh(hullOf(m.geometry, t), mat));
return m;
}
/** A flat ribbon along a 2D polyline (incised lines on cut-out figures). */
export function strokeGeo(pts: [number, number][], w: number, z: number): THREE.BufferGeometry {
const pos: number[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const [ax, ay] = pts[i]!, [bx, by] = pts[i + 1]!;
const dx = bx - ax, dy = by - ay, l = Math.hypot(dx, dy) || 1;
const nx = (-dy / l) * w / 2, ny = (dx / l) * w / 2;
pos.push(ax + nx, ay + ny, z, ax - nx, ay - ny, z, bx + nx, by + ny, z, bx + nx, by + ny, z, ax - nx, ay - ny, z, bx - nx, by - ny, z);
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
return g;
}
export const shape = (pts: [number, number][]) => {
const s = new THREE.Shape();
pts.forEach(([x, y], i) => (i ? s.lineTo(x, y) : s.moveTo(x, y)));
s.closePath();
return s;
};
export interface IslandOpts { radius: number; height: number; seed: number; segments?: number; /** a flat top of this radius (a plateau) */ top?: number }
export interface IslandColors { sand: THREE.ColorRepresentation; rock: THREE.ColorRepresentation; top: THREE.ColorRepresentation }
/** A bold graphic island: few facets, three height bands (sand, rock, scrub) as vertex colours. */
export function islandGeometry(o: IslandOpts, cols: IslandColors): THREE.BufferGeometry {
let g: THREE.BufferGeometry = o.top
? new THREE.CylinderGeometry(o.top, o.radius, o.height, o.segments ?? 9, 3, false)
: new THREE.ConeGeometry(o.radius, o.height, o.segments ?? 9, 3, true);
g.translate(0, o.height / 2 - 3, 0);
const pos = g.getAttribute("position") as THREE.BufferAttribute;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
const kx = Math.round(x * 10), ky = Math.round(y * 10), kz = Math.round(z * 10) + o.seed;
const j = 1 - Math.min(1, (y + 3) / o.height) * 0.5;
pos.setXYZ(i, x + (hash(kx, ky, kz) - 0.5) * o.radius * 0.22 * j, y + (hash(ky, kz, kx) - 0.5) * o.height * 0.1, z + (hash(kz, kx, ky) - 0.5) * o.radius * 0.22 * j);
}
g = g.toNonIndexed();
const sand = new THREE.Color(cols.sand), rock = new THREE.Color(cols.rock), top = new THREE.Color(cols.top);
const p = g.getAttribute("position") as THREE.BufferAttribute;
const colors = new Float32Array(p.count * 3);
for (let f = 0; f < p.count; f += 3) {
const y = (p.getY(f) + p.getY(f + 1) + p.getY(f + 2)) / 3;
const col = y < 1.2 ? sand : y > o.height * 0.45 - 3 ? top : rock;
for (let k = 0; k < 3; k++) col.toArray(colors, (f + k) * 3);
}
g.setAttribute("color", new THREE.BufferAttribute(colors, 3));
g.computeVertexNormals();
return g;
}
/** Posterised sky bands, horizon → zenith, with a flat paper sun and one ring (no glare). */
export function skyDome(bands: readonly [string, string, string, string], sun: string, ring: string, radius = 1000) {
const uniforms = {
uB0: { value: new THREE.Color(bands[0]) }, uB1: { value: new THREE.Color(bands[1]) },
uB2: { value: new THREE.Color(bands[2]) }, uB3: { value: new THREE.Color(bands[3]) },
uSun: { value: new THREE.Color(sun) }, uRing: { value: new THREE.Color(ring) },
uSunDir: { value: new THREE.Vector3(0.3, 0.17, -0.94).normalize() },
};
const mat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, fog: false, uniforms,
vertexShader: /* glsl */ `varying vec3 vDir; void main(){ vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: /* glsl */ `
uniform vec3 uB0; uniform vec3 uB1; uniform vec3 uB2; uniform vec3 uB3; uniform vec3 uSun; uniform vec3 uRing; uniform vec3 uSunDir; varying vec3 vDir;
void main(){
vec3 d = normalize(vDir);
float h = d.y + 0.012 * sin(atan(d.x, d.z) * 9.0);
vec3 col = uB0;
col = mix(col, uB1, step(0.035, h));
col = mix(col, uB2, step(0.09, h));
col = mix(col, uB3, step(0.18, h));
float s = dot(d, uSunDir);
col = mix(col, uRing, step(0.99955, s) * (1.0 - step(0.99967, s)));
col = mix(col, uSun, step(0.99978, s));
gl_FragColor = vec4(col, 1.0);
#include <colorspace_fragment>
}`,
});
const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 32, 16), mat);
mesh.renderOrder = -1;
mesh.frustumCulled = false;
return { mesh, uniforms };
}
/** Dispose every geometry and material under an object (landing scenes are thrown away when left). */
export function disposeTree(root: THREE.Object3D) {
const mats = new Set<THREE.Material>();
root.traverse((o) => {
const m = o as THREE.Mesh;
if (m.geometry) m.geometry.dispose();
const mat = m.material as THREE.Material | THREE.Material[] | undefined;
if (Array.isArray(mat)) mat.forEach((x) => mats.add(x));
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();
m.dispose();
}
}
/** Flat, layered flame shapes, as painted (17's cave fire). */
export function flameGeo(w: number, h: number): THREE.ShapeGeometry {
const s = new THREE.Shape();
s.moveTo(-w, 0);
s.quadraticCurveTo(-w * 1.1, h * 0.45, 0, h);
s.quadraticCurveTo(w * 1.1, h * 0.45, w, 0);
s.closePath();
return new THREE.ShapeGeometry(s, 6);
}
/** A fire of layered flames; returns the group and its flames for flickering. */
export function fire(mats: { fire: THREE.Material; core: THREE.Material; log: THREE.Material }) {
const group = new THREE.Group();
const flames: THREE.Mesh[] = [];
([[-2.2, 1.8, 5.5, 0], [0, 2.6, 8.5, 1], [2.3, 1.7, 6, 2], [0, 1.4, 4.6, 3], [-1, 0.9, 3, 4], [1.2, 0.8, 2.8, 5]] as const).forEach(([x, w, h, i]) => {
const core = i >= 3;
const f = new THREE.Mesh(flameGeo(w, h), core ? mats.core : mats.fire);
f.position.set(x, 0, core ? 0.3 : 0);
f.userData = { phase: i * 1.7 };
group.add(f);
flames.push(f);
});
const logs = new THREE.Mesh(new THREE.BoxGeometry(6, 0.8, 1.2), mats.log);
logs.position.y = 0.2;
group.add(logs);
return { group, flames };
}
/** Flicker flames and turn them to face the camera. */
export function flicker(flames: THREE.Mesh[], t: number, cam: THREE.Camera) {
for (const f of flames) {
const s = 0.82 + 0.18 * Math.sin(t * 8 + f.userData.phase) + 0.08 * Math.sin(t * 19 + f.userData.phase * 2);
f.scale.set(1 + (1 - s) * 0.4, s, 1);
const wp = f.parent!.getWorldPosition(new THREE.Vector3());
f.lookAt(cam.position.x, wp.y + f.position.y, cam.position.z);
}
}
/** A small landing scene: its own scene and camera, an intro move, per-frame update, and disposal. */
export interface Landing {
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
/** k: 0..1 through the arrival move (1 = settled); t: seconds. */
update(k: number, t: number): void;
dispose(): void;
}
+189
View File
@@ -0,0 +1,189 @@
// "Threads on the sea" (design record idea 8, ported from mockup 17's ribbons.ts): the current song's
// motifs as flat, ink-edged ribbons laid along the voyage's route, one colour per motif. A ribbon runs
// from the first to the last song where its motif plays: solid behind the ship, dashed ahead of it, with
// a knot at each song where the motif plays. The ribbons ride the same wave field as the sea (in the
// vertex shader), so nothing is updated per vertex on the CPU.
import * as THREE from "three";
import { motifs, songs, songsWithMotif } from "../../data/album";
import { PAL, WAVE_GLSL, seaWave } from "./materials";
import { ROUTE_LENGTH, pointAt, sAt, sIndex } from "./route";
const WIDTH = 0.9;
const GAP = 3.2;
const LIFT = 0.6;
const KNOT_R = 1.5;
/** Fade out within this distance of the camera, where a flat ribbon would become a wide road. */
const NEAR = 30;
/** Dash length (world units) ahead of the ship. */
const DASH = 5;
/** Samples per world unit along the route. */
const RES = 0.7;
interface Ribbon {
id: string;
off: number;
mats: THREE.MeshBasicMaterial[];
knots: { g: THREE.Group; i: number; x: number; z: number }[];
}
export const colorOf = (id: string) => motifs.find((m) => m.id === id)?.color ?? PAL.cream;
/** Song indices (0-based, album order) where a motif plays. */
export const hitsOf = (id: string) => songsWithMotif(id).map((s) => s.track - 1);
/** A point on the route at a fractional song index, and its sideways normal. */
function at(idx: number): { x: number; z: number; nx: number; nz: number } {
const p = pointAt(sAt(idx));
return { x: p.x, z: p.z, nx: -Math.cos(p.heading), nz: Math.sin(p.heading) };
}
export class RibbonSet {
readonly group = new THREE.Group();
private ribbons: Ribbon[] = [];
private split = { value: 0 };
private near = { value: NEAR };
private highlight: string | null = null;
private time = 0;
constructor(private waveU: { uTime: { value: number } }) {
this.group.renderOrder = 2;
}
get ids(): string[] { return this.ribbons.map((r) => r.id); }
private material(color: string, layer: number): THREE.MeshBasicMaterial {
const m = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2 });
m.onBeforeCompile = (sh) => {
Object.assign(sh.uniforms, this.waveU);
sh.uniforms.uSplit = this.split;
sh.uniforms.uNear = this.near;
sh.vertexShader = sh.vertexShader
.replace("#include <common>", `#include <common>\n${WAVE_GLSL}\nattribute float aU;\nattribute float aD;\nvarying float vU;\nvarying float vD;\nvarying float vDepth;`)
.replace("#include <begin_vertex>", `#include <begin_vertex>\nvU = aU; vD = aD;\ntransformed.y += seaWave((modelMatrix * vec4(transformed, 1.0)).xz) + ${LIFT.toFixed(2)};`)
.replace("#include <project_vertex>", "#include <project_vertex>\nvDepth = -mvPosition.z;");
sh.fragmentShader = sh.fragmentShader
.replace("#include <common>", "#include <common>\nuniform float uSplit;\nuniform float uNear;\nvarying float vU;\nvarying float vD;\nvarying float vDepth;")
.replace("#include <color_fragment>", "#include <color_fragment>\ndiffuseColor.a *= smoothstep(uNear * 0.45, uNear, vDepth);")
.replace("#include <clipping_planes_fragment>", `#include <clipping_planes_fragment>
// ahead of the ship the ribbon is dashed: the motif is still to come
if (vU > uSplit + 0.12 && fract(vD / ${DASH.toFixed(1)}) > 0.58) discard;`);
};
return m;
}
/** A strip from song index i0 to i1, offset sideways by off, w wide; sampled by distance along the route. */
private strip(i0: number, i1: number, off: number, w: number): THREE.BufferGeometry {
const s0 = sAt(i0), s1 = sAt(i1);
const n = Math.max(2, Math.ceil((s1 - s0) * ROUTE_LENGTH * RES) + 1);
const pos = new Float32Array(n * 6), u = new Float32Array(n * 2), d = new Float32Array(n * 2);
const idx: number[] = [];
for (let k = 0; k < n; k++) {
const s = s0 + ((s1 - s0) * k) / (n - 1);
// the fractional song index here, for the solid/dashed split
const fi = sIndex(s);
const p = pointAt(s);
const nx = -Math.cos(p.heading), nz = Math.sin(p.heading);
for (const side of [0, 1]) {
const o = off + (side ? 1 : -1) * w / 2;
const j = k * 2 + side;
pos.set([p.x + nx * o, 0, p.z + nz * o], j * 3);
u[j] = fi;
d[j] = s * ROUTE_LENGTH;
}
if (k) { const a = (k - 1) * 2; idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); }
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
geo.setAttribute("aU", new THREE.BufferAttribute(u, 1));
geo.setAttribute("aD", new THREE.BufferAttribute(d, 1));
geo.setIndex(idx);
return geo;
}
private clear() {
this.group.traverse((o) => {
const m = o as THREE.Mesh;
if (m.geometry) m.geometry.dispose();
if (m.material) (m.material as THREE.Material).dispose();
});
this.group.clear();
this.ribbons = [];
}
/** Lay out ribbons for these motifs (null or [] removes them all). */
set(motifIds: string[] | null) {
const ids = (motifIds ?? []).filter((id) => hitsOf(id).length > 0);
if (ids.join() === this.ids.join()) return;
this.clear();
ids.forEach((id, k) => {
const hits = hitsOf(id);
const off = (k - (ids.length - 1) / 2) * GAP;
const i0 = hits[0]!, i1 = Math.max(hits.at(-1)!, i0 + 0.05);
const fillMat = this.material(colorOf(id), 2);
const edgeMat = this.material(PAL.ink, 1);
const fill = new THREE.Mesh(this.strip(i0, i1, off, WIDTH), fillMat);
const edge = new THREE.Mesh(this.strip(i0, i1, off, WIDTH * 1.8), edgeMat);
fill.renderOrder = 3; edge.renderOrder = 2;
fill.frustumCulled = edge.frustumCulled = false;
this.group.add(edge, fill);
const mats = [fillMat, edgeMat];
const knots = hits.map((i) => {
const p = at(i);
const x = p.x + p.nx * off, z = p.z + p.nz * off;
const g = new THREE.Group();
const disc = (r: number, color: string, layer: number) => {
const mat = new THREE.MeshBasicMaterial({ color, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2 });
mats.push(mat);
const mesh = new THREE.Mesh(new THREE.CircleGeometry(r, 20), mat);
mesh.rotation.x = -Math.PI / 2;
mesh.renderOrder = 3 + layer;
g.add(mesh);
};
disc(KNOT_R * 1.32, PAL.ink, 3);
disc(KNOT_R, colorOf(id), 4);
disc(KNOT_R * 0.46, PAL.cream, 5);
g.position.set(x, 0, z);
this.group.add(g);
return { g, i, x, z };
});
this.ribbons.push({ id, off, mats, knots });
});
this.setHighlight(this.highlight);
}
setHighlight(id: string | null) {
this.highlight = id && this.ribbons.some((r) => r.id === id) ? id : null;
for (const r of this.ribbons) {
const op = this.highlight && r.id !== this.highlight ? 0.3 : 1;
r.mats.forEach((m) => (m.opacity = op));
r.knots.forEach((k) => k.g.scale.setScalar(this.highlight === r.id ? 1.35 : 1));
}
}
/** Keep the knots on the waves and move the solid/dashed split to the ship's place (fractional song index). */
update(t: number, split: number) {
this.time = t;
this.split.value = split;
for (const r of this.ribbons) for (const k of r.knots) k.g.position.y = seaWave(k.x, k.z, t) + LIFT + 0.05;
}
/**
* Where each ribbon's label points: its next knot ahead of the ship, or, if the motif is not heard again,
* its last knot. World space, a little above the knot. `near` is a point on the same ribbon just ahead of
* (or behind) the ship, for when the knot itself is out of view.
*/
labels(split: number): { id: string; pos: THREE.Vector3; near: THREE.Vector3; song: number; ahead: boolean }[] {
return this.ribbons.map((r) => {
const ahead = r.knots.find((k) => k.i > split + 0.35);
const k = ahead ?? r.knots.at(-1)!;
const lift = (x: number, z: number) => seaWave(x, z, this.time) + LIFT + 1.2;
const ni = Math.min(Math.max(ahead ? split + 0.5 : split - 0.5, r.knots[0]!.i), r.knots.at(-1)!.i);
const p = at(ni);
const nx = p.x + p.nx * r.off, nz = p.z + p.nz * r.off;
return { id: r.id, pos: new THREE.Vector3(k.x, lift(k.x, k.z), k.z), near: new THREE.Vector3(nx, lift(nx, nz), nz), song: k.i, ahead: !!ahead };
});
}
dispose() { this.clear(); }
}
export const songAt = (i: number) => songs[i]!;
+139
View File
@@ -0,0 +1,139 @@
// The voyage's geometry, pure (no three.js): nine places along a winding route and one stop per song.
// The route runs roughly towards -z. Each saga has a waypoint the route passes; its place (island, cliffs,
// whirlpool …) lies to one side of it, and that saga's songs are stops spread along the route past it.
// Within a saga the stops are a few ship-lengths apart; between sagas the route crosses open sea.
import { songs } from "../../data/album";
import type { PlaceId } from "../../data/album";
export interface Place { id: PlaceId; x: number; z: number; radius: number }
export interface Stop { songId: string; placeId: PlaceId; x: number; z: number; heading: number; s: number /* 0..1 along route */ }
export interface RoutePoint { x: number; z: number; heading: number }
/** The route's waypoint per saga, which side of it the place lies on (+1 starboard, -1 port), and its radius. */
const WAYPOINTS: { id: PlaceId; x: number; z: number; side: 1 | -1; radius: number }[] = [
{ id: "troy", x: 0, z: 0, side: -1, radius: 64 },
{ id: "cyclops", x: -150, z: -290, side: -1, radius: 58 },
{ id: "ocean", x: 110, z: -560, side: 1, radius: 60 },
{ id: "circe", x: -90, z: -850, side: -1, radius: 60 },
{ id: "underworld", x: 160, z: -1130, side: 1, radius: 56 },
{ id: "thunder", x: -70, z: -1420, side: -1, radius: 62 },
{ id: "wisdom", x: 170, z: -1700, side: 1, radius: 66 },
{ id: "vengeance", x: -50, z: -1990, side: -1, radius: 60 },
{ id: "ithaca", x: 120, z: -2280, side: 1, radius: 64 },
];
/** How far off the route the place's centre lies, and how much of the route its stops spread over (× radius). */
const OFFSET = 0.9;
const SPAN = 1.1;
function unit(x: number, z: number): [number, number] {
const l = Math.hypot(x, z) || 1;
return [x / l, z / l];
}
/** The place's centre and the route direction at its waypoint. */
function layout(i: number) {
const w = WAYPOINTS[i]!;
const a = WAYPOINTS[Math.max(0, i - 1)]!, b = WAYPOINTS[Math.min(WAYPOINTS.length - 1, i + 1)]!;
const [dx, dz] = unit(b.x - a.x, b.z - a.z);
const [nx, nz] = [-dz, dx]; // starboard when sailing along (dx, dz)
return { w, dx, dz, cx: w.x + w.side * nx * OFFSET * w.radius, cz: w.z + w.side * nz * OFFSET * w.radius };
}
export const PLACES: Place[] = WAYPOINTS.map((w, i) => {
const l = layout(i);
return { id: w.id, x: l.cx, z: l.cz, radius: w.radius };
});
/** Which side of the route each place lies on (+1 starboard, -1 port): the camera keeps to the other side. */
export const PLACE_SIDE: Record<PlaceId, 1 | -1> = Object.fromEntries(WAYPOINTS.map((w) => [w.id, w.side])) as Record<PlaceId, 1 | -1>;
/* ---------- stop positions and the spline through them ---------- */
const POS: { x: number; z: number; songId: string; placeId: PlaceId }[] = [];
WAYPOINTS.forEach((w, i) => {
const l = layout(i);
const own = songs.filter((s) => s.sagaId === w.id);
own.forEach((song, k) => {
const t = own.length > 1 ? k / (own.length - 1) : 0.5;
const along = (t - 0.5) * SPAN * w.radius;
POS.push({ x: w.x + l.dx * along, z: w.z + l.dz * along, songId: song.id, placeId: w.id });
});
});
const CUM: number[] = [0];
for (let i = 1; i < POS.length; i++) CUM.push(CUM[i - 1]! + Math.hypot(POS[i]!.x - POS[i - 1]!.x, POS[i]!.z - POS[i - 1]!.z));
/** The route's length in world units (straight lines between stops); s × ROUTE_LENGTH is a distance. */
export const ROUTE_LENGTH = CUM.at(-1)!;
/** Route position s of each stop: cumulative straight-line distance, normalised to 0..1. */
const S: number[] = CUM.map((v) => v / ROUTE_LENGTH);
/** Control points for the spline, with the ends extended so the first and last segments have neighbours. */
const P: [number, number][] = (() => {
const pts = POS.map((p) => [p.x, p.z] as [number, number]);
const n = pts.length;
return [
[2 * pts[0]![0] - pts[1]![0], 2 * pts[0]![1] - pts[1]![1]],
...pts,
[2 * pts[n - 1]![0] - pts[n - 2]![0], 2 * pts[n - 1]![1] - pts[n - 2]![1]],
];
})();
/** Centripetal Catmull–Rom (Barry–Goldman) on segment i (between stops i and i+1), u in 0..1. No loops or cusps
* where short in-saga hops meet long crossings. */
function spline(i: number, u: number): [number, number] {
const p0 = P[i]!, p1 = P[i + 1]!, p2 = P[i + 2]!, p3 = P[i + 3]!;
const knot = (a: [number, number], b: [number, number]) => Math.max(1e-6, Math.sqrt(Math.hypot(b[0] - a[0], b[1] - a[1])));
const t0 = 0, t1 = t0 + knot(p0, p1), t2 = t1 + knot(p1, p2), t3 = t2 + knot(p2, p3);
const t = t1 + (t2 - t1) * u;
const mix = (a: [number, number], b: [number, number], ta: number, tb: number): [number, number] => {
const k = (t - ta) / (tb - ta);
return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k];
};
const a1 = mix(p0, p1, t0, t1), a2 = mix(p1, p2, t1, t2), a3 = mix(p2, p3, t2, t3);
const b1 = mix(a1, a2, t0, t2), b2 = mix(a2, a3, t1, t3);
return mix(b1, b2, t1, t2);
}
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
/** The segment index and local parameter for a route position. */
function locate(s: number): { i: number; u: number } {
const v = clamp01(s);
let i = 0;
while (i < S.length - 2 && v >= S[i + 1]!) i++;
return { i, u: clamp01((v - S[i]!) / (S[i + 1]! - S[i]!)) };
}
/** A point on the route and the heading there (radians; atan2(dx, dz), as three.js' rotation.y for a ship facing +z). */
export function pointAt(s: number): RoutePoint {
const { i, u } = locate(s);
const [x, z] = spline(i, u);
const e = 1e-3;
const [ax, az] = spline(i, Math.max(0, u - e));
const [bx, bz] = spline(i, Math.min(1, u + e));
return { x, z, heading: Math.atan2(bx - ax, bz - az) };
}
/** Route position → fractional song index (0..39), linear between stops. */
export function sIndex(s: number): number {
const { i, u } = locate(s);
return i + u;
}
/** Fractional song index → route position. */
export function sAt(index: number): number {
const i = Math.min(S.length - 2, Math.max(0, Math.floor(index)));
const u = clamp01(index - i);
return S[i]! + (S[i + 1]! - S[i]!) * u;
}
const STOPS: Stop[] = POS.map((p, i) => ({ ...p, s: S[i]!, heading: pointAt(S[i]!).heading }));
/** One stop per song, in album order. */
export function stops(): Stop[] {
return STOPS.map((s) => ({ ...s }));
}
export function stopOf(songId: string): Stop | undefined {
return STOPS.find((s) => s.songId === songId);
}
+85
View File
@@ -0,0 +1,85 @@
// Odysseus' ship (ported from mockup 17): a black hull with a terracotta band, a curled stern post, a ram,
// painted eyes, oars and a striped sail — silhouette first. Faces +z; the world turns it with rotation.y.
import * as THREE from "three";
import { PAL, flat, inkBack, outline, toon } from "./materials";
export function buildShip(): THREE.Group {
const s = new THREE.Group();
const ink = inkBack();
const hullMat = toon(PAL.hull), bandMat = toon(PAL.band), wood = flat(PAL.wood);
const eyeMat = flat(PAL.eye), pupilMat = flat(PAL.ink);
const hullShape = new THREE.Shape();
hullShape.moveTo(0, 4.8);
hullShape.quadraticCurveTo(1.8, 1.6, 1.6, -1.6);
hullShape.quadraticCurveTo(1.35, -3.4, 0, -3.9);
hullShape.quadraticCurveTo(-1.35, -3.4, -1.6, -1.6);
hullShape.quadraticCurveTo(-1.8, 1.6, 0, 4.8);
const hullGeo = new THREE.ExtrudeGeometry(hullShape, { depth: 1.6, bevelEnabled: false, curveSegments: 6 });
hullGeo.rotateX(Math.PI / 2);
hullGeo.translate(0, 1.2, 0);
const hp = hullGeo.getAttribute("position") as THREE.BufferAttribute;
for (let i = 0; i < hp.count; i++) if (hp.getY(i) < 0.5) hp.setX(i, hp.getX(i) * 0.35);
hullGeo.computeVertexNormals();
s.add(outline(new THREE.Mesh(hullGeo, hullMat), 0.12, ink));
const band = new THREE.Mesh(new THREE.BoxGeometry(3.45, 0.32, 6.4), bandMat);
band.position.set(0, 1.0, 0.35);
s.add(outline(band, 0.07, ink));
const stern = new THREE.Mesh(new THREE.TorusGeometry(0.75, 0.17, 5, 10, Math.PI * 1.1), hullMat);
stern.rotation.y = Math.PI / 2;
stern.position.set(0, 2.15, -3.9);
s.add(outline(stern, 0.08, ink));
const ram = new THREE.Mesh(new THREE.ConeGeometry(0.35, 1.8, 4), hullMat);
ram.rotation.x = Math.PI / 2;
ram.position.set(0, 0.35, 5.2);
s.add(outline(ram, 0.06, ink));
const eyeGeo = new THREE.CircleGeometry(0.38, 12), pupilGeo = new THREE.CircleGeometry(0.17, 10), oarGeo = new THREE.BoxGeometry(3.6, 0.1, 0.2);
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(eyeGeo, eyeMat);
eye.position.set(side * 1.25, 1.45, 3.1);
eye.rotation.y = side * (Math.PI / 2 - 0.5);
const pupil = new THREE.Mesh(pupilGeo, pupilMat);
pupil.position.z = 0.02;
eye.add(pupil);
s.add(eye);
for (let i = 0; i < 5; i++) {
const oar = new THREE.Mesh(oarGeo, wood);
oar.position.set(side * 2.7, 0.6, -1.8 + i * 1.05);
oar.rotation.z = side * -0.38;
s.add(oar);
}
}
const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.16, 8.4, 5), wood);
mast.position.y = 5;
const yard = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 6.8, 5), wood);
yard.rotation.z = Math.PI / 2;
yard.position.y = 8.2;
s.add(mast, yard);
// the striped sail: a small canvas texture, nearest-filtered so the stripes stay crisp
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 64;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.fillStyle = PAL.sail; ctx.fillRect(0, 0, 64, 64);
ctx.fillStyle = PAL.sailStripe;
for (const x of [8, 27, 46]) ctx.fillRect(x, 0, 10, 64);
}
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
tex.magFilter = THREE.NearestFilter;
const sailGeo = new THREE.PlaneGeometry(6.2, 4.6, 6, 4);
const sp = sailGeo.getAttribute("position") as THREE.BufferAttribute;
for (let i = 0; i < sp.count; i++) {
const x = sp.getX(i) / 3.1, y = sp.getY(i) / 2.3;
sp.setZ(i, (1 - x * x) * (1 - 0.4 * y * y));
}
sailGeo.computeVertexNormals();
const sail = new THREE.Mesh(sailGeo, toon("#ffffff", { side: THREE.DoubleSide, map: tex }));
sail.position.set(0, 5.8, 0.2);
sail.add(new THREE.LineSegments(new THREE.EdgesGeometry(sailGeo, 30), new THREE.LineBasicMaterial({ color: PAL.ink })));
s.add(sail);
return s;
}
+70 -1
View File
@@ -1 +1,70 @@
/* the 3D world, landings and the frieze curtain (plan task 6) */
/* The 3D world's overlays and the frieze curtain (plan task 6): the curtain fitted between the caption
and the timeline, the black-figure painting classes (scoped to .frieze), and the motif labels that
float over the ribbons on the sea. */
/* ---------- the curtain: fitted between the caption card and the timeline ---------- */
.frieze-layer {
--fz-top: 116px;
--fz-bot: calc(var(--timeline-h) + 30px);
}
.fz-box, .fz-host { position: absolute; left: 0; right: 0; top: var(--fz-top); bottom: var(--fz-bot); }
.fz-box { visibility: hidden; pointer-events: none; }
.fz-host { will-change: transform, opacity; }
/* fade(): the stage covered in the curtain's terracotta for long jumps */
.fz-cover { position: absolute; inset: 0; background: var(--clay-deep); will-change: opacity; }
/* ---------- black-figure on terracotta ---------- */
.frieze { display: block; width: 100%; height: 100%; overflow: visible; }
.frieze .f-surround { fill: var(--glaze); }
.frieze .f-band { fill: var(--clay); }
.frieze .ink { fill: var(--glaze); }
.frieze .clay { fill: var(--clay); }
.frieze .inc { fill: none; stroke: var(--clay); stroke-linecap: round; stroke-linejoin: round; }
.frieze .limb { fill: none; stroke: var(--glaze); stroke-linecap: round; stroke-linejoin: round; }
.frieze .limb-edge { fill: none; stroke: var(--clay); stroke-linecap: round; stroke-linejoin: round; }
.frieze .oar { stroke: var(--glaze); stroke-width: 3.2; stroke-linecap: round; }
.frieze .rig { fill: none; stroke: var(--glaze); stroke-width: 1.4; }
.frieze .wave { fill: none; stroke: var(--glaze); stroke-linecap: round; }
.frieze .ground { stroke: var(--glaze); stroke-width: 3.5; }
.frieze .rule { fill: none; stroke: var(--glaze); stroke-width: 2.6; }
.frieze .vol { fill: none; stroke: var(--glaze); stroke-width: 3; stroke-linecap: round; }
.frieze .mean { fill: none; stroke: var(--glaze); stroke-width: 3; }
.frieze .scroll { fill: none; stroke: var(--glaze); stroke-width: 3.4; stroke-linecap: round; }
/* At 1440×900 the frieze is drawn at ~0.7 of its units: 28 units ≈ 19.7px on screen. Plain capitals with
little tracking (15's wide-tracked small capitals were "very hard to read"). */
.frieze .inscr { font: 700 28px/1 var(--serif); letter-spacing: 0.02em; fill: var(--glaze); }
.frieze .saga-name { font: 600 20px var(--sans); letter-spacing: 0.05em; fill: var(--glaze); }
/* the light through each doorway: the cave's firelight, the arena's sunlit sand, the hall's hearth */
.frieze--cyclops .glow-a { stop-color: #e2803a; }
.frieze--cyclops .glow-b { stop-color: #3a170a; }
.frieze--cyclops .glow-c { stop-color: #0c0705; }
.frieze--wisdom .glow-a { stop-color: #f6e2b8; }
.frieze--wisdom .glow-b { stop-color: #d6ad6e; }
.frieze--wisdom .glow-c { stop-color: #5a3d1e; }
.frieze--ithaca .glow-a { stop-color: #f2a95a; }
.frieze--ithaca .glow-b { stop-color: #6a2c14; }
.frieze--ithaca .glow-c { stop-color: #1d0c06; }
/* ---------- threads on the sea: labels over the ribbons, lifted on leader lines ---------- */
.wlabels { position: absolute; inset: 0; z-index: 1; pointer-events: none; overflow: hidden; }
.wl-leaders { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.wl-leaders path { stroke: var(--glaze); stroke-width: 1.6; fill: none; }
.wl-leaders circle { fill: var(--added-white); stroke: var(--glaze); stroke-width: 1.6; }
.wl {
position: absolute;
left: 0; top: 0;
padding: 7px 11px 8px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
background: var(--vellum-hi);
color: var(--iron);
white-space: nowrap;
box-shadow: 0 6px 14px -8px rgb(0 0 0 / 0.6);
}
.wl::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 4px; background: var(--clay); }
.wl__head { margin: 2px 0 4px; font-size: var(--fs-label); font-weight: 600; color: var(--iron-soft); }
.wl__ms { display: flex; flex-direction: column; gap: 2px; margin: 0; }
.wl__m { display: flex; align-items: center; gap: 7px; font-size: var(--fs-body); font-weight: 600; }
.wl__m i { width: 11px; height: 11px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1.5px var(--glaze); }
.wl__m.is-on { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; }
.wl__m.is-dim { opacity: 0.45; }
+443
View File
@@ -0,0 +1,443 @@
// The 3D world (spec 002 goals 3 and 5): one sea with all nine places along a winding route, the ship
// sailing from stop to stop with the camera following, three landing scenes the world switches to, and
// the motif ribbons on the sea with their labels. Rendered on the app's canvas; null without WebGL 2.
import * as THREE from "three";
import { motifs, songById, songs } from "../../data/album";
import type { LandingId, PlaceId } from "../../data/album";
import { SEA_HOLE, buildPlace, placeKit } from "./islands";
import { buildArena } from "./landings/arena";
import { buildCave } from "./landings/cave";
import { buildHall } from "./landings/hall";
import type { Landing } from "./materials";
import { PAL, WAVE_GLSL, clamp01, disposeTree, easeInOut, lerp, seaWave, skyDome, v3 } from "./materials";
import { RibbonSet, colorOf } from "./ribbons";
import { PLACES, PLACE_SIDE, ROUTE_LENGTH, pointAt, sIndex, stopOf } from "./route";
import { buildShip } from "./ship";
export interface World {
sailTo(songId: string, opts?: { instant?: boolean }): Promise<void>;
setLanding(id: LandingId | null): Promise<void>;
setRibbons(motifIds: string[] | null, highlight: string | null): void;
/** Camera pitch so the horizon sits on the frieze's ground line (px from the canvas top) as the curtain lifts. */
horizonFromFrieze(groundYpx: number): void;
resize(): void;
dispose(): void;
}
const FOV = 46;
const LANDINGS: Record<LandingId, () => Landing> = { cave: buildCave, arena: buildArena, hall: buildHall };
const INTRO_MS = 1800;
/** How long the horizon stays locked to the frieze's ground line, then how long it takes to let go. */
const LOCK_HOLD = 800, LOCK_RELEASE = 1200;
/** Places further than this from the camera are not drawn. */
const DRAW_DIST = 640;
const MOTIF_NAMES = new Map(motifs.map((m) => [m.id, m.name] as const));
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
const smooth = (a: number, b: number, x: number) => clamp01((x - a) / (b - a));
const angleLerp = (a: number, b: number, k: number) => a + Math.atan2(Math.sin(b - a), Math.cos(b - a)) * k;
export function createWorld(canvas: HTMLCanvasElement): World | null {
let renderer: THREE.WebGLRenderer;
try {
const gl = canvas.getContext("webgl2", { antialias: true, preserveDrawingBuffer: true, powerPreference: "high-performance" });
if (!gl) return null;
renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: true, preserveDrawingBuffer: true });
} catch {
return null;
}
const win = canvas.ownerDocument.defaultView ?? window;
const reduced = !!win.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
renderer.setPixelRatio(Math.min(win.devicePixelRatio || 1, 2));
const host = canvas.parentElement;
/* ---------- the sea scene ---------- */
const sea = new THREE.Scene();
sea.fog = new THREE.Fog(PAL.fog, PAL.fogNear, PAL.fogFar);
const ambient = new THREE.AmbientLight(PAL.ambient, 1.2);
const sunLight = new THREE.DirectionalLight(PAL.light, 2.4);
sea.add(ambient, sunLight, sunLight.target);
const sky = skyDome(PAL.sky, PAL.sun, PAL.sunRing);
sea.add(sky.mesh);
const SUN_DIR = new THREE.Vector3(0.3, 0.17, -0.94).normalize();
const waveU = { uTime: { value: 0 } };
const holeU = { uHole: { value: new THREE.Vector3(0, 0, 0) } };
const seaU = { uDeep: { value: new THREE.Color(PAL.deep) }, uMid: { value: new THREE.Color(PAL.mid) }, uCrest: { value: new THREE.Color(PAL.crest) }, uContour: { value: new THREE.Color(PAL.contour) } };
const seaMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
seaMat.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, waveU, seaU, holeU);
shader.vertexShader = shader.vertexShader
.replace("#include <common>", `#include <common>\n${WAVE_GLSL}\nvarying float vH;\nvarying vec2 vWp;`)
.replace("#include <begin_vertex>", `#include <begin_vertex>
vec4 seaWp = modelMatrix * vec4(transformed, 1.0);
float seaH = seaWave(seaWp.xz);
transformed.z += seaH;
vH = seaH;
vWp = seaWp.xz;`);
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", `#include <common>\nuniform float uTime;\nuniform vec3 uDeep;\nuniform vec3 uMid;\nuniform vec3 uCrest;\nuniform vec3 uContour;\nuniform vec3 uHole;\nvarying float vH;\nvarying vec2 vWp;`)
.replace("#include <clipping_planes_fragment>", "#include <clipping_planes_fragment>\nif (uHole.z > 0.0 && distance(vWp, uHole.xy) < uHole.z) discard;")
.replace("#include <color_fragment>", `#include <color_fragment>
// two flat bands by swell, then thin drawn wave strokes on the upper swells, thinning with distance
float h = vH + 0.2 * sin(vWp.x * 0.21 + vWp.y * 0.13 + uTime * 0.4);
vec3 c = mix(uDeep, uMid, step(-0.3, h));
float s = sin(vWp.y * 0.8 + 1.7 * sin(vWp.x * 0.085 + uTime * 0.22) - uTime * 1.1);
float fs = fwidth(s);
float stroke = smoothstep(0.935 - fs, 0.935 + fs, s);
float gate = smoothstep(0.3, 0.6, h) * smoothstep(0.1, 0.45, sin(vWp.x * 0.16 + vWp.y * 0.05 + 1.3));
float fade = 1.0 - smoothstep(0.3, 0.8, fs);
float halo = smoothstep(0.75 - fs, 0.75 + fs, s) * gate * fade;
c = mix(c, uCrest, halo * 0.3);
diffuseColor.rgb = mix(c, uContour, stroke * gate * fade * 0.95);`);
};
const SEA_SIZE = 1400, SEA_SEG = 175, CELL = SEA_SIZE / SEA_SEG;
const seaMesh = new THREE.Mesh(new THREE.PlaneGeometry(SEA_SIZE, SEA_SIZE, SEA_SEG, SEA_SEG), seaMat);
seaMesh.rotation.x = -Math.PI / 2;
seaMesh.frustumCulled = false;
sea.add(seaMesh);
const kit = placeKit(waveU.uTime);
const places = PLACES.map((p) => {
const g = buildPlace(p.id, kit);
// local +z faces the route: towards the middle of the place's stops
const own = songs.filter((s) => s.sagaId === p.id);
const st = stopOf(own[Math.floor(own.length / 2)]!.id)!;
const toRoute = Math.atan2(st.x - p.x, st.z - p.z);
g.position.set(p.x, 0, p.z);
g.rotation.y = toRoute;
sea.add(g);
return { ...p, g };
});
const placeById = new Map(places.map((p) => [p.id, p] as const));
const hole = (() => {
const id = (Object.keys(SEA_HOLE) as PlaceId[])[0];
const p = id ? placeById.get(id) : undefined;
return p ? new THREE.Vector3(p.x, p.z, SEA_HOLE[p.id]! - 0.5) : new THREE.Vector3();
})();
holeU.uHole.value.copy(hole);
const ship = buildShip();
sea.add(ship);
const camera = new THREE.PerspectiveCamera(FOV, 16 / 9, 0.5, 1600);
camera.rotation.order = "YXZ";
const ribbons = new RibbonSet(waveU);
sea.add(ribbons.group);
/* ---------- the ribbons' labels: DOM over the canvas ---------- */
const labels = canvas.ownerDocument.createElement("div");
labels.className = "wlabels";
labels.hidden = true;
labels.setAttribute("aria-hidden", "true");
host?.append(labels);
let labelKey = "";
/* ---------- state ---------- */
let s = 0;
let songIdx = 0;
let sail: { from: number; to: number; t0: number; dur: number; done: () => void } | null = null;
let landing: { id: LandingId; obj: Landing; t0: number; done: (() => void) | null } | null = null;
let camHeading = pointAt(0).heading;
let shipYaw = camHeading;
let lock: { at: number; t0: number } | null = null;
let ribbonIds: string[] | null = null;
let lastT = performance.now();
let raf = 0;
let disposed = false;
/** Lift the picture so the ship clears the bottom timeline: the timeline's height, from the app's tokens. */
function frameShift(): number {
const el = host ?? canvas;
const v = parseFloat(win.getComputedStyle?.(el).getPropertyValue("--timeline-h") || "") || 190;
return Math.max(0, (v - 92) * 0.55);
}
let shift = 0;
function applyView(cam: THREE.PerspectiveCamera, fov: number) {
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w || !h) return;
const fullH = h + 2 * shift;
cam.fov = THREE.MathUtils.radToDeg(2 * Math.atan(Math.tan(THREE.MathUtils.degToRad(fov / 2)) * (fullH / h)));
cam.aspect = w / fullH;
if (shift > 0) cam.setViewOffset(w, fullH, 0, 2 * shift, w, h);
else cam.clearViewOffset();
cam.updateProjectionMatrix();
}
function resize() {
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
shift = frameShift();
applyView(camera, FOV);
if (landing) applyView(landing.obj.camera, landing.obj.camera.userData.fov ?? landing.obj.camera.fov);
render(performance.now());
}
/* ---------- frame ---------- */
const tmp = new THREE.Vector3();
/** 1 while the horizon is held on the frieze's ground line, easing to 0 as it lets go. */
function lockFactor(now: number): number {
if (!lock) return 0;
const e = now - lock.t0;
const L = e < LOCK_HOLD ? 1 : 1 - easeInOut((e - LOCK_HOLD) / LOCK_RELEASE);
return Math.max(0, L);
}
function aim(pos: THREE.Vector3, target: THREE.Vector3, now: number) {
const d = tmp.copy(target).sub(pos);
const yaw = Math.atan2(-d.x, -d.z);
const pitchAim = Math.atan2(d.y, Math.hypot(d.x, d.z));
const L = lockFactor(now);
let pitchLock = 0;
if (lock) {
const h = canvas.clientHeight || 1;
const tanHalf = Math.tan(THREE.MathUtils.degToRad(FOV / 2));
// in the un-lifted frame: the horizon's offset from the picture's centre, which sits `shift` px above the canvas'
pitchLock = Math.atan((lock ? lock.at : 0.5) * 2 * tanHalf - tanHalf + (2 * shift / h) * tanHalf);
}
camera.position.copy(pos);
camera.rotation.set(lerp(pitchAim, pitchLock, L), yaw, 0);
if (lock && L <= 0) lock = null;
return yaw;
}
function renderSea(now: number, t: number, dt: number) {
if (sail) {
const k = clamp01((now - sail.t0) / sail.dur);
s = lerp(sail.from, sail.to, easeInOut(k));
if (k >= 1) { const done = sail.done; sail = null; done(); }
}
const p = pointAt(s);
// the ship faces where it is going, and turns back to the route's heading at rest
const moving = sail ? Math.sign(sail.to - sail.from) : 0;
const want = moving < 0 ? p.heading + Math.PI : p.heading;
shipYaw = angleLerp(shipYaw, want, 1 - Math.exp(-dt * 3));
camHeading = angleLerp(camHeading, p.heading, 1 - Math.exp(-dt * 1.6));
ship.position.set(p.x, seaWave(p.x, p.z, t) * 0.6 - 0.15, p.z);
ship.rotation.set(Math.sin(t * 0.9) * 0.03, shipYaw, Math.sin(t * 1.1) * 0.05);
// framing: out at sea, low behind the ship; near a place, pulled back and up with the place in view
let wSum = 0, side = 0;
const placeT = v3(0, 0, 0);
for (const pl of places) {
const d = Math.hypot(pl.x - p.x, pl.z - p.z);
const w = 1 - smooth(1.4 * pl.radius, 3.4 * pl.radius, d);
if (w <= 0) continue;
wSum += w;
side += w * -PLACE_SIDE[pl.id];
placeT.add(v3((p.x + pl.x) / 2, 7, (p.z + pl.z) / 2).multiplyScalar(w));
}
const W = Math.min(1, wSum);
if (wSum > 0) placeT.multiplyScalar(1 / wSum);
side = Math.max(-1, Math.min(1, side));
const fx = Math.sin(camHeading), fz = Math.cos(camHeading);
const sx = -fz, sz = fx; // starboard
// while the horizon is held on the frieze's line the camera sits low (as in 13/17), so the fogged sea
// meets the sky on the line instead of a band below it; it rises as the lock lets go
const L = lockFactor(now);
const back = lerp(46, 74, W), up = lerp(lerp(8, 19, W), 3.2, L), lat = 15 * side * W;
const pos = v3(p.x - fx * back + sx * lat, up, p.z - fz * back + sz * lat);
const open = v3(p.x + fx * 34, 3, p.z + fz * 34);
const target = wSum > 0 ? open.lerp(placeT, W) : open;
const yaw = aim(pos, target, now);
// the sky, the sea and the light travel with the camera; the sun keeps its place in the picture
sky.mesh.position.set(pos.x, 0, pos.z);
seaMesh.position.set(Math.round(pos.x / CELL) * CELL, 0, Math.round(pos.z / CELL) * CELL - 400);
const sun = SUN_DIR.clone().applyAxisAngle(new THREE.Vector3(0, 1, 0), yaw);
sky.uniforms.uSunDir.value.copy(sun);
sunLight.position.copy(pos).add(v3(-60, 90, 40).applyAxisAngle(new THREE.Vector3(0, 1, 0), yaw));
sunLight.target.position.copy(pos);
for (const pl of places) pl.g.visible = Math.hypot(pl.x - pos.x, pl.z - pos.z) < DRAW_DIST;
const split = sIndex(s);
if (ribbonIds?.length) {
ribbons.update(t, split);
placeLabels(split);
}
renderer.render(sea, camera);
}
function render(now: number) {
const dt = Math.min(0.1, (now - lastT) / 1000);
lastT = now;
const t = (reduced ? 0.25 : 1) * now / 1000;
waveU.uTime.value = t;
if (landing) {
const k = reduced ? 1 : clamp01((now - landing.t0) / INTRO_MS);
landing.obj.update(k, t);
if (k >= 1 && landing.done) { const d = landing.done; landing.done = null; d(); }
labels.hidden = true;
renderer.render(landing.obj.scene, landing.obj.camera);
if (sail) renderSeaState(now);
} else {
labels.hidden = !ribbonIds?.length;
renderSea(now, t, dt);
}
}
/** While ashore, a running sail still finishes (the ship arrives off-screen). */
function renderSeaState(now: number) {
if (!sail) return;
const k = clamp01((now - sail.t0) / sail.dur);
s = lerp(sail.from, sail.to, easeInOut(k));
if (k >= 1) { const done = sail.done; sail = null; done(); }
}
const loop = (now: number) => {
if (disposed) return;
render(now);
raf = win.requestAnimationFrame(loop);
};
/* ---------- labels: merged per knot, lifted on leader lines, stacked so they never overlap ---------- */
const LIFT = 70;
function placeLabels(split: number) {
const W = canvas.clientWidth, H = canvas.clientHeight;
// each label points at its knot, or, when the knot is out of view, at its ribbon just ahead of the ship
const onScreen = (p: THREE.Vector3) => {
const v = p.clone().project(camera);
return { x: (v.x * 0.5 + 0.5) * W, y: (-v.y * 0.5 + 0.5) * H, ok: v.z < 1 && Math.abs(v.x) < 0.96 && Math.abs(v.y) < 0.96 };
};
const pts = ribbons.labels(split).flatMap((l) => {
const a = onScreen(l.pos);
const b = a.ok ? a : onScreen(l.near);
return b.ok ? [{ ...l, x: b.x, y: b.y }] : [];
});
const groups = new Map<string, { song: number; ahead: boolean; ids: string[]; x: number; y: number }>();
for (const pt of pts) {
const k = `${pt.song}:${pt.ahead}`;
const g = groups.get(k);
if (g) { g.ids.push(pt.id); g.x = (g.x + pt.x) / 2; g.y = Math.max(g.y, pt.y); }
else groups.set(k, { song: pt.song, ahead: pt.ahead, ids: [pt.id], x: pt.x, y: pt.y });
}
const list = [...groups.values()].sort((a, b) => b.y - a.y);
const key = list.map((g) => `${g.song}${g.ahead}${g.ids.join()}`).join("|") + `#${songIdx}#${highlightId}`;
if (key !== labelKey) {
labelKey = key;
labels.innerHTML = `<svg class="wl-leaders" aria-hidden="true"></svg>` + list.map((g) => {
const song = songs[g.song]!;
const head = g.ahead ? `Next: ${song.title}` : g.song === songIdx ? "Not heard again after this" : `Last heard: ${song.title}`;
return `<div class="wl"><p class="wl__head">${esc(head)}</p><p class="wl__ms">${g.ids.map((id) => {
const dim = highlightId && highlightId !== id ? " is-dim" : highlightId === id ? " is-on" : "";
return `<span class="wl__m${dim}" style="--c:${colorOf(id)}"><i></i>${esc(motifName(id))}</span>`;
}).join("")}</p></div>`;
}).join("");
}
const els = [...labels.querySelectorAll<HTMLElement>(".wl")];
const top = 132;
const placed: { l: number; r: number; t: number; b: number }[] = [];
let lines = "";
list.forEach((g, i) => {
const el = els[i];
if (!el) return;
const w = el.offsetWidth, h = el.offsetHeight;
// first free slot: straight above the anchor, then stacked higher, then beside, never over another label
const clampL = (x: number) => Math.min(W - w - 12, Math.max(12, x));
const free = (l: number, b: number) => !placed.some((q) => l < q.r + 8 && l + w > q.l - 8 && b > q.t - 8 && b - h < q.b + 8);
const l0 = clampL(g.x - w / 2), b0 = Math.max(top + h, g.y - LIFT);
let l = l0, b = b0;
search: for (const dx of [0, 1, -1, 2, -2, 3, -3]) {
for (let k = 0; k < 6; k++) {
const cl = clampL(l0 + dx * (w + 12)), cb = b0 - k * (h + 10);
if (cb < top + h) break;
if (free(cl, cb)) { l = cl; b = cb; break search; }
}
}
placed.push({ l, r: l + w, t: b - h, b });
el.style.transform = `translate(${Math.round(l)}px, ${Math.round(b - h)}px)`;
const ax = Math.min(l + w - 14, Math.max(l + 14, g.x));
lines += `<path d="M${ax.toFixed(1)},${b.toFixed(1)} L${g.x.toFixed(1)},${(g.y - 6).toFixed(1)}"/><circle cx="${g.x.toFixed(1)}" cy="${g.y.toFixed(1)}" r="4"/>`;
});
const svg = labels.querySelector<SVGSVGElement>(".wl-leaders");
if (svg && svg.innerHTML !== lines) svg.innerHTML = lines;
}
let highlightId: string | null = null;
const motifName = (id: string) => MOTIF_NAMES.get(id) ?? id;
/* ---------- the interface ---------- */
const world: World = {
sailTo(songId, opts = {}) {
const stop = stopOf(songId);
if (!stop) return Promise.resolve();
songIdx = songById(songId)!.track - 1;
sail?.done();
sail = null;
const distance = Math.abs(stop.s - s) * ROUTE_LENGTH;
if (opts.instant || reduced || distance < 0.01) {
s = stop.s;
camHeading = shipYaw = stop.heading;
render(performance.now());
return Promise.resolve();
}
return new Promise<void>((resolve) => {
sail = { from: s, to: stop.s, t0: performance.now(), dur: Math.min(3600, Math.max(1300, 900 + distance * 7)), done: resolve };
});
},
setLanding(id) {
if ((landing?.id ?? null) === id) return Promise.resolve();
if (landing) {
landing.done?.();
landing.obj.dispose();
landing = null;
}
if (!id) { render(performance.now()); return Promise.resolve(); }
const obj = LANDINGS[id]();
obj.camera.userData.fov = obj.camera.fov;
return new Promise<void>((resolve) => {
landing = { id, obj, t0: performance.now(), done: resolve };
applyView(obj.camera, obj.camera.userData.fov);
render(performance.now());
});
},
setRibbons(motifIds, highlight) {
ribbonIds = motifIds && motifIds.length ? [...motifIds] : null;
ribbons.set(ribbonIds);
ribbons.group.visible = !!ribbonIds;
highlightId = highlight;
ribbons.setHighlight(highlight);
labelKey = "";
labels.hidden = !ribbonIds || !!landing;
if (!ribbonIds) labels.innerHTML = "";
},
horizonFromFrieze(groundYpx) {
const h = canvas.clientHeight;
if (!h) return;
lock = { at: groundYpx / h, t0: performance.now() };
render(performance.now());
},
resize,
dispose() {
disposed = true;
win.cancelAnimationFrame(raf);
ro?.disconnect();
sail?.done();
landing?.done?.();
landing?.obj.dispose();
landing = null;
ribbons.dispose();
disposeTree(sea);
renderer.dispose();
labels.remove();
},
};
const ro = typeof ResizeObserver === "function" ? new ResizeObserver(() => resize()) : null;
ro?.observe(canvas);
resize();
render(performance.now());
raf = win.requestAnimationFrame(loop);
return world;
}
+85
View File
@@ -0,0 +1,85 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { sagas, songsInSaga } from "../src/data/album";
import { friezeSvg } from "../src/app/frieze/sagas";
const escapeXml = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
describe("saga friezes", () => {
test.each(sagas.map((s) => [s.id, s] as const))("%s: names the saga and inscribes every song", (_id, saga) => {
const svg = friezeSvg(saga.id);
expect(svg.startsWith("<svg")).toBe(true);
expect(svg).toContain(escapeXml(saga.name));
for (const song of songsInSaga(saga.id)) expect(svg).toContain(escapeXml(song.title));
});
test("titles with apostrophes are escaped, never raw inside markup", () => {
const svg = friezeSvg("wisdom");
expect(svg).toContain("We&#39;ll Be Fine");
expect(svg).not.toContain("We'll Be Fine");
});
test("every saga has its own frieze", () => {
const all = sagas.map((s) => friezeSvg(s.id));
expect(new Set(all).size).toBe(9);
});
test("landing sagas have a doorway to go ashore through; the others do not", () => {
for (const s of sagas) {
const has = friezeSvg(s.id).includes('class="door-fill ');
expect(has).toBe(s.id === "cyclops" || s.id === "wisdom" || s.id === "ithaca");
}
});
});
describe("the curtain (happy-dom, reduced motion: 200ms crossfades)", () => {
const { GlobalRegistrator } = require("@happy-dom/global-registrator") as typeof import("@happy-dom/global-registrator");
let createCurtain: typeof import("../src/app/frieze/curtain").createCurtain;
let createWorld: typeof import("../src/app/world/world").createWorld;
beforeAll(async () => {
GlobalRegistrator.register();
window.matchMedia = ((q: string) => ({ matches: q.includes("reduce"), media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
({ createCurtain } = await import("../src/app/frieze/curtain"));
({ createWorld } = await import("../src/app/world/world"));
});
afterAll(() => GlobalRegistrator.unregister());
test("drop shows the saga's frieze; lift clears it", async () => {
const layer = document.createElement("div");
document.body.append(layer);
const c = createCurtain(layer);
await c.drop("cyclops", { landing: "cave" });
const host = layer.querySelector<HTMLElement>(".fz-host")!;
expect(host.hidden).toBe(false);
expect(host.innerHTML).toContain("The Cyclops Saga");
await c.lift();
expect(host.hidden).toBe(true);
expect(Number.isFinite(c.groundY())).toBe(true);
});
test("rapid calls never leave the curtain stuck: the last request wins", async () => {
const layer = document.createElement("div");
const c = createCurtain(layer);
const all = [c.drop("troy"), c.fade(), c.drop("ocean"), c.drop("circe")];
const last = c.lift();
await Promise.all([...all, last]);
expect(layer.querySelector<HTMLElement>(".fz-host")!.hidden).toBe(true);
expect(layer.querySelector<HTMLElement>(".fz-cover")!.hidden).toBe(true);
});
test("fade covers the stage in the curtain colour; lift uncovers it", async () => {
const layer = document.createElement("div");
const c = createCurtain(layer);
await c.fade();
const cover = layer.querySelector<HTMLElement>(".fz-cover")!;
expect(cover.hidden).toBe(false);
await c.lift();
expect(cover.hidden).toBe(true);
});
test("createWorld returns null without WebGL", () => {
const canvas = document.createElement("canvas");
canvas.getContext = (() => null) as typeof canvas.getContext;
expect(createWorld(canvas)).toBeNull();
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test";
import { sagas, songs } from "../src/data/album";
import { PLACES, pointAt, sIndex, stops } from "../src/app/world/route";
describe("route geometry", () => {
test("nine places in saga order", () => {
expect(PLACES.length).toBe(9);
expect(PLACES.map((p) => p.id)).toEqual(sagas.map((s) => s.id));
});
test("one stop per song, in album order, s strictly increasing from 0 to 1", () => {
const st = stops();
expect(st.length).toBe(40);
expect(st.map((s) => s.songId)).toEqual(songs.map((s) => s.id));
expect(st[0]!.s).toBe(0);
expect(st.at(-1)!.s).toBe(1);
for (let i = 1; i < st.length; i++) expect(st[i]!.s).toBeGreaterThan(st[i - 1]!.s);
});
test("each stop lies at its saga's place, within 1.5× its radius", () => {
for (const st of stops()) {
const song = songs.find((s) => s.id === st.songId)!;
expect(st.placeId).toBe(song.sagaId);
const place = PLACES.find((p) => p.id === st.placeId)!;
expect(Math.hypot(st.x - place.x, st.z - place.z)).toBeLessThanOrEqual(1.5 * place.radius);
}
});
test("the ship never anchors inside an island: stops keep clear of the place's centre", () => {
for (const st of stops()) {
const place = PLACES.find((p) => p.id === st.placeId)!;
expect(Math.hypot(st.x - place.x, st.z - place.z)).toBeGreaterThan(0.75 * place.radius);
}
});
test("stops are close within a saga and farther apart between sagas", () => {
const st = stops();
const d = (a: number, b: number) => Math.hypot(st[a]!.x - st[b]!.x, st[a]!.z - st[b]!.z);
let maxWithin = 0, minBetween = Infinity;
for (let i = 1; i < st.length; i++) {
if (st[i]!.placeId === st[i - 1]!.placeId) maxWithin = Math.max(maxWithin, d(i, i - 1));
else minBetween = Math.min(minBetween, d(i, i - 1));
}
expect(minBetween).toBeGreaterThan(3 * maxWithin);
});
test("pointAt follows the route through every stop, with headings along the way", () => {
for (const st of stops()) {
const p = pointAt(st.s);
expect(p.x).toBeCloseTo(st.x, 6);
expect(p.z).toBeCloseTo(st.z, 6);
expect(p.heading).toBeCloseTo(st.heading, 6);
}
const mid = pointAt(0.5);
expect(Number.isFinite(mid.x) && Number.isFinite(mid.z) && Number.isFinite(mid.heading)).toBe(true);
expect(pointAt(-1)).toEqual(pointAt(0));
expect(pointAt(2)).toEqual(pointAt(1));
});
test("sIndex maps route position to a fractional song index and back", () => {
const st = stops();
st.forEach((stop, i) => expect(sIndex(stop.s)).toBeCloseTo(i, 6));
const half = (st[5]!.s + st[6]!.s) / 2;
expect(sIndex(half)).toBeCloseTo(5.5, 6);
});
});