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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:08:11 +01:00

140 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}