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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 15:08:11 +01:00
1 parent 27d624fa44
commit ac017787b5
17 files changed
+2865 -2

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
// The voyage's geometry, pure (no three.js): nine places along a winding route and one stop per song.
// The route runs roughly towards -z. Each saga has a waypoint the route passes; its place (island, cliffs,
// whirlpool …) lies to one side of it, and that saga's songs are stops spread along the route past it.
// Within a saga the stops are a few ship-lengths apart; between sagas the route crosses open sea.
import { songs } from "../../data/album";
import type { PlaceId } from "../../data/album";
export interface Place { id: PlaceId; x: number; z: number; radius: number }
export interface Stop { songId: string; placeId: PlaceId; x: number; z: number; heading: number; s: number /* 0..1 along route */ }
export interface RoutePoint { x: number; z: number; heading: number }
/** The route's waypoint per saga, which side of it the place lies on (+1 starboard, -1 port), and its radius. */
const WAYPOINTS: { id: PlaceId; x: number; z: number; side: 1 | -1; radius: number }[] = [
{ id: "troy", x: 0, z: 0, side: -1, radius: 64 },
{ id: "cyclops", x: -150, z: -290, side: -1, radius: 58 },
{ id: "ocean", x: 110, z: -560, side: 1, radius: 60 },
{ id: "circe", x: -90, z: -850, side: -1, radius: 60 },
{ id: "underworld", x: 160, z: -1130, side: 1, radius: 56 },
{ id: "thunder", x: -70, z: -1420, side: -1, radius: 62 },
{ id: "wisdom", x: 170, z: -1700, side: 1, radius: 66 },
{ id: "vengeance", x: -50, z: -1990, side: -1, radius: 60 },
{ id: "ithaca", x: 120, z: -2280, side: 1, radius: 64 },
];
/** How far off the route the place's centre lies, and how much of the route its stops spread over (× radius). */
const OFFSET = 0.9;
const SPAN = 1.1;
function unit(x: number, z: number): [number, number] {
const l = Math.hypot(x, z) || 1;
return [x / l, z / l];
}
/** The place's centre and the route direction at its waypoint. */
function layout(i: number) {
const w = WAYPOINTS[i]!;
const a = WAYPOINTS[Math.max(0, i - 1)]!, b = WAYPOINTS[Math.min(WAYPOINTS.length - 1, i + 1)]!;
const [dx, dz] = unit(b.x - a.x, b.z - a.z);
const [nx, nz] = [-dz, dx]; // starboard when sailing along (dx, dz)
return { w, dx, dz, cx: w.x + w.side * nx * OFFSET * w.radius, cz: w.z + w.side * nz * OFFSET * w.radius };
}
export const PLACES: Place[] = WAYPOINTS.map((w, i) => {
const l = layout(i);
return { id: w.id, x: l.cx, z: l.cz, radius: w.radius };
});
/** Which side of the route each place lies on (+1 starboard, -1 port): the camera keeps to the other side. */
export const PLACE_SIDE: Record<PlaceId, 1 | -1> = Object.fromEntries(WAYPOINTS.map((w) => [w.id, w.side])) as Record<PlaceId, 1 | -1>;
/* ---------- stop positions and the spline through them ---------- */
const POS: { x: number; z: number; songId: string; placeId: PlaceId }[] = [];
WAYPOINTS.forEach((w, i) => {
const l = layout(i);
const own = songs.filter((s) => s.sagaId === w.id);
own.forEach((song, k) => {
const t = own.length > 1 ? k / (own.length - 1) : 0.5;
const along = (t - 0.5) * SPAN * w.radius;
POS.push({ x: w.x + l.dx * along, z: w.z + l.dz * along, songId: song.id, placeId: w.id });
});
});
const CUM: number[] = [0];
for (let i = 1; i < POS.length; i++) CUM.push(CUM[i - 1]! + Math.hypot(POS[i]!.x - POS[i - 1]!.x, POS[i]!.z - POS[i - 1]!.z));
/** The route's length in world units (straight lines between stops); s × ROUTE_LENGTH is a distance. */
export const ROUTE_LENGTH = CUM.at(-1)!;
/** Route position s of each stop: cumulative straight-line distance, normalised to 0..1. */
const S: number[] = CUM.map((v) => v / ROUTE_LENGTH);
/** Control points for the spline, with the ends extended so the first and last segments have neighbours. */
const P: [number, number][] = (() => {
const pts = POS.map((p) => [p.x, p.z] as [number, number]);
const n = pts.length;
return [
[2 * pts[0]![0] - pts[1]![0], 2 * pts[0]![1] - pts[1]![1]],
...pts,
[2 * pts[n - 1]![0] - pts[n - 2]![0], 2 * pts[n - 1]![1] - pts[n - 2]![1]],
];
})();
/** Centripetal Catmull–Rom (Barry–Goldman) on segment i (between stops i and i+1), u in 0..1. No loops or cusps
* where short in-saga hops meet long crossings. */
function spline(i: number, u: number): [number, number] {
const p0 = P[i]!, p1 = P[i + 1]!, p2 = P[i + 2]!, p3 = P[i + 3]!;
const knot = (a: [number, number], b: [number, number]) => Math.max(1e-6, Math.sqrt(Math.hypot(b[0] - a[0], b[1] - a[1])));
const t0 = 0, t1 = t0 + knot(p0, p1), t2 = t1 + knot(p1, p2), t3 = t2 + knot(p2, p3);
const t = t1 + (t2 - t1) * u;
const mix = (a: [number, number], b: [number, number], ta: number, tb: number): [number, number] => {
const k = (t - ta) / (tb - ta);
return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k];
};
const a1 = mix(p0, p1, t0, t1), a2 = mix(p1, p2, t1, t2), a3 = mix(p2, p3, t2, t3);
const b1 = mix(a1, a2, t0, t2), b2 = mix(a2, a3, t1, t3);
return mix(b1, b2, t1, t2);
}
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
/** The segment index and local parameter for a route position. */
function locate(s: number): { i: number; u: number } {
const v = clamp01(s);
let i = 0;
while (i < S.length - 2 && v >= S[i + 1]!) i++;
return { i, u: clamp01((v - S[i]!) / (S[i + 1]! - S[i]!)) };
}
/** A point on the route and the heading there (radians; atan2(dx, dz), as three.js' rotation.y for a ship facing +z). */
export function pointAt(s: number): RoutePoint {
const { i, u } = locate(s);
const [x, z] = spline(i, u);
const e = 1e-3;
const [ax, az] = spline(i, Math.max(0, u - e));
const [bx, bz] = spline(i, Math.min(1, u + e));
return { x, z, heading: Math.atan2(bx - ax, bz - az) };
}
/** Route position → fractional song index (0..39), linear between stops. */
export function sIndex(s: number): number {
const { i, u } = locate(s);
return i + u;
}
/** Fractional song index → route position. */
export function sAt(index: number): number {
const i = Math.min(S.length - 2, Math.max(0, Math.floor(index)));
const u = clamp01(index - i);
return S[i]! + (S[i + 1]! - S[i]!) * u;
}
const STOPS: Stop[] = POS.map((p, i) => ({ ...p, s: S[i]!, heading: pointAt(S[i]!).heading }));
/** One stop per song, in album order. */
export function stops(): Stop[] {
return STOPS.map((s) => ({ ...s }));
}
export function stopOf(songId: string): Stop | undefined {
return STOPS.find((s) => s.songId === songId);
}