feat(mockups): 13 curtain call (frieze → sea → landfall → frieze → cave, look toggle 1/2/3)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
df8ab8d174
commit
e3fd97a78c
@@ -198,6 +198,10 @@ mockup of the transition.
|
||||
→ into the cave. The vellum chart (12) appears as the map on the captain's table at sea.
|
||||
- three.js is allowed in 13 (it is already a dependency; it reuses 07's sea).
|
||||
|
||||
- **13 is finished and committed** (`feat/sea-chart-round`, not pushed); report in
|
||||
`docs/design/002-sea-chart-spikes/13-curtain-call-report.md`. Preview rebuilt with 01–13.
|
||||
Known rough spots: a small sideways camera shuffle at landfall, sun glare in looks 1/2, a boxy giant.
|
||||
|
||||
## Open questions (in priority order)
|
||||
|
||||
1. ~~Which sea-chart approach (08–11) reads best, or which mix?~~ 11's navigation + 10's style (§9).
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 13 Curtain call — report
|
||||
|
||||
Status: **done** (not committed). Built from `13-curtain-call-brief.md`.
|
||||
|
||||
## What was built
|
||||
|
||||
`src/mockups/13-curtain-call/` is a five-beat scripted transition. All of it is a pure function of one
|
||||
number, the beat position `p` (0 = frieze … 4 = cave), so every transition can be played forwards or
|
||||
backwards, or frozen with `?beat=N&t=0..1`.
|
||||
|
||||
| Beat | What you see | Transition into it |
|
||||
|---|---|---|
|
||||
| 1 Frieze | 04's black-figure band: Troy saga column, the horse and the infant, Just a Man, Full Speed Ahead. Caption "Troy falls". | — |
|
||||
| 2 To sea | 07's low-poly sea and ship; a small redraw of 12's general chart lies bottom left as the table map, with "You are here". | 1.8 s. The figures flatten into the ground line. The band splits along that line: the top half slides up, the bottom half slides down. The 3D horizon starts **exactly on the painted ground line**: the camera pitch is solved from the layout. The line fades, and only then does the camera rise over the ship's shoulder. The chart slides in last. |
|
||||
| 3 Landfall | Cyclops' Island with a cave mouth facing the camera; the ship lies off the shore. | 2.0 s. The ship sails a gentle S, the island rises from under the sea, the camera swings to frame both, and "You are here" moves to port 6 on the chart. |
|
||||
| 4 Curtain | The Cyclops frieze: crew (Odysseus with the cup), the cave mouth in the rock, Polyphemus with his club, sheep. Caption "Polyphemus". | 1.2 s. The frieze drops over the sea like a curtain (ease-in-out); the chart is put away as it falls. |
|
||||
| 5 Cave | 3D cave interior: firelit rock, the giant's silhouette with a glinting eye, the fire, the flock. "Back to sea" in the caption. | 1.6 s. The glow in the cave mouth fades and opens. The mouth is a real hole, masked through the whole frieze, so the 3D cave shows through it. The frieze zooms through the mouth (×16) while the cave camera moves in. |
|
||||
|
||||
Controls: Play/Pause (holds each beat for 3.4 s, about 25 s in total), ← / → (also arrow keys, Space
|
||||
to play), five labelled step dots, "Look: 1 · 2 · 3" with the look's name, and `?beat=1..5`,
|
||||
`?look=1|2|3`, `?t=` (freeze) and `?play=1`. A jump that skips a beat, such as a dot two steps away
|
||||
or "Back to sea", fades through the curtain colour in about 0.7 s instead of scrubbing through the
|
||||
beats in between. Under `prefers-reduced-motion`, **every** step is that fade, and the waves slow
|
||||
down. The credits footer is a grid row of its own, outside the stage, so it is visible in every beat
|
||||
and every look.
|
||||
|
||||
Files: `main.ts` (beats, timing, DOM choreography), `frieze.ts` (the two full-stage frieze SVGs,
|
||||
split at the ground line), `figures.ts` (copied from 04, helpers exported), `scene.ts` (trimmed from 07:
|
||||
sky, sea, island, ship, plus a new cave scene, palettes per look), `chart.ts` (simplified 12 chart).
|
||||
|
||||
## Screenshots (1440×900)
|
||||
|
||||
Look 2 (default): `screenshots/13-curtain-call-beat1.png` … `-beat5.png`.
|
||||
Looks 1/3: `-beat2-look1.png`, `-beat2-look3.png`, `-beat4-look1.png`, `-beat4-look3.png`.
|
||||
Mid-transition (frozen with `?t=`): `-morph.png` (1→2 at 40 %), `-morph-look1.png`, `-morph-look3.png`,
|
||||
`-mid-landfall.png` (2→3 at 50 %), `-mid-curtain.png` (3→4 at 50 %), `-mid-cave.png` (4→5 at 50 %).
|
||||
|
||||
**Capture note:** with the brief's `--use-gl=swiftshader`, headless Chrome often captured the WebGL
|
||||
canvas blank, which looked like a black stage. `--use-angle=swiftshader --use-gl=angle
|
||||
--enable-unsafe-swiftshader --virtual-time-budget=6000` captures reliably. The renderer also sets
|
||||
`preserveDrawingBuffer`.
|
||||
|
||||
## What works
|
||||
|
||||
- **The morph is honest.** The ground line really becomes the horizon: the split happens on the line,
|
||||
and the sea's horizon sits on it pixel-exact before the camera moves (`-morph*.png`).
|
||||
- **The cave zoom** reads well: through the mouth you see the real 3D cave, then the frieze falls away
|
||||
around you (`-mid-cave.png`).
|
||||
- The curtain drop is simple and clear. All transitions sit at 1.2–2.0 s and run in reverse with ←.
|
||||
|
||||
## What doesn't, or is rough
|
||||
|
||||
- **Landfall framing:** in the middle of 2→3 the island rises to the *right* of the ship, then the camera
|
||||
swings and it ends up behind it. That reads as a slight sideways shuffle; a fixed camera would be calmer.
|
||||
- **The sun** is a large glare in the morph frames of looks 1 and 2, because the camera looks level
|
||||
at the horizon there.
|
||||
- **The giant** is blocky boxes. As a silhouette it is enough, but it is not pretty.
|
||||
- **The 3D does not match 1440×900 exactly:** the stage is 1440×~857 and the frieze uses SVG `slice`.
|
||||
It is tuned for desktop only, as briefed. Other sizes still line the horizon up, because it is
|
||||
computed, but the crops will differ.
|
||||
- The Play hold times were only checked in code; headless virtual time does not run the rAF loop
|
||||
faithfully, and one headless Play run showed the state switching (Pause, dot 2) but not the frames.
|
||||
- Beat 3 has no song chip (only an eyebrow, "year 1"). Beats 3 and 4 both point at Polyphemus, so I left
|
||||
that chip off beat 3.
|
||||
|
||||
## The three looks, honestly
|
||||
|
||||
**Look 2, two worlds** (default) is the most legible. The terracotta frieze is a strong, clearly
|
||||
"other" curtain, and the naturalistic sea is a real place, so each change of world feels like an event.
|
||||
But the morph (`-morph.png`) shows the seam: clay band → blue-gold sea is a jump in palette, carried
|
||||
only by the geometry of the horizon line.
|
||||
|
||||
**Look 3, frieze-led** makes the morph almost seamless, because terracotta sky meets terracotta band
|
||||
(`-morph-look3.png`). The sea becomes a black-figure painting come to life, which is the most
|
||||
*distinctive* of the three. The cost is that the 3D sea is dark and moody, the ship is a silhouette
|
||||
with little detail, and the vellum chart then looks like a foreign object (which the brief allows:
|
||||
"one object on the table").
|
||||
|
||||
**Look 1, vellum throughout** is the most unified and the quietest. The pen-and-wash frieze as an
|
||||
illustrated chart border is charming (`-beat4-look1.png`), and the sepia sea sits right next to the
|
||||
chart. But the sepia 3D loses depth and contrast (`-beat2-look1.png`), and nothing feels like a
|
||||
"curtain" any more: the transitions are less of an event.
|
||||
|
||||
My read: 2 if the frieze is the punctuation, 3 if the frieze is the identity. 1 is the safest, but it
|
||||
undersells the 3D.
|
||||
@@ -0,0 +1,65 @@
|
||||
// A small, simplified redraw of 12's general chart: the captain's-table map, with "You are here".
|
||||
// Coordinates are 12's general chart scaled into a 320×210 card.
|
||||
|
||||
const P = (x: number, y: number): [number, number] => [10 + ((x - 50) * 300) / 1050, 10 + ((y - 85) * 190) / 660];
|
||||
const PORTS: [number, number][] = [
|
||||
P(344, 277), P(404, 331), P(432, 407), P(420, 488), P(373, 552),
|
||||
P(456, 498), P(543, 576), P(679, 576), P(766, 499), P(960, 400),
|
||||
];
|
||||
const LOOP = P(410, 592);
|
||||
const AT_SEA = P(392, 584);
|
||||
|
||||
const pt = (p: [number, number]) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`;
|
||||
const port = (i: number) => PORTS[i]!;
|
||||
|
||||
/** The ship's course on the card, as one path (12's red dashed line). */
|
||||
const COURSE = `M${pt(port(0))} Q${pt(P(380, 300))} ${pt(port(1))} Q${pt(P(440, 360))} ${pt(port(2))} L${pt(port(3))} L${pt(port(4))}
|
||||
Q${pt(P(380, 600))} ${pt(LOOP)} Q${pt(P(440, 570))} ${pt(port(5))} Q${pt(P(480, 570))} ${pt(port(6))} L${pt(port(7))}
|
||||
Q${pt(P(730, 560))} ${pt(port(8))} Q${pt(P(860, 530))} ${pt(port(9))}`;
|
||||
|
||||
/** Where "You are here" sits: k=0 out at sea after Troy, k=1 at the first Cyclops port. */
|
||||
export function hereAt(k: number): [number, number] {
|
||||
const a = AT_SEA, b = LOOP, c = port(5);
|
||||
// two straight legs are close enough at this size
|
||||
if (k < 0.4) { const s = k / 0.4; return [a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s]; }
|
||||
const s = (k - 0.4) / 0.6;
|
||||
return [b[0] + (c[0] - b[0]) * s, b[1] + (c[1] - b[1]) * s];
|
||||
}
|
||||
|
||||
export function chartSvg(): string {
|
||||
const [ix, iy] = P(605, 470);
|
||||
const coast = `M0,0 L${pt(P(275, 85))} Q${pt(P(300, 200))} ${pt(P(290, 300))} Q${pt(P(390, 400))} ${pt(P(375, 430))} Q${pt(P(330, 520))} ${pt(P(285, 550))} Q${pt(P(240, 680))} ${pt(P(200, 745))} L0,210 Z`;
|
||||
const ports = PORTS.map((p, i) => `<circle class="c-port" cx="${p[0].toFixed(1)}" cy="${p[1].toFixed(1)}" r="2.6"/><text class="c-num" x="${(p[0] + 4).toFixed(1)}" y="${(p[1] - 3).toFixed(1)}">${i + 1}</text>`).join("");
|
||||
let rhumbs = "";
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const a = (i / 16) * Math.PI * 2;
|
||||
rhumbs += `M206,48 L${(206 + Math.cos(a) * 260).toFixed(0)},${(48 + Math.sin(a) * 260).toFixed(0)} `;
|
||||
}
|
||||
let spiral = "";
|
||||
for (let r = 3; r < 15; r += 3) spiral += `<ellipse class="c-ink" cx="${port(9)[0].toFixed(1)}" cy="${(port(9)[1] - 4).toFixed(1)}" rx="${r * 1.5}" ry="${r * 0.8}"/>`;
|
||||
return `<svg class="chart__svg" viewBox="0 0 320 210" role="img" aria-label="General chart of the voyage from Troy, with the ship's position marked">
|
||||
<defs><clipPath id="chart-clip"><rect x="6" y="6" width="308" height="198"/></clipPath></defs>
|
||||
<rect class="c-vellum" x="0" y="0" width="320" height="210"/>
|
||||
<g clip-path="url(#chart-clip)">
|
||||
<path class="c-rhumb" d="${rhumbs}"/>
|
||||
<path class="c-land" d="${coast}"/>
|
||||
<ellipse class="c-land" cx="${ix.toFixed(1)}" cy="${iy.toFixed(1)}" rx="38" ry="22"/>
|
||||
<path class="c-ink" d="M${(ix - 6).toFixed(1)},${(iy + 6).toFixed(1)} a8,8 0 0 1 16,0" />
|
||||
<ellipse class="c-land" cx="${P(450, 195)[0]}" cy="${P(450, 195)[1]}" rx="9" ry="6"/>
|
||||
<ellipse class="c-land" cx="${P(722, 335)[0]}" cy="${P(722, 335)[1]}" rx="5" ry="3.5"/>
|
||||
${spiral}
|
||||
<path class="c-course" d="${COURSE}"/>
|
||||
${ports}
|
||||
<text class="c-place" x="16" y="34">Troy</text>
|
||||
<text class="c-place" x="${(ix - 30).toFixed(1)}" y="${(iy - 28).toFixed(1)}">Cyclops' Island</text>
|
||||
<text class="c-sea" x="${P(870, 440)[0].toFixed(1)}" y="${P(870, 440)[1].toFixed(1)}">two years at sea</text>
|
||||
<g class="c-rose" transform="translate(292,178)"><circle r="11"/><path d="M0,-15 L3,0 L0,15 L-3,0 Z M-15,0 L0,3 L15,0 L0,-3 Z"/></g>
|
||||
</g>
|
||||
<g class="c-here" id="chart-here">
|
||||
<circle class="c-here__ring" r="7"/>
|
||||
<circle class="c-here__dot" r="3"/>
|
||||
<text class="c-here__label" x="10" y="-8">You are here</text>
|
||||
</g>
|
||||
<rect class="c-frame" x="4" y="4" width="312" height="202"/>
|
||||
</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// Copied from 04-greek-frieze/figures.ts (mockups never import across folders); helpers exported for 13.
|
||||
// Black-figure painting kit: glaze (fill: var(--ink)) with "incised" detail lines scratched back to the clay (stroke: var(--inc)).
|
||||
// Every panel shares one 300-unit-tall coordinate space with the ground line at GROUND.
|
||||
|
||||
export const PANEL_H = 300;
|
||||
export const GROUND = 284;
|
||||
|
||||
type Arms = "spear" | "open" | "down" | "raise" | "reach" | "mourn" | "club" | "throw";
|
||||
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;
|
||||
}
|
||||
|
||||
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: 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}"/>`;
|
||||
|
||||
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 = "";
|
||||
// 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");
|
||||
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.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) + 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;
|
||||
default: upper += limb("M10,-148 L16,-104");
|
||||
}
|
||||
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);
|
||||
}
|
||||
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 } = {}): 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" />`;
|
||||
}
|
||||
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"/>`;
|
||||
}
|
||||
|
||||
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"/>`);
|
||||
}
|
||||
|
||||
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"));
|
||||
|
||||
function cave(w: number): string {
|
||||
return ink(`M0,0 H${w} V${PANEL_H} H${w - 30} Q${w - 20},${66} ${w / 2},${58} Q30,66 32,${PANEL_H} H0 Z`) +
|
||||
inc(`M${w - 22},120 q-6,-30 -14,-48 M20,150 q6,-40 18,-70 M${w * 0.3},40 q30,-6 60,-2 M${w * 0.62},44 q24,0 46,10`, 1.6);
|
||||
}
|
||||
|
||||
export interface PanelArt { w: number; art: string; inscription?: "dark" | "light"; inscrEnd?: boolean }
|
||||
|
||||
const ground = (w: number) => `<path class="ground" d="M0,${GROUND} H${w}"/>`;
|
||||
|
||||
export const PANELS: Record<string, PanelArt> = {
|
||||
"the-horse-and-the-infant": { w: 370, art: trojanHorse(132, 0.9) + person({ x: 300, f: -1, arms: "reach", helmet: true, infant: true }) },
|
||||
"just-a-man": {
|
||||
w: 240,
|
||||
art: person({ x: 96, arms: "mourn", helmet: true, beard: true }) +
|
||||
limb("M178,284 L192,70", 3.5) + ink("M192,70 L186,48 L200,66 Z") +
|
||||
`<circle class="ink" cx="176" cy="250" r="32"/><circle class="inc" cx="176" cy="250" r="26" stroke-width="1.6"/>` +
|
||||
inc("M162,236 l28,28 M190,236 l-28,28", 1.4),
|
||||
},
|
||||
"full-speed-ahead": { w: 390, art: waves(0, 390, 290) + ship(190, 276, 1, { sail: true }) },
|
||||
"open-arms": {
|
||||
w: 300,
|
||||
art: person({ x: 92, arms: "open", beard: true }) + person({ x: 214, f: -1, arms: "reach", robe: true }),
|
||||
},
|
||||
"warrior-of-the-mind": {
|
||||
w: 340,
|
||||
art: person({ x: 98, arms: "spear", helmet: true, shield: true, robe: true }) +
|
||||
person({ x: 246, f: -1, arms: "spear", helmet: true, shield: true, beard: true }),
|
||||
},
|
||||
polyphemus: {
|
||||
w: 340,
|
||||
art: person({ x: 76, arms: "reach", beard: true, cup: true }) +
|
||||
person({ x: 252, f: -1, s: 1.2, arms: "club", cyclops: true, beard: true }),
|
||||
},
|
||||
survive: {
|
||||
w: 340, inscription: "light",
|
||||
art: person({ x: 214, f: -1, s: 1.02, arms: "down", cyclops: true, beard: true }) +
|
||||
person({ x: 82, s: 0.62, arms: "mourn", kneel: true }) + person({ x: 120, s: 0.62, arms: "reach" }) + cave(340),
|
||||
},
|
||||
"remember-them": {
|
||||
w: 310,
|
||||
art: stele(155) + person({ x: 62, arms: "mourn", kneel: true, beard: true }) + person({ x: 250, f: -1, arms: "mourn", robe: true }),
|
||||
},
|
||||
"my-goodbye": {
|
||||
w: 400, inscrEnd: true,
|
||||
art: waves(160, 400, 290) + person({ x: 78, s: 1.15, arms: "throw", cyclops: true, beard: true }) +
|
||||
ship(292, 276, 0.62, { sail: true }),
|
||||
},
|
||||
storm: {
|
||||
w: 350,
|
||||
art: bolt(52, 40, 1) + bolt(282, 52, 0.8) + ship(176, 252, 0.86, { tilt: -9 }) + waves(0, 350, 278, 1.3) + waves(18, 350, 296, 0.9),
|
||||
},
|
||||
};
|
||||
|
||||
const escXml = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||
|
||||
/** The painted scene for a song: figures plus its name written in the field, as on a vase. */
|
||||
export function panelSvg(id: string, title: string, withTitle = true): string {
|
||||
const p = PANELS[id] ?? { w: 260, art: person({ x: 130 }) };
|
||||
const light = p.inscription === "light";
|
||||
const x = p.inscrEnd ? p.w - 16 : light ? 46 : 16;
|
||||
const label = withTitle
|
||||
? `<text class="inscr${light ? " inscr--light" : ""}" x="${x}" y="${light ? 32 : 28}"${p.inscrEnd ? ' text-anchor="end"' : ""}>${escXml(title.toUpperCase())}</text>`
|
||||
: "";
|
||||
return `<svg class="scene" viewBox="0 0 ${p.w} ${PANEL_H}" aria-hidden="true" focusable="false">${p.art}${ground(p.w)}${label}</svg>`;
|
||||
}
|
||||
|
||||
export function panelWidth(id: string): number {
|
||||
return PANELS[id]?.w ?? 260;
|
||||
}
|
||||
|
||||
/** Narrow ladder band between consecutive songs of one saga. */
|
||||
export function ladderSvg(): string {
|
||||
let d = "M5,0 V300 M21,0 V300 ";
|
||||
for (let y = 8; y < 300; y += 12) d += `M5,${y} H21 `;
|
||||
return `<svg class="sep-art" viewBox="0 0 26 300" aria-hidden="true"><path class="rule" d="${d}"/></svg>`;
|
||||
}
|
||||
|
||||
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 saga boundary: palmettes framing the saga's name, painted vertically. */
|
||||
export function sagaSvg(name: string, gap: number): string {
|
||||
const tally = gap > 0
|
||||
? `<text class="tally" x="66" y="296" text-anchor="middle">${gap} ${gap === 1 ? "year" : "years"} later</text>`
|
||||
: "";
|
||||
return `<svg class="sep-art" viewBox="0 0 132 300" aria-hidden="true">
|
||||
<path class="rule" d="M4,0 V300 M128,0 V300"/>
|
||||
${palmette(66, 52)}${palmette(66, gap > 0 ? 226 : 248, true)}
|
||||
<text class="saga-name" transform="translate(71,150) rotate(-90)" text-anchor="middle">${escXml(name.replace(/^The /, "").toUpperCase())}</text>
|
||||
${tally}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/** Elapsed story time between two songs, painted as a tally of years. */
|
||||
export function gapSvg(gap: number): string {
|
||||
const w = 40 + gap * 24;
|
||||
let bars = "";
|
||||
for (let i = 0; i < gap; i++) bars += `M${20 + i * 24 + 6},120 V180 `;
|
||||
return `<svg class="sep-art" viewBox="0 0 ${w} 300" aria-hidden="true">
|
||||
<path class="rule" d="M4,0 V300 M${w - 4},0 V300"/>
|
||||
<path class="tally-bars" d="${bars}"/>
|
||||
<text class="tally" transform="translate(${w / 2 + 4},250) rotate(-90)" >+${gap} yr</text>
|
||||
</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// The two frieze "curtains": full-stage SVGs drawn in the 04 black-figure kit. Colours come from CSS
|
||||
// custom properties, so look 1 redraws the same figures as ink on vellum.
|
||||
import { GROUND, PANEL_H, g, inc, ink, limb, palmette, person, PANELS, ship, waves } from "./figures";
|
||||
|
||||
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 on the stage (also the 3D horizon at the start of the morph). */
|
||||
export const GROUND_Y = FIELD_Y + GROUND * SCALE; // ≈ 574
|
||||
const BOT_Y = GROUND_Y + 14;
|
||||
const BAND_BOT = BOT_Y + 100;
|
||||
/** Centre of the cave mouth in the Cyclops frieze, in stage units: the zoom goes through here. */
|
||||
export const MOUTH = { x: 720, y: FIELD_Y + (GROUND - 100) * SCALE };
|
||||
|
||||
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||
|
||||
function defs(id: string): string {
|
||||
const meander = (key: string, y: number) =>
|
||||
`<pattern id="${id}-${key}" y="${y}" width="28" height="28" patternUnits="userSpaceOnUse"><path class="mean" d="M0 25.2H28M4.2 25.2V3H24V20.3H9.8V8.4H18.2V14.7" /></pattern>`;
|
||||
return `<defs>
|
||||
${meander("mt", BAND_TOP + 14)}${meander("mb", BOT_Y + 8)}
|
||||
<pattern id="${id}-tongues" y="${BAND_TOP + 50}" width="18" height="22" patternUnits="userSpaceOnUse">
|
||||
<path class="ink" d="M2 0H16V13A7 7 0 0 1 2 13Z" />
|
||||
</pattern>
|
||||
<pattern id="${id}-rays" y="${BOT_Y + 46}" width="32" height="54" patternUnits="userSpaceOnUse">
|
||||
<path class="ink" d="M2 54L16 6L30 54Z" />
|
||||
</pattern>
|
||||
</defs>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome, split at the ground line into a top half (surround, band, top border) and a bottom half,
|
||||
* so the morph can part them along the line that becomes the horizon.
|
||||
*/
|
||||
function chrome(id: string) {
|
||||
const X0 = -200, W = VB_W + 400;
|
||||
const rule = (y: number) => `<path class="rule" d="M${X0} ${y}H${X0 + W}"/>`;
|
||||
const frame = (y: number) => `<path class="frame-rule" d="M${X0} ${y}H${X0 + W}M${X0} ${y + 5}H${X0 + W}"/>`;
|
||||
const top = `<g class="f-top">
|
||||
<rect class="f-surround" x="${X0}" y="-600" width="${W}" height="${GROUND_Y + 600}"/>
|
||||
<rect class="f-band" x="${X0}" y="${BAND_TOP}" width="${W}" height="${GROUND_Y - BAND_TOP}"/>
|
||||
${frame(BAND_TOP - 12)}${rule(BAND_TOP + 8)}
|
||||
<rect x="${X0}" y="${BAND_TOP + 14}" width="${W}" height="28" fill="url(#${id}-mt)"/>
|
||||
${rule(BAND_TOP + 46)}
|
||||
<rect x="${X0}" y="${BAND_TOP + 50}" width="${W}" height="22" fill="url(#${id}-tongues)"/>
|
||||
</g>`;
|
||||
const bottom = `<g class="f-bottom">
|
||||
<rect class="f-surround" x="${X0}" y="${GROUND_Y}" width="${W}" height="${VB_H - GROUND_Y + 600}"/>
|
||||
<rect class="f-band" x="${X0}" y="${GROUND_Y}" width="${W}" height="${BAND_BOT - GROUND_Y}"/>
|
||||
${rule(BOT_Y + 2)}
|
||||
<rect x="${X0}" y="${BOT_Y + 8}" width="${W}" height="28" fill="url(#${id}-mb)"/>
|
||||
${rule(BOT_Y + 40)}
|
||||
<rect x="${X0}" y="${BOT_Y + 46}" width="${W}" height="54" fill="url(#${id}-rays)"/>
|
||||
${frame(BAND_BOT + 6)}
|
||||
</g>`;
|
||||
return { top, bottom };
|
||||
}
|
||||
|
||||
const placed = (x: number, art: string) => g(`translate(${x},${FIELD_Y}) scale(${SCALE})`, art);
|
||||
const inscription = (x: number, text: string, anchor: "start" | "middle" | "end" = "start") =>
|
||||
`<text class="inscr" x="${x}" y="${FIELD_Y + 34}" text-anchor="${anchor}">${esc(text.toUpperCase())}</text>`;
|
||||
|
||||
/** A painted saga column (palmettes framing the saga's name), as in 04. */
|
||||
function sagaColumn(x: number, name: string): string {
|
||||
return placed(x, `<path class="rule" d="M4,0 V${PANEL_H} M128,0 V${PANEL_H}"/>${palmette(66, 52)}${palmette(66, 248, true)}
|
||||
<text class="saga-name" transform="translate(71,150) rotate(-90)" text-anchor="middle">${esc(name.toUpperCase())}</text>`);
|
||||
}
|
||||
const ladder = (x: number) => {
|
||||
let d = "M5,0 V300 M21,0 V300 ";
|
||||
for (let y = 8; y < 300; y += 12) d += `M5,${y} H21 `;
|
||||
return placed(x, `<path class="rule" d="${d}"/>`);
|
||||
};
|
||||
|
||||
/** Beat 1: the fall of Troy. */
|
||||
export function troyFrieze(): string {
|
||||
const id = "fz1";
|
||||
const c = chrome(id);
|
||||
const u = (n: number) => n * SCALE;
|
||||
let x = 22;
|
||||
let figs = sagaColumn(x, "Troy Saga");
|
||||
x += u(132) + 18;
|
||||
figs += placed(x, PANELS["the-horse-and-the-infant"]!.art) + inscription(x + 18, "The Horse and the Infant");
|
||||
x += u(370);
|
||||
figs += ladder(x);
|
||||
x += u(26) + 6;
|
||||
figs += placed(x, PANELS["just-a-man"]!.art) + inscription(x + 18, "Just a Man");
|
||||
x += u(240);
|
||||
figs += ladder(x);
|
||||
x += u(26) + 6;
|
||||
figs += placed(x, waves(0, 330, 290) + ship(170, 276, 0.9, { sail: true })) + inscription(x + 18, "Full Speed Ahead");
|
||||
return `<svg class="frieze frieze--troy" viewBox="0 0 ${VB_W} ${VB_H}" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
${defs(id)}
|
||||
${c.top}${c.bottom}
|
||||
<g class="f-figs">${figs}</g>
|
||||
<path class="ground horizon-line" d="M-200 ${GROUND_Y}H${VB_W + 200}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ---------- Cyclops frieze: crew, cave mouth, Polyphemus, sheep ---------- */
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** The rock and the arch of the cave mouth; the mouth itself is cut out with a mask so the 3D cave shows through. */
|
||||
const MOUTH_W = 120; // half-width of the arch, panel units
|
||||
const MOUTH_H = 200; // height of the arch above the ground line
|
||||
function mouthPath(cx: number): string {
|
||||
return `M${cx - MOUTH_W},${GROUND} V${GROUND - 92} Q${cx - MOUTH_W},${GROUND - MOUTH_H} ${cx},${GROUND - MOUTH_H} Q${cx + MOUTH_W},${GROUND - MOUTH_H} ${cx + MOUTH_W},${GROUND - 92} V${GROUND} Z`;
|
||||
}
|
||||
function caveRock(cx: number): string {
|
||||
const r = MOUTH_W + 46;
|
||||
let b = ink(`M${cx - r - 40},${GROUND} Q${cx - r - 30},${GROUND - 170} ${cx - r + 20},${GROUND - 222} Q${cx - 60},${GROUND - 268} ${cx + 10},${GROUND - 262} Q${cx + r - 10},${GROUND - 270} ${cx + r + 18},${GROUND - 200} Q${cx + r + 46},${GROUND - 110} ${cx + r + 40},${GROUND} Z`);
|
||||
b += `<path class="inc" d="${mouthPath(cx)}" stroke-width="5" transform="translate(${cx},${GROUND}) scale(1.06) translate(${-cx},${-GROUND})"/>`;
|
||||
b += inc(`M${cx - r - 18},${GROUND - 120} q18,-30 46,-40 M${cx - r + 4},${GROUND - 190} q34,-24 70,-26 M${cx + r - 30},${GROUND - 214} q30,6 44,40 M${cx + r + 10},${GROUND - 80} q8,-30 4,-60 M${cx - 40},${GROUND - 246} q40,-8 84,2`, 2);
|
||||
return b;
|
||||
}
|
||||
|
||||
export function cyclopsFrieze(): string {
|
||||
const id = "fz4";
|
||||
const c = chrome(id);
|
||||
const u = (n: number) => n * SCALE;
|
||||
const panelX = (stageX: number) => (stageX - 0) / SCALE; // field art is placed at x=0
|
||||
const cx = panelX(MOUTH.x);
|
||||
let art = "";
|
||||
// crew on the left: Odysseus offers the cup, two men behind him
|
||||
art += person({ x: panelX(250), s: 0.82, arms: "spear", helmet: true, shield: true });
|
||||
art += person({ x: panelX(326), s: 0.82, arms: "down", helmet: true });
|
||||
art += person({ x: panelX(398), s: 0.94, arms: "reach", beard: true, cup: true });
|
||||
art += caveRock(cx);
|
||||
// Polyphemus at his door, then the flock
|
||||
art += person({ x: panelX(1060), f: -1, s: 1.16, arms: "club", cyclops: true, beard: true });
|
||||
art += sheep(panelX(1180), 0.95) + sheep(panelX(1275), 0.85, -1) + sheep(panelX(1370), 0.92);
|
||||
let figs = sagaColumn(22, "Cyclops Saga");
|
||||
figs += placed(0, art);
|
||||
figs += inscription(u(132) + 60, "Polyphemus");
|
||||
figs += inscription(VB_W - 34, "Survive", "end");
|
||||
const mouthStage = `translate(0,${FIELD_Y}) scale(${SCALE})`;
|
||||
return `<svg class="frieze frieze--cyclops" viewBox="0 0 ${VB_W} ${VB_H}" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
${defs(id)}
|
||||
<mask id="${id}-mouth" maskUnits="userSpaceOnUse" x="-4000" y="-4000" width="10000" height="10000">
|
||||
<rect x="-4000" y="-4000" width="10000" height="10000" fill="#fff"/>
|
||||
<path d="${mouthPath(cx)}" transform="${mouthStage}" fill="#000"/>
|
||||
</mask>
|
||||
<radialGradient id="${id}-glow" cx="0.5" cy="0.92" r="0.75">
|
||||
<stop offset="0" class="glow-a"/><stop offset="0.55" class="glow-b"/><stop offset="1" class="glow-c"/>
|
||||
</radialGradient>
|
||||
<g class="f-zoom">
|
||||
<g mask="url(#${id}-mouth)">
|
||||
${c.top}${c.bottom}
|
||||
<g class="f-figs">${figs}</g>
|
||||
<path class="ground" d="M-200 ${GROUND_Y}H${VB_W + 200}"/>
|
||||
</g>
|
||||
<path class="mouth-fill" d="${mouthPath(cx)}" transform="${mouthStage}" fill="url(#${id}-glow)"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
@@ -1,3 +1,261 @@
|
||||
import { songs } from "../../data/sample";
|
||||
import { mountCredits } from "../../shared/credits";
|
||||
import { chartSvg, hereAt } from "./chart";
|
||||
import { cyclopsFrieze, GROUND_Y, MOUTH, troyFrieze, VB_H, VB_W } from "./frieze";
|
||||
import { CurtainScene, ease, webglAvailable, type Look } from "./scene";
|
||||
|
||||
mountCredits();
|
||||
|
||||
/* ---------- beats ---------- */
|
||||
|
||||
interface Beat { short: string; eyebrow: string; caption: string; songId?: string }
|
||||
const BEATS: Beat[] = [
|
||||
{ short: "Frieze", eyebrow: "The Troy Saga · year 0", caption: "Troy falls", songId: "the-horse-and-the-infant" },
|
||||
{ short: "To sea", eyebrow: "Between sagas · a year at sea", caption: "Out on the open sea", songId: "full-speed-ahead" },
|
||||
{ short: "Landfall", eyebrow: "The Cyclops Saga · year 1", caption: "Landfall: Cyclops' Island" },
|
||||
{ short: "Curtain", eyebrow: "The Cyclops Saga", caption: "Polyphemus", songId: "polyphemus" },
|
||||
{ short: "Cave", eyebrow: "Inside the cave", caption: "Into the cave", songId: "survive" },
|
||||
];
|
||||
/** Seconds for the transition that leaves beat i (1-2 s each, per the brief). */
|
||||
const SEG_DUR = [1.8, 2.0, 1.2, 1.6];
|
||||
const HOLD = 3.4; // seconds a beat holds while playing
|
||||
|
||||
const LOOKS: Record<Look, string> = { 1: "Vellum throughout", 2: "Two worlds", 3: "Frieze-led" };
|
||||
|
||||
/* ---------- params ---------- */
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const clampBeat = (n: number) => Math.min(5, Math.max(1, Math.round(n) || 1));
|
||||
const startBeat = clampBeat(Number(params.get("beat") ?? 1));
|
||||
const freezeT = params.has("t") ? Math.min(1, Math.max(0, Number(params.get("t")) || 0)) : null;
|
||||
let look: Look = (["1", "2", "3"].includes(params.get("look") ?? "") ? Number(params.get("look")) : 2) as Look;
|
||||
|
||||
/* ---------- DOM ---------- */
|
||||
|
||||
const app = document.getElementById("app")!;
|
||||
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||
app.innerHTML = `
|
||||
<section class="stage" id="stage" aria-label="Curtain call: a scripted transition in five beats">
|
||||
<div class="stage__three" id="three"></div>
|
||||
<div class="stage__fallback" id="fallback" hidden><p>3D needs WebGL; the frieze beats still play.</p></div>
|
||||
<div class="stage__paper" aria-hidden="true"></div>
|
||||
<aside class="chart" id="chart" aria-label="Captain's table map">
|
||||
<p class="chart__head"><span>General chart</span> The voyage from Troy</p>
|
||||
${chartSvg()}
|
||||
</aside>
|
||||
<div class="frieze-host frieze-host--troy" id="troy">${troyFrieze()}</div>
|
||||
<div class="frieze-host frieze-host--cyclops" id="cyclops">${cyclopsFrieze()}</div>
|
||||
<div class="cut" id="cut" aria-hidden="true"></div>
|
||||
<header class="cap" id="cap" aria-live="polite">
|
||||
<p class="cap__eyebrow" id="cap-eyebrow"></p>
|
||||
<h1 class="cap__title" id="cap-title"></h1>
|
||||
<p class="cap__row"><span class="chip" id="cap-chip"></span>
|
||||
<button type="button" class="back" id="back" hidden>Back to sea</button></p>
|
||||
</header>
|
||||
<nav class="bar" aria-label="Beats">
|
||||
<button type="button" class="bar__play" id="play" aria-pressed="false">Play</button>
|
||||
<button type="button" class="bar__step" id="prev" aria-label="Previous beat">←</button>
|
||||
<ol class="dots" id="dots">
|
||||
${BEATS.map((b, i) => `<li><button type="button" data-beat="${i + 1}"><span class="dots__n">${i + 1}</span><span class="dots__label">${esc(b.short)}</span></button></li>`).join("")}
|
||||
</ol>
|
||||
<button type="button" class="bar__step" id="next" aria-label="Next beat">→</button>
|
||||
<div class="looks" role="group" aria-label="Look">
|
||||
<span class="looks__label">Look</span>
|
||||
${([1, 2, 3] as Look[]).map((l) => `<button type="button" data-look="${l}" title="${LOOKS[l]}">${l}</button>`).join('<span class="looks__sep" aria-hidden="true">·</span>')}
|
||||
<span class="looks__name" id="look-name"></span>
|
||||
</div>
|
||||
</nav>
|
||||
</section>`;
|
||||
|
||||
const $ = (id: string) => document.getElementById(id)!;
|
||||
const stage = $("stage");
|
||||
const troy = $("troy");
|
||||
const cyclops = $("cyclops");
|
||||
const chart = $("chart");
|
||||
const cut = $("cut");
|
||||
const capEl = $("cap");
|
||||
const playBtn = $("play") as HTMLButtonElement;
|
||||
const backBtn = $("back") as HTMLButtonElement;
|
||||
const here = document.getElementById("chart-here")!;
|
||||
const troyParts = {
|
||||
figs: troy.querySelector<SVGGElement>(".f-figs")!,
|
||||
top: troy.querySelector<SVGGElement>(".f-top")!,
|
||||
bottom: troy.querySelector<SVGGElement>(".f-bottom")!,
|
||||
horizon: troy.querySelector<SVGPathElement>(".horizon-line")!,
|
||||
};
|
||||
const cyParts = {
|
||||
zoom: cyclops.querySelector<SVGGElement>(".f-zoom")!,
|
||||
mouth: cyclops.querySelector<SVGPathElement>(".mouth-fill")!,
|
||||
};
|
||||
|
||||
let scene: CurtainScene | null = null;
|
||||
if (webglAvailable()) {
|
||||
try { scene = new CurtainScene($("three"), reduced); } catch { scene = null; }
|
||||
}
|
||||
if (!scene) $("fallback").hidden = false;
|
||||
|
||||
/* ---------- state ---------- */
|
||||
|
||||
let p = freezeT !== null ? Math.max(0, startBeat - 2 + freezeT) : startBeat - 1;
|
||||
let anim: { from: number; to: number; start: number; dur: number } | null = null;
|
||||
let cutting = false;
|
||||
let playing = false;
|
||||
let holdUntil = 0;
|
||||
let shownBeat = -1;
|
||||
|
||||
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
|
||||
const easeIn = (x: number) => { const t = clamp01(x); return t * t * t; };
|
||||
const easeInOut = (x: number) => { const t = clamp01(x); return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
|
||||
|
||||
/* ---------- render: everything is a function of p ---------- */
|
||||
|
||||
function renderDom() {
|
||||
// Beat 1 → 2: the band peels open along its ground line, which becomes the 3D horizon.
|
||||
const s0 = clamp01(p);
|
||||
troy.hidden = s0 >= 1;
|
||||
if (!troy.hidden) {
|
||||
const flat = ease(s0 / 0.32);
|
||||
troyParts.figs.setAttribute("transform", `translate(0,${GROUND_Y}) scale(1,${(1 - flat).toFixed(4)}) translate(0,${-GROUND_Y})`);
|
||||
troyParts.figs.style.opacity = String(1 - ease((s0 - 0.1) / 0.24));
|
||||
const part = easeInOut((s0 - 0.12) / 0.5);
|
||||
troyParts.top.setAttribute("transform", `translate(0,${(-part * (GROUND_Y + 80)).toFixed(1)})`);
|
||||
troyParts.bottom.setAttribute("transform", `translate(0,${(part * (VB_H - GROUND_Y + 80)).toFixed(1)})`);
|
||||
troyParts.horizon.style.opacity = String(1 - ease((s0 - 0.5) / 0.22));
|
||||
}
|
||||
|
||||
// The captain's-table map rests at sea (beats 2–3) and is put away when the curtain drops.
|
||||
const chartIn = ease((p - 0.62) / 0.38) * (1 - ease((p - 2) / 0.35));
|
||||
chart.style.opacity = chartIn.toFixed(3);
|
||||
chart.style.transform = `translateY(${((1 - chartIn) * 24).toFixed(1)}px) rotate(-2deg)`;
|
||||
chart.style.visibility = chartIn > 0.001 ? "visible" : "hidden";
|
||||
const [hx, hy] = hereAt(ease((p - 1.1) / 0.8));
|
||||
here.setAttribute("transform", `translate(${hx.toFixed(1)},${hy.toFixed(1)})`);
|
||||
|
||||
// Beat 3 → 4: the Cyclops frieze drops like a curtain. Beat 4 → 5: through the cave mouth.
|
||||
cyclops.hidden = p <= 2;
|
||||
if (!cyclops.hidden) {
|
||||
const drop = easeInOut(p - 2);
|
||||
cyclops.style.transform = `translateY(${((drop - 1) * 102).toFixed(2)}%)`;
|
||||
const z = clamp01(p - 3);
|
||||
cyParts.mouth.style.opacity = String(1 - ease(z / 0.28));
|
||||
const scale = 1 + 15 * easeIn((z - 0.08) / 0.92);
|
||||
cyParts.zoom.setAttribute("transform", `translate(${MOUTH.x},${MOUTH.y}) scale(${scale.toFixed(4)}) translate(${-MOUTH.x},${-MOUTH.y})`);
|
||||
cyclops.style.opacity = String(1 - ease((z - 0.72) / 0.26));
|
||||
}
|
||||
stage.dataset.world = p < 0.5 ? "frieze" : p < 2.5 ? "sea" : p < 3.5 ? "frieze" : "cave";
|
||||
|
||||
// Captions dip and swap at each transition's midpoint.
|
||||
const near = Math.round(p);
|
||||
const d = Math.abs(p - near);
|
||||
capEl.style.opacity = String(clamp01(1 - d * 3.2));
|
||||
if (near !== shownBeat) {
|
||||
shownBeat = near;
|
||||
const b = BEATS[near]!;
|
||||
$("cap-eyebrow").textContent = b.eyebrow;
|
||||
$("cap-title").textContent = b.caption;
|
||||
const song = b.songId ? songs.find((s) => s.id === b.songId) : undefined;
|
||||
const chip = $("cap-chip");
|
||||
chip.hidden = !song;
|
||||
if (song) chip.innerHTML = `<span class="chip__n">${song.track}</span>${esc(song.title)}`;
|
||||
backBtn.hidden = near !== 4;
|
||||
}
|
||||
const target = anim ? anim.to : Math.round(p);
|
||||
document.querySelectorAll<HTMLButtonElement>("#dots button").forEach((btn, i) => {
|
||||
if (i === target) btn.setAttribute("aria-current", "step");
|
||||
else btn.removeAttribute("aria-current");
|
||||
});
|
||||
($("prev") as HTMLButtonElement).disabled = target <= 0;
|
||||
($("next") as HTMLButtonElement).disabled = target >= 4;
|
||||
}
|
||||
|
||||
/* ---------- navigation ---------- */
|
||||
|
||||
function go(to: number) {
|
||||
if (freezeT !== null || cutting) return;
|
||||
to = Math.min(4, Math.max(0, to));
|
||||
if (anim) { p = anim.to; anim = null; }
|
||||
if (to === p) return;
|
||||
if (reduced || Math.abs(to - p) !== 1) crossCut(to);
|
||||
else anim = { from: p, to, start: performance.now(), dur: SEG_DUR[Math.min(p, to)]! * 1000 };
|
||||
}
|
||||
|
||||
/** Non-adjacent jumps (and every step under reduced motion) fade through the look's curtain colour. */
|
||||
function crossCut(to: number) {
|
||||
cutting = true;
|
||||
cut.classList.add("is-on");
|
||||
window.setTimeout(() => {
|
||||
p = to;
|
||||
renderDom();
|
||||
cut.classList.remove("is-on");
|
||||
window.setTimeout(() => { cutting = false; holdUntil = performance.now() + HOLD * 1000; }, 380);
|
||||
}, reduced ? 260 : 300);
|
||||
}
|
||||
|
||||
function setPlaying(on: boolean) {
|
||||
playing = on;
|
||||
playBtn.textContent = on ? "Pause" : "Play";
|
||||
playBtn.setAttribute("aria-pressed", String(on));
|
||||
if (on) {
|
||||
if (Math.round(p) >= 4 && !anim) go(0);
|
||||
holdUntil = performance.now() + 1200;
|
||||
}
|
||||
}
|
||||
|
||||
function setLook(l: Look) {
|
||||
look = l;
|
||||
stage.dataset.look = String(l);
|
||||
document.body.dataset.look = String(l);
|
||||
scene?.setLook(l);
|
||||
document.querySelectorAll<HTMLButtonElement>(".looks button").forEach((b) => b.setAttribute("aria-pressed", String(Number(b.dataset.look) === l)));
|
||||
$("look-name").textContent = LOOKS[l];
|
||||
const u = new URL(location.href);
|
||||
u.searchParams.set("look", String(l));
|
||||
history.replaceState(null, "", u);
|
||||
}
|
||||
|
||||
playBtn.addEventListener("click", () => setPlaying(!playing));
|
||||
$("prev").addEventListener("click", () => { setPlaying(false); go((anim ? anim.to : p) - 1); });
|
||||
$("next").addEventListener("click", () => { setPlaying(false); go((anim ? anim.to : p) + 1); });
|
||||
backBtn.addEventListener("click", () => { setPlaying(false); go(1); });
|
||||
document.querySelectorAll<HTMLButtonElement>("#dots button").forEach((b) =>
|
||||
b.addEventListener("click", () => { setPlaying(false); go(Number(b.dataset.beat) - 1); }));
|
||||
document.querySelectorAll<HTMLButtonElement>(".looks button").forEach((b) =>
|
||||
b.addEventListener("click", () => setLook(Number(b.dataset.look) as Look)));
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowRight") { setPlaying(false); go((anim ? anim.to : p) + 1); }
|
||||
else if (e.key === "ArrowLeft") { setPlaying(false); go((anim ? anim.to : p) - 1); }
|
||||
else if (e.key === " " && !(e.target instanceof HTMLButtonElement)) { e.preventDefault(); setPlaying(!playing); }
|
||||
});
|
||||
|
||||
/* ---------- loop ---------- */
|
||||
|
||||
const t0 = performance.now();
|
||||
function frame(now: number) {
|
||||
if (anim) {
|
||||
const k = clamp01((now - anim.start) / anim.dur);
|
||||
p = anim.from + (anim.to - anim.from) * k;
|
||||
if (k >= 1) { p = anim.to; anim = null; holdUntil = now + HOLD * 1000; }
|
||||
} else if (playing && !cutting && now >= holdUntil) {
|
||||
if (Math.round(p) >= 4) setPlaying(false);
|
||||
else go(Math.round(p) + 1);
|
||||
}
|
||||
renderDom();
|
||||
scene?.render(p, (now - t0) / 1000 + 2.2);
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
setLook(look);
|
||||
renderDom();
|
||||
requestAnimationFrame(frame);
|
||||
if (params.get("play") === "1" && freezeT === null) setPlaying(true);
|
||||
|
||||
/** The 3D horizon must start exactly on the frieze's painted ground line, wherever the slice-scaled SVG puts it. */
|
||||
function syncHorizon() {
|
||||
const w = stage.clientWidth, h = stage.clientHeight;
|
||||
if (!w || !h) return;
|
||||
const s = Math.max(w / VB_W, h / VB_H);
|
||||
scene?.setHorizon(((GROUND_Y - VB_H / 2) * s + h / 2) / h);
|
||||
}
|
||||
new ResizeObserver(syncHorizon).observe(stage);
|
||||
syncHorizon();
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
// The 3D half of the curtain call: one renderer, two scenes (the open sea with Cyclops' Island, and
|
||||
// the cave). Sea, sky, island and ship are trimmed copies of 07-sea-voyage/scene.ts. Everything is a
|
||||
// pure function of the beat position p (0..4), so a transition can be scrubbed, frozen or reversed.
|
||||
import * as THREE from "three";
|
||||
|
||||
export type Look = 1 | 2 | 3;
|
||||
|
||||
export function webglAvailable(): boolean {
|
||||
try {
|
||||
const c = document.createElement("canvas");
|
||||
return !!(c.getContext("webgl2") ?? c.getContext("webgl"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- wave field (GLSL and JS agree) ---------- */
|
||||
|
||||
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);
|
||||
}`;
|
||||
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);
|
||||
|
||||
/* ---------- palettes per look ---------- */
|
||||
|
||||
interface Palette {
|
||||
skyTop: string; skyHorizon: string; sun: string; sunGlow: number;
|
||||
deep: string; crest: string; foam: string; fog: string; fogNear: number; fogFar: number;
|
||||
hemiSky: string; hemiGround: string; hemiI: number; sunI: number; sunLight: string; seaRough: number;
|
||||
hull: string; stripe: string; wood: string; sail: string; sailStripe: string;
|
||||
islandTint: string; silhouette: boolean;
|
||||
rock: string; caveFloor: string; fire: string; fireCore: string; caveAmbient: number; giant: string; wool: string;
|
||||
}
|
||||
|
||||
const PALETTES: Record<Look, Palette> = {
|
||||
// 1 · Vellum throughout: the sea is a sepia wash, as if the chart's own ink had come to life.
|
||||
1: {
|
||||
skyTop: "#b49c74", skyHorizon: "#efe2c2", sun: "#fff4dc", sunGlow: 0.35,
|
||||
deep: "#6b5a40", crest: "#a8926a", foam: "#f2e7cb", fog: "#e6d6b2", fogNear: 70, fogFar: 300,
|
||||
hemiSky: "#fff3da", hemiGround: "#5a4630", hemiI: 1.5, sunI: 1.6, sunLight: "#fff1d6", seaRough: 0.7,
|
||||
hull: "#4a3522", stripe: "#8c3a26", wood: "#6a4c30", sail: "#efe3c6", sailStripe: "#9b3f2a",
|
||||
islandTint: "#d9c49a", silhouette: false,
|
||||
rock: "#6b5a44", caveFloor: "#57483a", fire: "#e7a45a", fireCore: "#fbe2a8", caveAmbient: 0.35, giant: "#1f1710", wool: "#eadcbc",
|
||||
},
|
||||
// 2 · Two worlds: 07's naturalistic sea, warm at the horizon so it meets the clay band softly.
|
||||
2: {
|
||||
skyTop: "#6f9fb4", skyHorizon: "#efc996", sun: "#ffd49c", sunGlow: 0.55,
|
||||
deep: "#185a70", crest: "#5fb0b0", foam: "#eef0ea", fog: "#e9cfa6", fogNear: 80, fogFar: 330,
|
||||
hemiSky: "#ffffff", hemiGround: "#224455", hemiI: 1.3, sunI: 2, sunLight: "#ffffff", seaRough: 0.62,
|
||||
hull: "#2b2522", stripe: "#1d5f86", wood: "#6d4a30", sail: "#efe9dc", sailStripe: "#1d5f86",
|
||||
islandTint: "#ffffff", silhouette: false,
|
||||
rock: "#4d4540", caveFloor: "#3b3430", fire: "#ff8a3a", fireCore: "#ffe08a", caveAmbient: 0.25, giant: "#120e0c", wool: "#efece4",
|
||||
},
|
||||
// 3 · Frieze-led: terracotta sky, glaze-black sea, black-figure silhouettes for ship and island.
|
||||
3: {
|
||||
skyTop: "#9c4c24", skyHorizon: "#dc8d52", sun: "#efe3c8", sunGlow: 0.25,
|
||||
deep: "#1d140e", crest: "#4a2a18", foam: "#c46a33", fog: "#c9733d", fogNear: 70, fogFar: 300,
|
||||
hemiSky: "#e8a070", hemiGround: "#1d140e", hemiI: 0.9, sunI: 1.2, sunLight: "#dc8d52", seaRough: 0.9,
|
||||
hull: "#1d140e", stripe: "#c46a33", wood: "#1d140e", sail: "#1d140e", sailStripe: "#c46a33",
|
||||
islandTint: "#1d140e", silhouette: true,
|
||||
rock: "#5a2c16", caveFloor: "#3a1d10", fire: "#dc8d52", fireCore: "#efe3c8", caveAmbient: 0.3, giant: "#140c08", wool: "#efe3c8",
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
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);
|
||||
}
|
||||
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); };
|
||||
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 lerp = (a: number, b: number, k: number) => a + (b - a) * k;
|
||||
const v3 = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z);
|
||||
|
||||
function solid(color: string, opts: THREE.MeshStandardMaterialParameters = {}) {
|
||||
return new THREE.MeshStandardMaterial({ color, flatShading: true, roughness: 0.85, ...opts });
|
||||
}
|
||||
|
||||
interface IslandOpts { radius: number; height: number; seed: number; sand: string; rock: string; top: string }
|
||||
|
||||
/** Low-poly island: jittered cone, coloured per face by height (from 07). */
|
||||
function islandGeometry(o: IslandOpts): THREE.BufferGeometry {
|
||||
let g: THREE.BufferGeometry = new THREE.ConeGeometry(o.radius, o.height, 11, 6, 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.6;
|
||||
pos.setXYZ(i, x + (hash(kx, ky, kz) - 0.5) * o.radius * 0.28 * j, y + (hash(ky, kz, kx) - 0.5) * o.height * 0.14, z + (hash(kz, kx, ky) - 0.5) * o.radius * 0.28 * j);
|
||||
}
|
||||
g = g.toNonIndexed();
|
||||
const p = g.getAttribute("position") as THREE.BufferAttribute;
|
||||
const colors = new Float32Array(p.count * 3);
|
||||
const sand = new THREE.Color(o.sand), rock = new THREE.Color(o.rock), scrub = new THREE.Color(o.top), tmp = new THREE.Color();
|
||||
for (let f = 0; f < p.count; f += 3) {
|
||||
const y = (p.getY(f) + p.getY(f + 1) + p.getY(f + 2)) / 3;
|
||||
const n = hash(f, o.seed, 3);
|
||||
tmp.copy(y < 0.9 ? sand : y > o.height * 0.38 - 3 && n > 0.35 ? scrub : rock).offsetHSL(0, 0, (n - 0.5) * 0.06);
|
||||
for (let k = 0; k < 3; k++) tmp.toArray(colors, (f + k) * 3);
|
||||
}
|
||||
g.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
g.computeVertexNormals();
|
||||
return g;
|
||||
}
|
||||
|
||||
/* ---------- choreography: where the ship and the cameras are at p ---------- */
|
||||
|
||||
const SHIP_A = v3(0, 0, 0); // beat 2: out at sea
|
||||
const SHIP_B = v3(-4, 0, -150); // beat 3: anchored off the island
|
||||
const ISLAND = v3(-40, 0, -178);
|
||||
const ISLAND_SUNK = -48;
|
||||
/** The ship's course from open sea to the anchorage, s in 0..1, with a gentle S so it is not a ruler line. */
|
||||
const shipAt = (s: number) =>
|
||||
v3(lerp(SHIP_A.x, SHIP_B.x, s) + Math.sin(s * Math.PI) * 9, 0, lerp(SHIP_A.z, SHIP_B.z, s));
|
||||
|
||||
export class CurtainScene {
|
||||
private renderer: THREE.WebGLRenderer;
|
||||
private sea = new THREE.Scene();
|
||||
private cave = new THREE.Scene();
|
||||
private camera = new THREE.PerspectiveCamera(46, 16 / 9, 0.5, 1400);
|
||||
private caveCam = new THREE.PerspectiveCamera(54, 16 / 9, 0.3, 400);
|
||||
private waveU = { uTime: { value: 0 } };
|
||||
private seaU = { uDeep: { value: new THREE.Color() }, uCrest: { value: new THREE.Color() }, uFoam: { value: new THREE.Color() } };
|
||||
private skyU = { uTop: { value: new THREE.Color() }, uHorizon: { value: new THREE.Color() }, uSun: { value: new THREE.Color() }, uGlow: { value: 0.5 }, uSunDir: { value: new THREE.Vector3(-0.55, 0.34, -0.76).normalize() } };
|
||||
private fog = new THREE.Fog(0xffffff, 80, 330);
|
||||
private hemi = new THREE.HemisphereLight(0xffffff, 0x224455, 1.3);
|
||||
private sun = new THREE.DirectionalLight(0xffffff, 2);
|
||||
private seaMat = new THREE.MeshStandardMaterial({ color: 0xffffff, flatShading: true, roughness: 0.62, metalness: 0.05 });
|
||||
private ship = new THREE.Group();
|
||||
private island = new THREE.Group();
|
||||
private islandMats: { natural: THREE.MeshStandardMaterial; flat: THREE.MeshBasicMaterial; meshes: THREE.Mesh[] };
|
||||
private shipMats = { hull: solid("#2b2522"), stripe: solid("#1d5f86"), wood: solid("#6d4a30"), sail: new THREE.MeshStandardMaterial({ side: THREE.DoubleSide, flatShading: true, roughness: 1 }) };
|
||||
private sailCanvas = document.createElement("canvas");
|
||||
private sailTex: THREE.CanvasTexture;
|
||||
private islets: THREE.Mesh[] = [];
|
||||
// cave
|
||||
private caveMats = { rock: solid("#4d4540", { side: THREE.BackSide }), floor: solid("#3b3430"), giant: new THREE.MeshBasicMaterial({ color: "#120e0c" }), wool: solid("#efece4", { emissive: "#3a3026", roughness: 1 }), fire: new THREE.MeshBasicMaterial({ color: "#ff8a3a" }), core: new THREE.MeshBasicMaterial({ color: "#ffe08a" }) };
|
||||
private fireLight = new THREE.PointLight(0xff8a3a, 900, 0, 1.7);
|
||||
private caveHemi = new THREE.HemisphereLight(0xffffff, 0x000000, 0.25);
|
||||
private flames: THREE.Mesh[] = [];
|
||||
private eye: THREE.Mesh;
|
||||
private ro: ResizeObserver;
|
||||
private look: Look = 2;
|
||||
private pal: Palette = PALETTES[2];
|
||||
private tmp = new THREE.Vector3();
|
||||
/** Fraction down the stage where the frieze's ground line sits; at p=0 the horizon sits exactly on it. */
|
||||
private horizonAt = 0.645;
|
||||
|
||||
constructor(private host: HTMLElement, private reduced: boolean) {
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance", preserveDrawingBuffer: true });
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75));
|
||||
this.renderer.domElement.className = "stage__canvas";
|
||||
this.renderer.domElement.setAttribute("aria-hidden", "true");
|
||||
host.append(this.renderer.domElement);
|
||||
this.camera.rotation.order = "YXZ";
|
||||
|
||||
this.sea.fog = this.fog;
|
||||
this.sea.add(this.hemi, this.sun);
|
||||
this.sun.position.set(-60, 90, -160);
|
||||
this.buildSky();
|
||||
this.buildSea();
|
||||
this.islandMats = this.buildIsland();
|
||||
this.buildIslets();
|
||||
this.sailCanvas.width = this.sailCanvas.height = 64;
|
||||
this.sailTex = new THREE.CanvasTexture(this.sailCanvas);
|
||||
this.sailTex.colorSpace = THREE.SRGBColorSpace;
|
||||
this.shipMats.sail.map = this.sailTex;
|
||||
this.buildShip();
|
||||
this.eye = this.buildCave();
|
||||
|
||||
this.setLook(2);
|
||||
this.ro = new ResizeObserver(() => this.resize());
|
||||
this.ro.observe(host);
|
||||
this.resize();
|
||||
}
|
||||
|
||||
/* ----- build: sea ----- */
|
||||
|
||||
private buildSky() {
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
side: THREE.BackSide, depthWrite: false, fog: false, uniforms: this.skyU,
|
||||
vertexShader: /* glsl */ `varying vec3 vDir; void main(){ vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform vec3 uTop; uniform vec3 uHorizon; uniform vec3 uSun; uniform vec3 uSunDir; uniform float uGlow; varying vec3 vDir;
|
||||
void main(){
|
||||
vec3 col = mix(uHorizon, uTop, smoothstep(-0.02, 0.5, vDir.y));
|
||||
float s = max(dot(normalize(vDir), uSunDir), 0.0);
|
||||
col += uSun * (pow(s, 40.0) * uGlow + pow(s, 900.0) * 0.6);
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}`,
|
||||
});
|
||||
const sky = new THREE.Mesh(new THREE.SphereGeometry(1000, 32, 16), mat);
|
||||
sky.renderOrder = -1;
|
||||
this.sea.add(sky);
|
||||
}
|
||||
|
||||
private buildSea() {
|
||||
const g = new THREE.PlaneGeometry(1300, 1300, 170, 170);
|
||||
const mat = this.seaMat;
|
||||
mat.onBeforeCompile = (shader) => {
|
||||
Object.assign(shader.uniforms, this.waveU, this.seaU);
|
||||
shader.vertexShader = shader.vertexShader
|
||||
.replace("#include <common>", `#include <common>\n${WAVE_GLSL}\nvarying float vH;`)
|
||||
.replace("#include <begin_vertex>", `#include <begin_vertex>
|
||||
vec4 seaWp = modelMatrix * vec4(transformed, 1.0);
|
||||
float seaH = seaWave(seaWp.xz);
|
||||
transformed.z += seaH;
|
||||
vH = seaH;`);
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace("#include <common>", `#include <common>\nuniform vec3 uDeep;\nuniform vec3 uCrest;\nuniform vec3 uFoam;\nvarying float vH;`)
|
||||
.replace("#include <color_fragment>", `#include <color_fragment>
|
||||
diffuseColor.rgb = mix(uDeep, uCrest, smoothstep(-1.3, 1.5, vH));
|
||||
diffuseColor.rgb = mix(diffuseColor.rgb, uFoam, smoothstep(0.95, 1.5, vH) * 0.6);`);
|
||||
};
|
||||
const sea = new THREE.Mesh(g, mat);
|
||||
sea.rotation.x = -Math.PI / 2;
|
||||
sea.position.set(0, 0, -120);
|
||||
this.sea.add(sea);
|
||||
}
|
||||
|
||||
private buildIsland() {
|
||||
const basalt = { sand: "#b8ab8c", rock: "#5d5a55", top: "#56603f" };
|
||||
const natural = new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 0.95 });
|
||||
const flat = new THREE.MeshBasicMaterial({ color: "#1d140e" });
|
||||
const meshes: THREE.Mesh[] = [];
|
||||
const add = (geo: THREE.BufferGeometry, x: number, z: number) => {
|
||||
const m = new THREE.Mesh(geo, natural);
|
||||
m.position.set(x, 0, z);
|
||||
this.island.add(m);
|
||||
meshes.push(m);
|
||||
return m;
|
||||
};
|
||||
add(islandGeometry({ radius: 26, height: 32, seed: 23, ...basalt }), 0, 0);
|
||||
add(islandGeometry({ radius: 18, height: 22, seed: 41, ...basalt }), -18, -16);
|
||||
add(islandGeometry({ radius: 12, height: 14, seed: 57, ...basalt }), 20, -6);
|
||||
// The cave mouth faces the ship's anchorage.
|
||||
// faces the landfall camera, so the cave reads at beat 3
|
||||
const toShip = v3(SHIP_B.x + 40, 0, SHIP_B.z + 46).sub(ISLAND).setY(0).normalize();
|
||||
const mouth = new THREE.Mesh(new THREE.CircleGeometry(6.5, 9, 0, Math.PI), new THREE.MeshBasicMaterial({ color: "#100c0b", side: THREE.DoubleSide }));
|
||||
mouth.position.copy(toShip.clone().multiplyScalar(21.5)).setY(0.8);
|
||||
mouth.lookAt(mouth.position.clone().add(toShip));
|
||||
this.island.add(mouth);
|
||||
const boulderGeo = new THREE.DodecahedronGeometry(2.8, 0).toNonIndexed();
|
||||
const bc = new Float32Array((boulderGeo.getAttribute("position") as THREE.BufferAttribute).count * 3).fill(0.42);
|
||||
boulderGeo.setAttribute("color", new THREE.BufferAttribute(bc, 3));
|
||||
const boulder = new THREE.Mesh(boulderGeo, natural);
|
||||
boulder.position.copy(toShip.clone().multiplyScalar(21)).add(v3(6, 2, 2));
|
||||
this.island.add(boulder);
|
||||
meshes.push(boulder);
|
||||
this.island.position.copy(ISLAND);
|
||||
this.sea.add(this.island);
|
||||
return { natural, flat, meshes };
|
||||
}
|
||||
|
||||
private buildIslets() {
|
||||
const spots: [number, number, number][] = [[95, -260, 9], [-130, -320, 12], [70, -90, 5], [-95, -60, 6], [160, -150, 7]];
|
||||
spots.forEach(([x, z, r], i) => {
|
||||
const geo = islandGeometry({ radius: r, height: 4 + r * 0.9, seed: 100 + i, sand: "#d9c9a2", rock: "#9f8e74", top: "#7b8250" });
|
||||
const m = new THREE.Mesh(geo, this.islandMats.natural);
|
||||
m.position.set(x, 0, z);
|
||||
this.sea.add(m);
|
||||
this.islets.push(m);
|
||||
});
|
||||
}
|
||||
|
||||
private buildShip() {
|
||||
const s = this.ship;
|
||||
const outline = new THREE.Shape();
|
||||
outline.moveTo(0, 4.6);
|
||||
outline.quadraticCurveTo(1.7, 1.6, 1.55, -1.6);
|
||||
outline.quadraticCurveTo(1.3, -3.4, 0, -3.8);
|
||||
outline.quadraticCurveTo(-1.3, -3.4, -1.55, -1.6);
|
||||
outline.quadraticCurveTo(-1.7, 1.6, 0, 4.6);
|
||||
const hullGeo = new THREE.ExtrudeGeometry(outline, { depth: 1.5, bevelEnabled: false, curveSegments: 5 });
|
||||
hullGeo.rotateX(Math.PI / 2);
|
||||
hullGeo.translate(0, 1.1, 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(new THREE.Mesh(hullGeo, this.shipMats.hull));
|
||||
const stripe = new THREE.Mesh(new THREE.BoxGeometry(3.25, 0.28, 6.2), this.shipMats.stripe);
|
||||
stripe.position.set(0, 0.95, 0.3);
|
||||
s.add(stripe);
|
||||
const stern = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.3, 3.2, 5), this.shipMats.hull);
|
||||
stern.position.set(0, 2.2, -3.9);
|
||||
stern.rotation.x = -0.6;
|
||||
s.add(stern);
|
||||
for (const side of [-1, 1]) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const oar = new THREE.Mesh(new THREE.BoxGeometry(3.4, 0.08, 0.16), this.shipMats.wood);
|
||||
oar.position.set(side * 2.6, 0.55, -1.8 + i * 1.05);
|
||||
oar.rotation.z = side * -0.35;
|
||||
s.add(oar);
|
||||
}
|
||||
}
|
||||
const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.14, 8, 5), this.shipMats.wood);
|
||||
mast.position.y = 4.8;
|
||||
const yard = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 6.2, 5), this.shipMats.wood);
|
||||
yard.rotation.z = Math.PI / 2;
|
||||
yard.position.y = 7.9;
|
||||
s.add(mast, yard);
|
||||
const sailGeo = new THREE.PlaneGeometry(5.8, 4.4, 6, 4);
|
||||
const sp = sailGeo.getAttribute("position") as THREE.BufferAttribute;
|
||||
for (let i = 0; i < sp.count; i++) {
|
||||
const x = sp.getX(i) / 2.9, y = sp.getY(i) / 2.2;
|
||||
sp.setZ(i, (1 - x * x) * (1 - 0.4 * y * y) * 0.9);
|
||||
}
|
||||
sailGeo.computeVertexNormals();
|
||||
const sail = new THREE.Mesh(sailGeo, this.shipMats.sail);
|
||||
sail.position.set(0, 5.6, 0.2);
|
||||
s.add(sail);
|
||||
this.sea.add(s);
|
||||
}
|
||||
|
||||
/* ----- build: cave ----- */
|
||||
|
||||
private buildCave(): THREE.Mesh {
|
||||
const c = this.cave;
|
||||
// A lumpy dome, seen from inside.
|
||||
let dome: THREE.BufferGeometry = new THREE.IcosahedronGeometry(1, 3);
|
||||
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.22;
|
||||
dp.setXYZ(i, x * n, y * n, z * n);
|
||||
}
|
||||
dome = dome.toNonIndexed();
|
||||
dome.computeVertexNormals();
|
||||
const shell = new THREE.Mesh(dome, this.caveMats.rock);
|
||||
shell.scale.set(44, 30, 56);
|
||||
shell.position.set(0, 4, -16);
|
||||
c.add(shell);
|
||||
const floor = new THREE.Mesh(new THREE.CircleGeometry(70, 24), this.caveMats.floor);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.position.y = -9;
|
||||
c.add(floor);
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const r = 1.5 + hash(i, 2, 9) * 3.5;
|
||||
const b = new THREE.Mesh(new THREE.DodecahedronGeometry(r, 0), this.caveMats.floor);
|
||||
const side = i % 2 ? 1 : -1;
|
||||
b.position.set(side * (12 + hash(i, 4, 1) * 18), -9 + r * 0.5, -hash(1, i, 4) * 50 + 10);
|
||||
c.add(b);
|
||||
}
|
||||
|
||||
// The fire, behind the giant, so he stands black against his own firelit wall.
|
||||
const fire = new THREE.Group();
|
||||
fire.position.set(-11, -9, -38);
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const h = 3 + hash(i, 6, 6) * 4;
|
||||
const f = new THREE.Mesh(new THREE.ConeGeometry(0.9 + hash(6, i, 6) * 0.8, h, 5), i % 3 === 0 ? this.caveMats.core : this.caveMats.fire);
|
||||
f.position.set((hash(i, 1, 1) - 0.5) * 4, h / 2, (hash(1, i, 1) - 0.5) * 3);
|
||||
f.userData = { h, phase: i * 1.7 };
|
||||
fire.add(f);
|
||||
this.flames.push(f);
|
||||
}
|
||||
c.add(fire);
|
||||
this.fireLight.position.set(-11, -3, -36);
|
||||
c.add(this.fireLight, this.caveHemi);
|
||||
const fill = new THREE.PointLight(0xffb070, 120, 0, 1.6);
|
||||
fill.position.set(8, 6, 2);
|
||||
c.add(fill);
|
||||
|
||||
// Polyphemus: a blocky giant, seated-tall, one eye catching the firelight.
|
||||
const gm = this.caveMats.giant;
|
||||
const giant = new THREE.Group();
|
||||
const box = (w: number, h: number, d: number, x: number, y: number, z: number, rz = 0) => {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), gm);
|
||||
m.position.set(x, y, z); m.rotation.z = rz; giant.add(m);
|
||||
};
|
||||
box(3.2, 13, 3.2, -2.6, 6.5, 0, 0.06);
|
||||
box(3.2, 13, 3.2, 2.6, 6.5, 0, -0.06);
|
||||
box(10, 12, 5, 0, 18.5, 0);
|
||||
box(13, 3.2, 5.4, 0, 23.6, 0);
|
||||
box(2.8, 12, 2.8, -7.4, 17, 0, -0.22);
|
||||
box(2.8, 11, 2.8, 7.8, 20, 0, 0.9);
|
||||
box(2.2, 16, 2.2, 12, 25, 0, 0.35); // the club, raised
|
||||
const head = new THREE.Mesh(new THREE.IcosahedronGeometry(3.6, 0), gm);
|
||||
head.position.set(0, 28.5, 0);
|
||||
giant.add(head);
|
||||
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.75, 10, 8), new THREE.MeshBasicMaterial({ color: "#ffe9b0" }));
|
||||
eye.position.set(0, 29.2, 3.3);
|
||||
giant.add(eye);
|
||||
giant.position.set(6, -9, -34);
|
||||
giant.rotation.y = -0.3;
|
||||
c.add(giant);
|
||||
|
||||
// The flock, near the mouth: woolly bodies with dark faces, so they do not read as rocks.
|
||||
const face = new THREE.MeshBasicMaterial({ color: "#1a1410" });
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const sheep = new THREE.Group();
|
||||
const body = new THREE.Mesh(new THREE.IcosahedronGeometry(1.3, 1), this.caveMats.wool);
|
||||
body.scale.set(1.35, 0.95, 1);
|
||||
const head = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.8, 0.9), face);
|
||||
head.position.set(1.75, 0.35, 0);
|
||||
sheep.add(body, head);
|
||||
for (const [lx, lz] of [[-0.8, -0.5], [-0.8, 0.5], [0.8, -0.5], [0.8, 0.5]] as const) {
|
||||
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.22, 1, 0.22), face);
|
||||
leg.position.set(lx, -1.1, lz);
|
||||
sheep.add(leg);
|
||||
}
|
||||
sheep.position.set(-22 + i * 3.6 + hash(i, 3, 3) * 2, -7.4, -2 - hash(3, i, 3) * 10);
|
||||
sheep.rotation.y = (hash(i, 9, 2) - 0.5) * 1.6 + (i % 2 ? Math.PI : 0);
|
||||
c.add(sheep);
|
||||
}
|
||||
return eye;
|
||||
}
|
||||
|
||||
/* ----- look ----- */
|
||||
|
||||
setLook(look: Look) {
|
||||
this.look = look;
|
||||
const p = (this.pal = PALETTES[look]);
|
||||
const col = (h: string) => new THREE.Color(h);
|
||||
this.skyU.uTop.value.set(p.skyTop);
|
||||
this.skyU.uHorizon.value.set(p.skyHorizon);
|
||||
this.skyU.uSun.value.set(p.sun);
|
||||
this.skyU.uGlow.value = p.sunGlow;
|
||||
this.seaU.uDeep.value.set(p.deep);
|
||||
this.seaU.uCrest.value.set(p.crest);
|
||||
this.seaU.uFoam.value.set(p.foam);
|
||||
this.fog.color.set(p.fog);
|
||||
this.fog.near = p.fogNear;
|
||||
this.fog.far = p.fogFar;
|
||||
this.hemi.color.set(p.hemiSky);
|
||||
this.hemi.groundColor.set(p.hemiGround);
|
||||
this.hemi.intensity = p.hemiI;
|
||||
this.sun.intensity = p.sunI;
|
||||
this.sun.color.set(p.sunLight);
|
||||
this.seaMat.roughness = p.seaRough;
|
||||
this.islandMats.natural.color.set(p.islandTint);
|
||||
this.islandMats.flat.color.set(p.islandTint);
|
||||
for (const m of [...this.islandMats.meshes, ...this.islets]) m.material = p.silhouette ? this.islandMats.flat : this.islandMats.natural;
|
||||
const shipMat = (m: THREE.MeshStandardMaterial, h: string) => { m.color.set(h); m.emissive.set(p.silhouette ? h : "#000000"); };
|
||||
shipMat(this.shipMats.hull, p.hull);
|
||||
shipMat(this.shipMats.stripe, p.stripe);
|
||||
shipMat(this.shipMats.wood, p.wood);
|
||||
const ctx = this.sailCanvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.fillStyle = p.sail; ctx.fillRect(0, 0, 64, 64);
|
||||
ctx.fillStyle = p.sailStripe;
|
||||
if (p.silhouette) { for (const y of [12, 30, 48]) ctx.fillRect(0, y, 64, 4); }
|
||||
else for (const x of [10, 27, 44]) ctx.fillRect(x, 0, 9, 64);
|
||||
}
|
||||
this.sailTex.needsUpdate = true;
|
||||
this.shipMats.sail.emissive.set(p.silhouette ? "#ffffff" : "#000000");
|
||||
this.shipMats.sail.emissiveMap = p.silhouette ? this.sailTex : null;
|
||||
this.shipMats.sail.needsUpdate = true;
|
||||
// cave
|
||||
this.caveMats.rock.color.set(p.rock);
|
||||
this.caveMats.floor.color.set(p.caveFloor);
|
||||
this.caveMats.giant.color.set(p.giant);
|
||||
this.caveMats.wool.color.set(p.wool);
|
||||
this.caveMats.fire.color.set(p.fire);
|
||||
this.caveMats.core.color.set(p.fireCore);
|
||||
this.fireLight.color.copy(col(p.fire));
|
||||
this.caveHemi.intensity = p.caveAmbient;
|
||||
}
|
||||
|
||||
/* ----- frame ----- */
|
||||
|
||||
setHorizon(fraction: number) { this.horizonAt = fraction; }
|
||||
|
||||
private resize() {
|
||||
const w = this.host.clientWidth, h = this.host.clientHeight;
|
||||
if (!w || !h) return;
|
||||
this.renderer.setSize(w, h, false);
|
||||
this.camera.aspect = this.caveCam.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.caveCam.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
/** Points the sea camera from pos at target; at p=0 the pitch is forced so the horizon sits on the frieze's ground line. */
|
||||
private aimSea(pos: THREE.Vector3, target: THREE.Vector3, horizonLock: number) {
|
||||
const d = this.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 tanHalf = Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2));
|
||||
const pitchLock = Math.atan((this.horizonAt - 0.5) * 2 * tanHalf);
|
||||
this.camera.position.copy(pos);
|
||||
this.camera.rotation.set(lerp(pitchAim, pitchLock, horizonLock), yaw, 0);
|
||||
}
|
||||
|
||||
/** Render the world at beat position p (0 = frieze … 4 = cave), wall-clock time in seconds. */
|
||||
render(p: number, time: number) {
|
||||
const t = this.reduced ? time * 0.25 : time;
|
||||
this.waveU.uTime.value = t;
|
||||
if (p < 2.9) this.renderSea(p, t);
|
||||
else this.renderCave(p, t);
|
||||
}
|
||||
|
||||
private renderSea(p: number, t: number) {
|
||||
const sail = easeInOut(p - 1);
|
||||
const shipPos = shipAt(sail);
|
||||
const ahead = shipAt(Math.min(1, sail + 0.01)).sub(shipAt(Math.max(0, sail - 0.01)));
|
||||
const heading = Math.atan2(ahead.x, ahead.z);
|
||||
this.ship.position.set(shipPos.x, seaWave(shipPos.x, shipPos.z, t) * 0.6 - 0.15, shipPos.z);
|
||||
this.ship.rotation.set(Math.sin(t * 0.9) * 0.03, heading, Math.sin(t * 1.1) * 0.05);
|
||||
|
||||
const rise = easeInOut((p - 1.12) / 0.78);
|
||||
this.island.position.y = lerp(ISLAND_SUNK, 0, rise);
|
||||
this.island.visible = rise > 0;
|
||||
|
||||
// camera keyframes, as offsets from the ship
|
||||
const m = ease((p - 0.42) / 0.58); // frieze horizon → over the shoulder (holds still while the band parts)
|
||||
const n = easeInOut(p - 1); // over the shoulder → landfall framing
|
||||
const off0 = v3(0, 3.2, 74), off1 = v3(13, 10, 36), off2 = v3(40, 13, 46);
|
||||
const camOff = p <= 1 ? off0.lerp(off1, m) : off1.lerp(off2, n);
|
||||
const pos = shipPos.clone().add(camOff);
|
||||
const tgt1 = shipPos.clone().add(v3(-10, 1, -26));
|
||||
const tgt2 = shipPos.clone().lerp(ISLAND, 0.62).setY(6);
|
||||
const target = p <= 1 ? tgt1 : tgt1.lerp(tgt2, n);
|
||||
this.aimSea(pos, target, p <= 1 ? 1 - m : 0);
|
||||
this.renderer.render(this.sea, this.camera);
|
||||
}
|
||||
|
||||
private renderCave(p: number, t: number) {
|
||||
const k = easeInOut((p - 3.12) / 0.88);
|
||||
const pos = v3(lerp(0, 3, k), lerp(1, -3, k), lerp(40, 20, k));
|
||||
const target = v3(lerp(0, -1, k), lerp(0, 5, k), -40);
|
||||
this.caveCam.position.copy(pos);
|
||||
this.caveCam.lookAt(target);
|
||||
for (const f of this.flames) {
|
||||
const s = 0.8 + 0.25 * Math.sin(t * 9 + f.userData.phase) + 0.1 * Math.sin(t * 23 + f.userData.phase * 2);
|
||||
f.scale.set(1, s, 1);
|
||||
f.position.y = (f.userData.h * s) / 2;
|
||||
}
|
||||
this.fireLight.intensity = (this.look === 3 ? 760 : 900) * (0.88 + 0.12 * Math.sin(t * 11) * Math.sin(t * 4.3));
|
||||
(this.eye.material as THREE.MeshBasicMaterial).color.setScalar(0.8 + 0.2 * Math.sin(t * 2));
|
||||
this.renderer.render(this.cave, this.caveCam);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.ro.disconnect();
|
||||
this.renderer.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1 +1,334 @@
|
||||
body { margin: 0; --stub-13: 1; }
|
||||
/* 13 · Curtain call — frieze → 3D sea → landfall → frieze → cave, with the look of the whole as a toggle. */
|
||||
@import "@fontsource/cormorant-garamond/latin-500.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-600.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-700.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
|
||||
@import "@fontsource/inter/latin-400.css";
|
||||
@import "@fontsource/inter/latin-600.css";
|
||||
|
||||
:root {
|
||||
/* black-figure (04) */
|
||||
--clay: #c46a33;
|
||||
--clay-hi: #dc8d52;
|
||||
--glaze: #1d140e;
|
||||
--added-white: #efe3c8;
|
||||
/* vellum (12) */
|
||||
--vellum: #e3d6b4;
|
||||
--vellum-hi: #ece2c6;
|
||||
--iron: #2b2118;
|
||||
--iron-soft: #5e4d3a;
|
||||
--rhumb-red: #a23a2a;
|
||||
--desk: #3a2f24;
|
||||
--serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
|
||||
--sans: "Inter", system-ui, sans-serif;
|
||||
--ease-out: cubic-bezier(0.22, 0.8, 0.2, 1);
|
||||
|
||||
/* the frieze, per look (default: look 2, terracotta and black) */
|
||||
--surround: var(--glaze);
|
||||
--band: var(--clay);
|
||||
--ink: var(--glaze);
|
||||
--inc: var(--clay);
|
||||
/* the interface, per look */
|
||||
--ui-bg: rgb(236 226 198 / 0.94);
|
||||
--ui-fg: var(--iron);
|
||||
--ui-soft: var(--iron-soft);
|
||||
--ui-line: rgb(43 33 24 / 0.22);
|
||||
--ui-accent: var(--rhumb-red);
|
||||
--cut: var(--glaze);
|
||||
--foot-bg: var(--desk);
|
||||
--foot-fg: var(--vellum-hi);
|
||||
}
|
||||
body[data-look="1"] {
|
||||
--surround: #ddcfa9;
|
||||
--band: var(--vellum-hi);
|
||||
--ink: #5b4128;
|
||||
--inc: var(--vellum-hi);
|
||||
--cut: var(--vellum);
|
||||
--foot-bg: #d6c69e;
|
||||
--foot-fg: var(--iron);
|
||||
}
|
||||
body[data-look="3"] {
|
||||
--ui-bg: rgb(29 20 14 / 0.9);
|
||||
--ui-fg: var(--added-white);
|
||||
--ui-soft: var(--clay-hi);
|
||||
--ui-line: rgb(196 106 51 / 0.45);
|
||||
--ui-accent: var(--clay-hi);
|
||||
--foot-bg: var(--glaze);
|
||||
--foot-fg: var(--clay-hi);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
background: var(--foot-bg);
|
||||
color: var(--ui-fg);
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 3px solid var(--ui-accent); outline-offset: 2px; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
#app { min-height: 0; display: grid; }
|
||||
[data-fan-credits] {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
padding: 0.85rem 1.5rem;
|
||||
background: var(--foot-bg);
|
||||
color: var(--foot-fg);
|
||||
opacity: 1;
|
||||
border-top: 1px solid var(--ui-line);
|
||||
}
|
||||
|
||||
/* ---------- stage ---------- */
|
||||
.stage {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--surround);
|
||||
isolation: isolate;
|
||||
}
|
||||
.stage__three, .frieze-host, .cut, .stage__paper, .stage__fallback { position: absolute; inset: 0; }
|
||||
.stage__three { z-index: 0; }
|
||||
.stage__canvas { display: block; width: 100%; height: 100%; }
|
||||
body[data-look="1"] .stage__canvas { filter: sepia(0.38) saturate(0.85) contrast(0.96); }
|
||||
.stage__fallback { display: grid; place-items: center; background: linear-gradient(#6f9fb4, #efc996 60%, #185a70 61%); color: var(--iron); }
|
||||
|
||||
/* Look 1 lays a vellum grain over the 3D, so sea and chart read as one sheet. */
|
||||
.stage__paper {
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at center, transparent 55%, rgb(120 92 52 / 0.32) 100%),
|
||||
repeating-linear-gradient(90deg, rgb(91 65 40 / 0.035) 0 1px, transparent 1px 7px),
|
||||
repeating-linear-gradient(0deg, rgb(91 65 40 / 0.03) 0 1px, transparent 1px 9px);
|
||||
mix-blend-mode: multiply;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
body[data-look="1"] .stage__paper { opacity: 1; }
|
||||
|
||||
/* ---------- friezes ---------- */
|
||||
.frieze-host { z-index: 2; pointer-events: none; will-change: transform, opacity; }
|
||||
.frieze { display: block; width: 100%; height: 100%; overflow: visible; }
|
||||
.frieze-host--cyclops { z-index: 3; }
|
||||
.f-surround { fill: var(--surround); }
|
||||
.f-band { fill: var(--band); }
|
||||
.ink { fill: var(--ink); }
|
||||
.clay { fill: var(--inc); }
|
||||
.inc { fill: none; stroke: var(--inc); stroke-linecap: round; stroke-linejoin: round; }
|
||||
.limb { fill: none; stroke: var(--ink); stroke-linecap: round; stroke-linejoin: round; }
|
||||
.limb-edge { fill: none; stroke: var(--inc); stroke-linecap: round; stroke-linejoin: round; }
|
||||
.oar { stroke: var(--ink); stroke-width: 3.2; stroke-linecap: round; }
|
||||
.rig { fill: none; stroke: var(--ink); stroke-width: 1.4; }
|
||||
.wave { fill: none; stroke: var(--ink); stroke-linecap: round; }
|
||||
.ground { stroke: var(--ink); stroke-width: 3.5; }
|
||||
.rule { fill: none; stroke: var(--ink); stroke-width: 2.6; }
|
||||
.vol { fill: none; stroke: var(--ink); stroke-width: 3; stroke-linecap: round; }
|
||||
.mean { fill: none; stroke: var(--ink); stroke-width: 3; }
|
||||
.frame-rule { fill: none; stroke: var(--ink); stroke-width: 1.2; display: none; }
|
||||
.inscr { font: 600 19px var(--serif); letter-spacing: 0.2em; fill: var(--ink); }
|
||||
.saga-name { font: 600 15px var(--serif); letter-spacing: 0.22em; fill: var(--ink); }
|
||||
.glow-a { stop-color: #e2803a; }
|
||||
.glow-b { stop-color: #3a170a; }
|
||||
.glow-c { stop-color: #0c0705; }
|
||||
|
||||
/* Look 1: the same figures, redrawn as pen and wash on vellum — the chart's illustrated border. */
|
||||
body[data-look="1"] .frieze .ink { fill: rgb(91 65 40 / 0.14); stroke: var(--ink); stroke-width: 2; stroke-linejoin: round; }
|
||||
body[data-look="1"] .frieze .inc { stroke: var(--ink); opacity: 0.55; }
|
||||
body[data-look="1"] .frieze .limb { stroke-width: 3px; }
|
||||
body[data-look="1"] .frieze .limb-edge { stroke: transparent; }
|
||||
body[data-look="1"] .frieze .clay { fill: var(--ink); }
|
||||
body[data-look="1"] .mean, body[data-look="1"] .rule { stroke-width: 1.6; }
|
||||
body[data-look="1"] .frame-rule { display: inline; }
|
||||
body[data-look="1"] .inscr, body[data-look="1"] .saga-name { font-style: italic; font-weight: 500; letter-spacing: 0.12em; fill: var(--rhumb-red); }
|
||||
body[data-look="1"] .glow-a { stop-color: #d99a55; }
|
||||
body[data-look="1"] .glow-b { stop-color: #4a3420; }
|
||||
body[data-look="1"] .glow-c { stop-color: #241911; }
|
||||
|
||||
/* the fade-through used for jumps and under reduced motion */
|
||||
.cut {
|
||||
z-index: 6;
|
||||
background: var(--cut);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.cut.is-on { opacity: 1; }
|
||||
|
||||
/* ---------- the captain's-table map ---------- */
|
||||
.chart {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 28px;
|
||||
bottom: 112px;
|
||||
width: 330px;
|
||||
margin: 0;
|
||||
padding: 10px 12px 12px;
|
||||
background: linear-gradient(160deg, #ece2c6, #dccca5);
|
||||
color: var(--iron);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 0 rgb(255 255 255 / 0.4) inset, 0 18px 30px -12px rgb(20 12 6 / 0.55), 0 3px 8px rgb(20 12 6 / 0.3);
|
||||
transform-origin: 30% 100%;
|
||||
}
|
||||
.chart::before, .chart::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -6px; bottom: -6px;
|
||||
width: 9px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(90deg, #8e7b58, #e2d5b2 45%, #8e7b58);
|
||||
}
|
||||
.chart::before { left: -5px; }
|
||||
.chart::after { right: -5px; }
|
||||
.chart__head { margin: 0 0 6px; font: 600 1.05rem/1.1 var(--serif); }
|
||||
.chart__head span { display: block; font: italic 500 0.8rem/1.2 var(--serif); color: var(--iron-soft); }
|
||||
.chart__svg { display: block; width: 100%; height: auto; }
|
||||
.c-vellum { fill: #e6dab9; }
|
||||
.c-frame { fill: none; stroke: var(--iron); stroke-width: 0.8; }
|
||||
.c-rhumb { fill: none; stroke: rgb(162 58 42 / 0.18); stroke-width: 0.5; }
|
||||
.c-land { fill: #c9b07a; stroke: var(--iron); stroke-width: 0.9; }
|
||||
.c-ink { fill: none; stroke: var(--iron); stroke-width: 0.7; }
|
||||
.c-course { fill: none; stroke: var(--rhumb-red); stroke-width: 1.3; stroke-dasharray: 3 2.4; }
|
||||
.c-port { fill: #f3ebd6; stroke: var(--iron); stroke-width: 0.9; }
|
||||
.c-num { font: 600 6.5px var(--sans); fill: var(--iron); }
|
||||
.c-place { font: 600 italic 13px var(--serif); fill: var(--iron); }
|
||||
.c-sea { font: italic 500 8.5px var(--serif); fill: var(--rhumb-red); }
|
||||
.c-rose circle { fill: none; stroke: var(--iron-soft); stroke-width: 0.6; }
|
||||
.c-rose path { fill: var(--iron-soft); }
|
||||
.c-here__ring { fill: rgb(162 58 42 / 0.18); stroke: var(--rhumb-red); stroke-width: 1.4; }
|
||||
.c-here__dot { fill: var(--rhumb-red); }
|
||||
.c-here__label { font: 600 8.5px var(--sans); fill: var(--rhumb-red); paint-order: stroke; stroke: #efe6cd; stroke-width: 2.5px; }
|
||||
|
||||
/* ---------- caption ---------- */
|
||||
.cap {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
top: 22px;
|
||||
left: 28px;
|
||||
min-width: 300px;
|
||||
max-width: 460px;
|
||||
padding: 12px 18px 14px;
|
||||
background: var(--ui-bg);
|
||||
color: var(--ui-fg);
|
||||
border-left: 3px solid var(--ui-accent);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 10px 28px -14px rgb(0 0 0 / 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.cap__eyebrow { margin: 0; font-size: 0.75rem; letter-spacing: 0.04em; color: var(--ui-soft); }
|
||||
.cap__title { margin: 2px 0 8px; font: 600 2rem/1.05 var(--serif); }
|
||||
.cap__row { margin: 0; display: flex; align-items: center; gap: 10px; min-height: 26px; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 3px 10px 3px 4px;
|
||||
border: 1px solid var(--ui-line);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip__n {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 19px; height: 19px;
|
||||
border-radius: 50%;
|
||||
background: var(--ui-fg);
|
||||
color: var(--ui-bg);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.back {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--ui-accent);
|
||||
border-radius: 999px;
|
||||
background: var(--ui-accent);
|
||||
color: #fff8ea;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
body[data-look="3"] .back { color: var(--glaze); }
|
||||
|
||||
/* ---------- control bar ---------- */
|
||||
.bar {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
left: 50%;
|
||||
bottom: 18px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background: var(--ui-bg);
|
||||
color: var(--ui-fg);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 12px 30px -14px rgb(0 0 0 / 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bar button { cursor: pointer; }
|
||||
.bar__play {
|
||||
min-width: 74px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--ui-accent);
|
||||
border-radius: 999px;
|
||||
background: var(--ui-accent);
|
||||
color: #fff8ea;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
body[data-look="3"] .bar__play { color: var(--glaze); }
|
||||
.bar__step {
|
||||
width: 32px; height: 32px;
|
||||
border: 1px solid var(--ui-line);
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
.bar__step:disabled { opacity: 0.35; cursor: default; }
|
||||
.dots { display: flex; gap: 2px; margin: 0; padding: 0; list-style: none; }
|
||||
.dots button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 9px 5px 5px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ui-soft);
|
||||
}
|
||||
.dots__n {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 20px; height: 20px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 50%;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dots button[aria-current] { color: var(--ui-fg); font-weight: 600; }
|
||||
.dots button[aria-current] .dots__n { background: var(--ui-accent); border-color: var(--ui-accent); color: var(--ui-bg); }
|
||||
.looks { display: flex; align-items: center; gap: 4px; margin-left: 8px; padding-left: 14px; border-left: 1px solid var(--ui-line); font-size: 0.8rem; }
|
||||
.looks__label { margin-right: 4px; color: var(--ui-soft); }
|
||||
.looks__sep { color: var(--ui-soft); }
|
||||
.looks button {
|
||||
width: 26px; height: 26px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
.looks button[aria-pressed="true"] { border-color: var(--ui-accent); color: var(--ui-accent); }
|
||||
.looks__name { min-width: 118px; margin-left: 6px; font: italic 500 0.95rem var(--serif); color: var(--ui-soft); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cut { transition-duration: 0.25s; }
|
||||
.stage__paper { transition: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user