diff --git a/docs/plans/002-voyage-app.md b/docs/plans/002-voyage-app.md index a135be6..76f0cbd 100644 --- a/docs/plans/002-voyage-app.md +++ b/docs/plans/002-voyage-app.md @@ -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 diff --git a/src/app/frieze/curtain.ts b/src/app/frieze/curtain.ts new file mode 100644 index 0000000..91ecff5 --- /dev/null +++ b/src/app/frieze/curtain.ts @@ -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; + /** Uncover the stage: part along the ground line, zoom through the doorway (after a landing drop), or fade out a fade(). */ + lift(): Promise; + /** Cover the stage in the curtain colour (for jumps across several sagas); lift() uncovers it. */ + fade(): Promise; + /** 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 { + 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 = (sel: string) => host.querySelector(sel); + parts = { + zoom: q(".f-zoom"), top: q(".f-top"), bottom: q(".f-bottom"), + figs: q(".f-figs"), line: q(".horizon-line"), doors: [...host.querySelectorAll(".door-fill")], + masked: q("g[mask]"), mask: q("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 { + 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 { + 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 { + 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 }; +} diff --git a/src/app/frieze/kit.ts b/src/app/frieze/kit.ts new file mode 100644 index 0000000..1149695 --- /dev/null +++ b/src/app/frieze/kit.ts @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); + +export const g = (t: string, body: string) => `${body}`; +export const inc = (d: string, w = 1.6) => ``; +export const ink = (d: string) => ``; +/** 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) => + ``; + +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 += ``; + 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 += ``; + } 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 += ``; + upper += ``; + upper += ``; + } + // 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 += `` + 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 += ``; + upper += `` + ``; + 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) + ``; + if (p.infant) { + upper += ``; + 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 += ``; + b += inc("M-6,-150 h28 v22 h-28 Z", 1.4); + for (const wx of [-66, 56]) { + b += ``; + 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 += ``; + } + if (o.broken) { + b += limb("M0,-50 L0,-150", 5) + limb("M0,-150 L40,-196", 5); + } else { + b += ``; + 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 += ``; + 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 += ``; + 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 ``; +} + +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") + + ``); +} + +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 += ``; + 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 += ``; + b += `` + 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 += ``; + b += ``; + b += `` + 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 += ``; + 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") + ``); + } + 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 `` + 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 += ``; + } + const volutes = ``; + return g(`translate(${cx},${cy}) scale(1,${flip ? -1 : 1})`, leaves + `` + 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 }); diff --git a/src/app/frieze/sagas.ts b/src/app/frieze/sagas.ts new file mode 100644 index 0000000..77f85d1 --- /dev/null +++ b/src/app/frieze/sagas.ts @@ -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 = { 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) => + ``; + return ` + ${meander("mt", BAND_TOP + 14)}${meander("mb", BOT_Y + 8)} + + + + + + ${g("translate(21,60) scale(0.66)", palmette(0, 0))}${g("translate(63,18) scale(0.66)", palmette(0, 0, true))} + + + + + + + + + + + + `; +} + +/** 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) => ``; + const top = ` + + + ${rule(BAND_TOP - NECK_H + 4)} + + ${rule(BAND_TOP + 8)} + + ${rule(BAND_TOP + 46)} + + `; + const bottom = ` + + + + ${rule(BOT_Y + 2)} + + ${rule(BOT_Y + 40)} + + `; + 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})`, + `${palmette(66, 52)}${palmette(66, 248, true)} + ${esc(short)}`); +} +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 += ``; + return `${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 += ``; + 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 += ``; + 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 += ``; + 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 }) + `` + ink("M180,104 L174,84 L188,100 Z") + `` + 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 += `${esc(song.title)}${g(t, v.art)}`; + const lines = wrap(song.title.toUpperCase(), Math.floor(slot / CHAR_W)); + const cx = sx + slot / 2; + figs += `${lines + .map((l, j) => `${esc(l)}`).join("")}`; + if (v.door) { + doorMask += ``; + doorFill += ``; + // 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}${figs}`; + const masked = doorMask + ? ` + ${doorMask} + ${body}${doorFill}` + : body; + const svg = ``; + return { svg, layout: { door } }; +} + +const cache = new Map(); +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; +} diff --git a/src/app/world/islands.ts b/src/app/world/islands.ts new file mode 100644 index 0000000..bcce791 --- /dev/null +++ b/src/app/world/islands.ts @@ -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 + }`, + }); +} + +/** 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; + +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 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> = { vengeance: 26 }; diff --git a/src/app/world/landings/arena.ts b/src/app/world/landings/arena.ts new file mode 100644 index 0000000..e9656c4 --- /dev/null +++ b/src/app/world/landings/arena.ts @@ -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); }, + }; +} diff --git a/src/app/world/landings/cave.ts b/src/app/world/landings/cave.ts new file mode 100644 index 0000000..c9428c6 --- /dev/null +++ b/src/app/world/landings/cave.ts @@ -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); }, + }; +} diff --git a/src/app/world/landings/figures.ts b/src/app/world/landings/figures.ts new file mode 100644 index 0000000..540a9b9 --- /dev/null +++ b/src/app/world/landings/figures.ts @@ -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; +} diff --git a/src/app/world/landings/hall.ts b/src/app/world/landings/hall.ts new file mode 100644 index 0000000..bf35d67 --- /dev/null +++ b/src/app/world/landings/hall.ts @@ -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); }, + }; +} diff --git a/src/app/world/materials.ts b/src/app/world/materials.ts new file mode 100644 index 0000000..9f230fc --- /dev/null +++ b/src/app/world/materials.ts @@ -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(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 + }`, + }); + 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(); + 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; +} diff --git a/src/app/world/ribbons.ts b/src/app/world/ribbons.ts new file mode 100644 index 0000000..a766bc0 --- /dev/null +++ b/src/app/world/ribbons.ts @@ -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 ", `#include \n${WAVE_GLSL}\nattribute float aU;\nattribute float aD;\nvarying float vU;\nvarying float vD;\nvarying float vDepth;`) + .replace("#include ", `#include \nvU = aU; vD = aD;\ntransformed.y += seaWave((modelMatrix * vec4(transformed, 1.0)).xz) + ${LIFT.toFixed(2)};`) + .replace("#include ", "#include \nvDepth = -mvPosition.z;"); + sh.fragmentShader = sh.fragmentShader + .replace("#include ", "#include \nuniform float uSplit;\nuniform float uNear;\nvarying float vU;\nvarying float vD;\nvarying float vDepth;") + .replace("#include ", "#include \ndiffuseColor.a *= smoothstep(uNear * 0.45, uNear, vDepth);") + .replace("#include ", `#include + // 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]!; diff --git a/src/app/world/route.ts b/src/app/world/route.ts new file mode 100644 index 0000000..c555866 --- /dev/null +++ b/src/app/world/route.ts @@ -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 = Object.fromEntries(WAYPOINTS.map((w) => [w.id, w.side])) as Record; + +/* ---------- 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); +} diff --git a/src/app/world/ship.ts b/src/app/world/ship.ts new file mode 100644 index 0000000..96420cf --- /dev/null +++ b/src/app/world/ship.ts @@ -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; +} diff --git a/src/app/world/world.css b/src/app/world/world.css index 18c3f38..7d1a8e0 100644 --- a/src/app/world/world.css +++ b/src/app/world/world.css @@ -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; } diff --git a/src/app/world/world.ts b/src/app/world/world.ts new file mode 100644 index 0000000..eb94580 --- /dev/null +++ b/src/app/world/world.ts @@ -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; + setLanding(id: LandingId | null): Promise; + 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 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 ", `#include \n${WAVE_GLSL}\nvarying float vH;\nvarying vec2 vWp;`) + .replace("#include ", `#include + 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 ", `#include \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 ", "#include \nif (uHole.z > 0.0 && distance(vWp, uHole.xy) < uHole.z) discard;") + .replace("#include ", `#include + // 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(); + 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 = `` + 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 `

${esc(head)}

${g.ids.map((id) => { + const dim = highlightId && highlightId !== id ? " is-dim" : highlightId === id ? " is-on" : ""; + return `${esc(motifName(id))}`; + }).join("")}

`; + }).join(""); + } + const els = [...labels.querySelectorAll(".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 += ``; + }); + const svg = labels.querySelector(".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((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((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; +} diff --git a/tests/frieze.test.ts b/tests/frieze.test.ts new file mode 100644 index 0000000..dc04906 --- /dev/null +++ b/tests/frieze.test.ts @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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(" { + const svg = friezeSvg("wisdom"); + expect(svg).toContain("We'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(".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(".fz-host")!.hidden).toBe(true); + expect(layer.querySelector(".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(".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(); + }); +}); diff --git a/tests/route.test.ts b/tests/route.test.ts new file mode 100644 index 0000000..7bd9cc7 --- /dev/null +++ b/tests/route.test.ts @@ -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); + }); +});