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>
140 lines
6.3 KiB
TypeScript
140 lines
6.3 KiB
TypeScript
// 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);
|
||
}
|