Files
Epic-The-Musical-Timeline/src/app/frieze/kit.ts
T
Lucas WintherandClaude Opus 5.5 ac017787b5 feat(app): 3D world, landings and friezes
Nine stylised places on one route, ship and ribbons, the cave, arena and
hall landings, nine saga friezes and the curtain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:08:11 +01:00

297 lines
16 KiB
TypeScript

// The black-figure figure kit for the saga friezes. Copied from mockup 17's figures.ts (via 04 and 13;
// mockups are never imported) and extended with the props the nine sagas need: tridents, bows, wings,
// long hair, pigs, cattle, trees, columns, cliffs, a whirlpool and Scylla's heads.
// Glaze is `fill: var(--glaze)` (.ink); "incised" detail is scratched back to the clay (.inc).
// Every vignette shares one 300-unit-tall panel space with the ground line at GROUND.
export const PANEL_H = 300;
export const GROUND = 284;
/** Escapes text for SVG/HTML markup, apostrophes included. */
export const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
export const g = (t: string, body: string) => `<g transform="${t}">${body}</g>`;
export const inc = (d: string, w = 1.6) => `<path class="inc" d="${d}" stroke-width="${w}"/>`;
export const ink = (d: string) => `<path class="ink" d="${d}"/>`;
/** A limb: a glaze stroke over a wider clay stroke, so it reads as incised where it crosses glaze. */
export const limb = (d: string, w = 6) =>
`<path class="limb-edge" d="${d}" stroke-width="${w + 3}"/><path class="limb" d="${d}" stroke-width="${w}"/>`;
type Arms = "spear" | "open" | "down" | "raise" | "reach" | "mourn" | "club" | "throw" | "bow" | "bolt";
export interface Person {
x: number; y?: number; s?: number; f?: 1 | -1; arms?: Arms;
helmet?: boolean; shield?: boolean; robe?: boolean; cyclops?: boolean; kneel?: boolean;
beard?: boolean; infant?: boolean; cup?: boolean;
/** Long hair down the back (women, goddesses). */
hair?: boolean; wings?: boolean; crown?: boolean; trident?: boolean; bag?: boolean; staff?: boolean;
}
export function person(p: Person): string {
const { x, y = GROUND, s = 1, f = 1, arms = "down" } = p;
const lift = p.kneel ? 36 : 0;
let legs = "";
if (p.kneel) {
legs = limb("M4,-44 L34,-40 L36,-2", 8) + limb("M-2,-44 L-24,-4 L-4,-2", 8);
} else if (!p.robe) {
legs = limb("M-2,-80 L-10,-40 L-24,-2 L-14,-2", 8) + limb("M4,-80 L14,-40 L30,-2 L40,-2", 8);
}
let upper = "";
if (p.wings) {
upper += ink("M-8,-150 Q-60,-196 -78,-150 Q-62,-152 -66,-132 Q-50,-138 -50,-118 Q-34,-126 -10,-118 Z");
upper += inc("M-14,-142 Q-44,-160 -66,-150 M-14,-130 Q-38,-142 -58,-132", 1.4);
}
if (p.hair) upper += ink("M-8,-176 Q-20,-150 -14,-128 L-2,-132 Q-6,-152 2,-168 Z");
// the back arm sits behind the torso
if (arms === "open") upper += limb("M-8,-148 L-36,-176 L-44,-196");
if (arms === "mourn") upper += limb("M-8,-148 L-14,-120 L-6,-104");
if (arms === "bow") upper += limb("M-8,-148 L6,-150 L18,-152");
upper += ink("M-3,-160 h8 v10 h-8 Z");
upper += ink("M-13,-154 L15,-154 L9,-104 L-5,-104 Z");
if (p.robe) {
upper += ink("M-9,-108 L11,-108 L28,-2 L-24,-2 Z");
upper += inc("M1,-100 L2,-8 M-5,-96 L-14,-8 M7,-96 L18,-8 M-20,-30 L24,-30");
} else {
upper += ink("M-8,-108 L10,-108 L20,-74 L-18,-74 Z");
upper += inc("M1,-104 L1,-77 M-5,-100 L-10,-77 M7,-100 L12,-77");
}
upper += inc("M-9,-146 L11,-146 M-2,-150 L1,-112");
// head
upper += `<circle class="ink" cx="2" cy="-168" r="11"/>`;
upper += ink("M10,-173 L18,-166 L10,-162 Z");
if (p.beard) upper += ink("M-2,-162 L13,-162 L5,-146 Z") + inc("M2,-158 L5,-150", 1.2);
if (p.cyclops) {
upper += `<circle class="clay" cx="7" cy="-171" r="5"/><circle class="ink" cx="8.5" cy="-171" r="2.4"/>`;
} else {
upper += inc("M5,-171 h5", 1.4);
}
if (p.helmet) {
upper += ink("M-12,-166 A14 14 0 0 1 15,-170 L15,-160 L9,-160 L9,-166 Z");
upper += ink("M-18,-178 Q-8,-214 24,-194 Q4,-200 -8,-176 Z");
upper += inc("M-10,-166 L13,-168 M4,-176 L4,-166");
} else if (p.crown) {
upper += ink("M-9,-176 L-7,-190 L-2,-180 L2,-194 L6,-180 L11,-190 L13,-176 Z");
} else if (!p.cyclops) {
upper += inc("M-6,-176 Q2,-182 10,-176", 1.3);
}
if (p.shield) {
upper += `<circle class="ink" cx="12" cy="-124" r="32"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="26" stroke-width="1.6"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="7" stroke-width="1.6"/>`;
}
// front arm
switch (arms) {
case "spear":
upper += limb("M10,-148 L30,-132 L40,-150");
upper += limb("M-4,-52 L72,-222", 3.5);
upper += p.trident
? ink("M72,-222 L80,-246 L74,-224 Z M64,-226 L58,-248 L70,-228 Z M80,-218 L96,-238 L82,-214 Z") + limb("M62,-228 L84,-214", 3)
: ink("M72,-222 L66,-244 L80,-226 Z");
break;
case "open": upper += limb("M10,-148 L40,-178 L48,-198"); break;
case "raise": upper += limb("M10,-148 L22,-182 L26,-214"); break;
case "reach": upper += limb("M10,-148 L36,-140 L56,-146"); break;
case "mourn": upper += limb("M10,-148 L26,-136 L12,-172"); break;
case "club":
upper += limb("M10,-148 L30,-130 L40,-142");
upper += limb("M18,-96 L66,-214", 10) + ink("M60,-218 Q66,-236 78,-214 Z");
break;
case "throw":
upper += limb("M10,-148 L-6,-182 L-12,-214");
upper += `<circle class="ink" cx="-16" cy="-232" r="18"/>` + inc("M-26,-238 Q-16,-246 -6,-236 M-24,-226 l10,2", 1.4);
break;
case "bow":
upper += limb("M10,-148 L38,-150 L60,-152");
upper += `<path class="limb" d="M54,-206 Q86,-152 54,-98" stroke-width="4.5"/>`;
upper += `<path class="rig" d="M54,-206 L18,-152 L54,-98"/>` + `<path class="rig" d="M18,-152 L92,-152" stroke-width="2.2"/>`;
upper += ink("M92,-152 L82,-157 L84,-152 L82,-147 Z");
break;
case "bolt":
upper += limb("M10,-148 L22,-182 L26,-210");
upper += g("translate(14,-262) scale(0.62)", ink("M0,0 L22,0 L10,34 L24,34 L-6,92 L2,48 L-12,48 Z"));
break;
default: upper += limb("M10,-148 L16,-104");
}
if (p.staff) upper += limb("M14,-100 L22,-236", 3.5) + `<circle class="ink" cx="22" cy="-240" r="6"/>`;
if (p.infant) {
upper += `<ellipse class="ink" cx="58" cy="-150" rx="7.5" ry="17" transform="rotate(-14 58 -150)"/><circle class="ink" cx="63" cy="-174" r="7"/>`;
upper += inc("M52,-158 l12,-3 M53,-148 l12,-3 M54,-138 l11,-3", 1.3);
}
if (p.cup) {
upper += ink("M48,-162 h26 q-2,12 -13,12 q-11,0 -13,-12 Z M60,-150 v5 h-5 h10");
upper += inc("M52,-158 h18", 1.2);
}
if (p.bag) {
upper += ink("M54,-146 Q46,-138 50,-116 Q58,-96 76,-104 Q88,-118 76,-138 Q70,-146 60,-146 Z M55,-150 h8 l-2,6 h-5 Z");
upper += inc("M58,-130 q8,6 16,0 M56,-118 q10,6 20,-2", 1.3);
}
const body = legs + (lift ? g(`translate(0,${lift})`, upper) : upper);
return g(`translate(${x},${y}) scale(${f * s},${s})`, body);
}
export function trojanHorse(x: number, s: number): string {
let b = "";
b += ink("M-92,-32 h176 v12 h-176 Z");
for (const lx of [-60, -42, 30, 48]) b += limb(`M${lx},-112 L${lx},-30`, 10);
b += limb("M-80,-150 Q-108,-128 -98,-88", 6);
b += ink("M-84,-164 h150 q10,0 10,12 v36 q0,14 -14,14 h-132 q-14,0 -14,-14 v-36 q0,-12 12,-12 Z");
b += ink("M40,-156 L60,-236 L88,-230 L68,-140 Z");
b += ink("M58,-238 Q70,-256 100,-244 L124,-208 Q120,-196 106,-202 L84,-224 Z M62,-246 L68,-266 L76,-244 Z");
b += inc("M-76,-146 H62 M-76,-130 H62 M-76,-116 H62 M-40,-160 V-104 M0,-160 V-104 M36,-160 V-104");
b += inc("M62,-228 l6,-6 l2,8 l6,-6 l2,8 l6,-6 M50,-196 l6,-6 l2,8 l6,-6", 1.4);
b += `<circle class="inc" cx="90" cy="-232" r="3" stroke-width="1.6"/>`;
b += inc("M-6,-150 h28 v22 h-28 Z", 1.4);
for (const wx of [-66, 56]) {
b += `<circle class="ink" cx="${wx}" cy="-12" r="13"/><circle class="inc" cx="${wx}" cy="-12" r="8" stroke-width="1.4"/>`;
b += inc(`M${wx - 8},-12 h16 M${wx},-20 v16`, 1.2);
}
return g(`translate(${x},${GROUND}) scale(${s})`, b);
}
export function ship(x: number, y: number, s: number, o: { sail?: boolean; tilt?: number; f?: 1 | -1; broken?: boolean } = {}): string {
const f = o.f ?? 1;
let b = "";
for (let i = 0; i < 9; i++) {
const ox = -96 + i * 24;
b += `<path class="oar" d="M${ox},-44 L${ox - 18},6" />`;
}
if (o.broken) {
b += limb("M0,-50 L0,-150", 5) + limb("M0,-150 L40,-196", 5);
} else {
b += `<path class="rig" d="M-84,-218 L-130,-60 M84,-218 L120,-48 M0,-232 L150,-90"/>`;
b += limb("M0,-50 L0,-236", 5);
if (o.sail) {
b += ink("M-78,-222 h156 v104 q-78,-18 -156,0 Z");
let grid = "";
for (let gx = -58; gx < 78; gx += 20) grid += `M${gx},-220 V-122 `;
grid += "M-76,-196 H76 M-76,-172 H76 M-76,-148 H76";
b += inc(grid, 1.3);
}
b += limb("M-86,-222 L86,-222", 4);
}
for (let i = 0; i < 8; i++) b += `<circle class="ink" cx="${-84 + i * 24}" cy="-62" r="6.5"/>`;
b += ink("M-160,-104 Q-150,-52 -108,-30 L118,-30 L178,-36 L132,-46 L140,-84 L128,-86 Q122,-54 100,-54 L-100,-54 Q-140,-58 -150,-104 Z");
b += inc("M-112,-40 H120 M-118,-48 H104", 1.4);
b += `<circle class="clay" cx="122" cy="-44" r="4.5"/><circle class="ink" cx="123" cy="-44" r="2"/>`;
return g(`translate(${x},${y}) rotate(${o.tilt ?? 0}) scale(${f * s},${s})`, b);
}
export function waves(x0: number, x1: number, y: number, size = 1): string {
let d = "";
const step = 36 * size;
for (let x = x0; x < x1 - step / 2; x += step) {
d += `M${x},${y} C${x + 8 * size},${y - 22 * size} ${x + 34 * size},${y - 24 * size} ${x + 34 * size},${y - 8 * size} C${x + 34 * size},${y} ${x + 22 * size},${y + 2 * size} ${x + 20 * size},${y - 6 * size} `;
}
return `<path class="wave" d="${d}" stroke-width="${4 * size}"/><path class="wave" d="M${x0},${y + 2} H${x1}" stroke-width="3"/>`;
}
export function stele(x: number): string {
return g(`translate(${x},${GROUND})`,
ink("M-36,0 v-12 h72 v12 Z M-24,-12 V-150 L0,-178 L24,-150 V-12 Z") +
inc("M-18,-146 H18 M-14,-60 H14 M-14,-48 H14 M-14,-36 H14") +
`<circle class="inc" cx="0" cy="-112" r="12" stroke-width="1.6"/><circle class="inc" cx="0" cy="-112" r="5" stroke-width="1.2"/>`);
}
export const bolt = (x: number, y: number, s: number) =>
g(`translate(${x},${y}) scale(${s})`, ink("M0,0 L22,0 L10,34 L24,34 L-6,92 L2,48 L-12,48 Z"));
export function sheep(x: number, s: number, f: 1 | -1 = 1): string {
let b = "";
b += limb("M-14,-14 L-16,0", 4) + limb("M-4,-14 L-3,0", 4) + limb("M12,-14 L13,0", 4) + limb("M20,-14 L22,0", 4);
b += ink("M-24,-30 Q-26,-46 -10,-48 Q2,-58 14,-48 Q30,-50 28,-32 Q32,-16 16,-14 L-14,-14 Q-30,-14 -24,-30 Z");
b += inc("M-14,-38 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0 M-16,-26 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0", 1.3);
b += ink("M26,-40 L40,-44 Q46,-38 40,-30 L28,-30 Z");
b += `<circle class="clay" cx="38" cy="-39" r="1.6"/>`;
return g(`translate(${x},${GROUND}) scale(${f * s},${s})`, b);
}
/** Circe's work: a pig, snout forward, curly tail. */
export function pig(x: number, s: number, f: 1 | -1 = 1): string {
let b = "";
b += limb("M-16,-12 L-18,0", 5) + limb("M-6,-12 L-5,0", 5) + limb("M12,-12 L13,0", 5) + limb("M20,-12 L22,0", 5);
b += ink("M-30,-26 Q-30,-46 -6,-48 Q22,-50 30,-36 L42,-34 Q46,-28 42,-22 L30,-20 Q22,-10 -6,-10 Q-30,-10 -30,-26 Z");
b += ink("M22,-46 L30,-58 L32,-44 Z");
b += `<path class="limb" d="M-30,-30 q-10,-2 -8,-10 q2,-6 6,-2" stroke-width="2.4"/>`;
b += `<circle class="clay" cx="30" cy="-38" r="1.8"/>` + inc("M40,-30 v4", 1.4) + inc("M-14,-22 Q4,-16 18,-24", 1.3);
return g(`translate(${x},${GROUND}) scale(${f * s},${s})`, b);
}
/** Helios' cattle: a broad bull with lyre horns. */
export function bull(x: number, s: number, f: 1 | -1 = 1): string {
let b = "";
b += limb("M-30,-24 L-32,0", 7) + limb("M-16,-24 L-14,0", 7) + limb("M22,-24 L24,0", 7) + limb("M34,-24 L36,0", 7);
b += ink("M-44,-46 Q-44,-70 -14,-72 L30,-72 Q48,-70 50,-52 L62,-60 Q74,-58 72,-44 L60,-36 Q50,-22 30,-22 L-30,-22 Q-44,-24 -44,-46 Z");
b += `<path class="limb" d="M-44,-54 q-12,10 -8,30" stroke-width="3"/>`;
b += `<path class="limb" d="M62,-60 q-4,-14 6,-20 M66,-58 q10,-10 18,-6" stroke-width="3.2"/>`;
b += `<circle class="clay" cx="64" cy="-50" r="2"/>` + inc("M-30,-60 Q0,-52 34,-60 M-24,-36 Q4,-30 30,-38", 1.4);
return g(`translate(${x},${GROUND}) scale(${f * s},${s})`, b);
}
/** A tree: Calypso's palm or Ithaca's olive. */
export function tree(x: number, s: number, kind: "palm" | "olive"): string {
let b = "";
if (kind === "palm") {
b += limb("M0,0 Q-6,-80 6,-170", 9);
for (let i = -3; i <= 3; i++) b += `<ellipse class="ink" cx="6" cy="-200" rx="7" ry="36" transform="rotate(${i * 28} 6 -170)"/>`;
b += inc("M-2,-30 h8 M-3,-60 h8 M-2,-90 h8 M0,-120 h8 M3,-150 h7", 1.4);
} else {
b += limb("M0,0 Q-10,-50 4,-100 M2,-90 Q-24,-110 -34,-128 M4,-100 Q26,-112 34,-134", 8);
b += ink("M-58,-130 Q-64,-170 -30,-176 Q-16,-206 16,-196 Q50,-200 54,-166 Q72,-140 44,-124 Q20,-112 -4,-122 Q-40,-108 -58,-130 Z");
b += inc("M-40,-150 q8,-6 14,0 M-10,-170 q8,-6 14,0 M20,-156 q8,-6 14,0 M-24,-132 q8,-6 14,0 M28,-136 q8,-6 14,0", 1.4);
}
return g(`translate(${x},${GROUND}) scale(${s})`, b);
}
/** A Doric column, base on the ground line. */
export function column(x: number, h: number, w = 26): string {
const half = w / 2;
let b = ink(`M${-half - 6},0 h${w + 12} v-8 h${-w - 12} Z`);
b += ink(`M${-half},-8 L${-half + 2},${-h + 14} h${w - 4} L${half},-8 Z`);
b += ink(`M${-half - 8},${-h + 14} h${w + 16} v-8 h${-w - 16} Z M${-half - 4},${-h + 6} h${w + 8} v-6 h${-w - 8} Z`);
b += inc(`M${-half / 2},-12 V${-h + 18} M0,-12 V${-h + 18} M${half / 2},-12 V${-h + 18}`, 1.3);
return g(`translate(${x},${GROUND})`, b);
}
/** A jagged sea cliff rising from the ground line. */
export function cliff(x: number, w: number, h: number, f: 1 | -1 = 1): string {
const b = ink(`M0,0 L0,${-h * 0.7} L${w * 0.18},${-h} L${w * 0.42},${-h * 0.86} L${w * 0.56},${-h * 0.62} L${w * 0.8},${-h * 0.5} L${w},0 Z`) +
inc(`M${w * 0.14},${-h * 0.62} q${w * 0.1},${-h * 0.12} ${w * 0.2},${-h * 0.1} M${w * 0.3},${-h * 0.34} q${w * 0.16},${-h * 0.08} ${w * 0.3},0 M${w * 0.62},${-h * 0.4} q${w * 0.1},${h * 0.04} ${w * 0.16},${h * 0.16}`, 1.8);
return g(`translate(${x},${GROUND}) scale(${f},1)`, b);
}
/** Scylla: long necks out of a cave in the cliff, each ending in a snapping head. */
export function scyllaHeads(x: number, y: number, s: number): string {
let b = "";
const necks: [string, number, number, number][] = [
["M0,0 Q40,-60 90,-40", 90, -40, -10], ["M0,0 Q60,-10 110,24", 110, 24, 20], ["M0,0 Q20,-110 70,-110", 70, -110, -24], ["M0,0 Q70,40 120,80", 120, 80, 34],
];
for (const [d, hx, hy, r] of necks) {
b += limb(d, 9);
b += g(`translate(${hx},${hy}) rotate(${r})`, ink("M-6,-9 L22,-6 L30,-1 L16,2 L28,7 L-6,9 Z") + `<circle class="clay" cx="8" cy="-3" r="2"/>`);
}
return g(`translate(${x},${y}) scale(${s})`, b);
}
/** Charybdis: a spiral whirlpool seen from the side, as stacked scrolls. */
export function whirlpool(cx: number, y: number, r: number): string {
let d = "";
for (let k = 0; k <= 64; k++) {
const a = (k / 64) * Math.PI * 6;
const rr = r * (1 - k / 70);
d += `${k ? "L" : "M"}${(cx + Math.cos(a) * rr).toFixed(1)},${(y - 30 + Math.sin(a) * rr * 0.32).toFixed(1)}`;
}
return `<path class="wave" d="${d}" stroke-width="4"/>` + ink(`M${cx - 10},${y - 34} L${cx},${y - 6} L${cx + 10},${y - 34} Z`) + waves(cx - r - 20, cx + r + 20, y, 1.1);
}
export function palmette(cx: number, cy: number, flip = false): string {
let leaves = "";
for (let i = -3; i <= 3; i++) {
leaves += `<ellipse class="ink" cx="0" cy="-26" rx="5.5" ry="18" transform="rotate(${i * 22})"/>`;
}
const volutes = `<path class="vol" d="M0,0 C-10,10 -28,8 -28,-4 C-28,-12 -18,-14 -16,-6 M0,0 C10,10 28,8 28,-4 C28,-12 18,-14 16,-6"/>`;
return g(`translate(${cx},${cy}) scale(1,${flip ? -1 : 1})`, leaves + `<circle class="ink" cx="0" cy="-2" r="7"/>` + volutes);
}
/** A shade of the dead: a robed, veiled figure with mourning arms. */
export const shade = (x: number, s: number, f: 1 | -1 = 1) => person({ x, s, f, arms: "mourn", robe: true, hair: true });