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:
1 parent
27d624fa44
commit
ac017787b5
17 files changed
+2865
-2
No files matched your search
@@ -0,0 +1,85 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { sagas, songsInSaga } from "../src/data/album";
|
||||
import { friezeSvg } from "../src/app/frieze/sagas";
|
||||
|
||||
const escapeXml = (s: string) =>
|
||||
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
||||
|
||||
describe("saga friezes", () => {
|
||||
test.each(sagas.map((s) => [s.id, s] as const))("%s: names the saga and inscribes every song", (_id, saga) => {
|
||||
const svg = friezeSvg(saga.id);
|
||||
expect(svg.startsWith("<svg")).toBe(true);
|
||||
expect(svg).toContain(escapeXml(saga.name));
|
||||
for (const song of songsInSaga(saga.id)) expect(svg).toContain(escapeXml(song.title));
|
||||
});
|
||||
|
||||
test("titles with apostrophes are escaped, never raw inside markup", () => {
|
||||
const svg = friezeSvg("wisdom");
|
||||
expect(svg).toContain("We'll Be Fine");
|
||||
expect(svg).not.toContain("We'll Be Fine");
|
||||
});
|
||||
|
||||
test("every saga has its own frieze", () => {
|
||||
const all = sagas.map((s) => friezeSvg(s.id));
|
||||
expect(new Set(all).size).toBe(9);
|
||||
});
|
||||
|
||||
test("landing sagas have a doorway to go ashore through; the others do not", () => {
|
||||
for (const s of sagas) {
|
||||
const has = friezeSvg(s.id).includes('class="door-fill ');
|
||||
expect(has).toBe(s.id === "cyclops" || s.id === "wisdom" || s.id === "ithaca");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the curtain (happy-dom, reduced motion: 200ms crossfades)", () => {
|
||||
const { GlobalRegistrator } = require("@happy-dom/global-registrator") as typeof import("@happy-dom/global-registrator");
|
||||
let createCurtain: typeof import("../src/app/frieze/curtain").createCurtain;
|
||||
let createWorld: typeof import("../src/app/world/world").createWorld;
|
||||
beforeAll(async () => {
|
||||
GlobalRegistrator.register();
|
||||
window.matchMedia = ((q: string) => ({ matches: q.includes("reduce"), media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||
({ createCurtain } = await import("../src/app/frieze/curtain"));
|
||||
({ createWorld } = await import("../src/app/world/world"));
|
||||
});
|
||||
afterAll(() => GlobalRegistrator.unregister());
|
||||
|
||||
test("drop shows the saga's frieze; lift clears it", async () => {
|
||||
const layer = document.createElement("div");
|
||||
document.body.append(layer);
|
||||
const c = createCurtain(layer);
|
||||
await c.drop("cyclops", { landing: "cave" });
|
||||
const host = layer.querySelector<HTMLElement>(".fz-host")!;
|
||||
expect(host.hidden).toBe(false);
|
||||
expect(host.innerHTML).toContain("The Cyclops Saga");
|
||||
await c.lift();
|
||||
expect(host.hidden).toBe(true);
|
||||
expect(Number.isFinite(c.groundY())).toBe(true);
|
||||
});
|
||||
|
||||
test("rapid calls never leave the curtain stuck: the last request wins", async () => {
|
||||
const layer = document.createElement("div");
|
||||
const c = createCurtain(layer);
|
||||
const all = [c.drop("troy"), c.fade(), c.drop("ocean"), c.drop("circe")];
|
||||
const last = c.lift();
|
||||
await Promise.all([...all, last]);
|
||||
expect(layer.querySelector<HTMLElement>(".fz-host")!.hidden).toBe(true);
|
||||
expect(layer.querySelector<HTMLElement>(".fz-cover")!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
test("fade covers the stage in the curtain colour; lift uncovers it", async () => {
|
||||
const layer = document.createElement("div");
|
||||
const c = createCurtain(layer);
|
||||
await c.fade();
|
||||
const cover = layer.querySelector<HTMLElement>(".fz-cover")!;
|
||||
expect(cover.hidden).toBe(false);
|
||||
await c.lift();
|
||||
expect(cover.hidden).toBe(true);
|
||||
});
|
||||
|
||||
test("createWorld returns null without WebGL", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.getContext = (() => null) as typeof canvas.getContext;
|
||||
expect(createWorld(canvas)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { sagas, songs } from "../src/data/album";
|
||||
import { PLACES, pointAt, sIndex, stops } from "../src/app/world/route";
|
||||
|
||||
describe("route geometry", () => {
|
||||
test("nine places in saga order", () => {
|
||||
expect(PLACES.length).toBe(9);
|
||||
expect(PLACES.map((p) => p.id)).toEqual(sagas.map((s) => s.id));
|
||||
});
|
||||
|
||||
test("one stop per song, in album order, s strictly increasing from 0 to 1", () => {
|
||||
const st = stops();
|
||||
expect(st.length).toBe(40);
|
||||
expect(st.map((s) => s.songId)).toEqual(songs.map((s) => s.id));
|
||||
expect(st[0]!.s).toBe(0);
|
||||
expect(st.at(-1)!.s).toBe(1);
|
||||
for (let i = 1; i < st.length; i++) expect(st[i]!.s).toBeGreaterThan(st[i - 1]!.s);
|
||||
});
|
||||
|
||||
test("each stop lies at its saga's place, within 1.5× its radius", () => {
|
||||
for (const st of stops()) {
|
||||
const song = songs.find((s) => s.id === st.songId)!;
|
||||
expect(st.placeId).toBe(song.sagaId);
|
||||
const place = PLACES.find((p) => p.id === st.placeId)!;
|
||||
expect(Math.hypot(st.x - place.x, st.z - place.z)).toBeLessThanOrEqual(1.5 * place.radius);
|
||||
}
|
||||
});
|
||||
|
||||
test("the ship never anchors inside an island: stops keep clear of the place's centre", () => {
|
||||
for (const st of stops()) {
|
||||
const place = PLACES.find((p) => p.id === st.placeId)!;
|
||||
expect(Math.hypot(st.x - place.x, st.z - place.z)).toBeGreaterThan(0.75 * place.radius);
|
||||
}
|
||||
});
|
||||
|
||||
test("stops are close within a saga and farther apart between sagas", () => {
|
||||
const st = stops();
|
||||
const d = (a: number, b: number) => Math.hypot(st[a]!.x - st[b]!.x, st[a]!.z - st[b]!.z);
|
||||
let maxWithin = 0, minBetween = Infinity;
|
||||
for (let i = 1; i < st.length; i++) {
|
||||
if (st[i]!.placeId === st[i - 1]!.placeId) maxWithin = Math.max(maxWithin, d(i, i - 1));
|
||||
else minBetween = Math.min(minBetween, d(i, i - 1));
|
||||
}
|
||||
expect(minBetween).toBeGreaterThan(3 * maxWithin);
|
||||
});
|
||||
|
||||
test("pointAt follows the route through every stop, with headings along the way", () => {
|
||||
for (const st of stops()) {
|
||||
const p = pointAt(st.s);
|
||||
expect(p.x).toBeCloseTo(st.x, 6);
|
||||
expect(p.z).toBeCloseTo(st.z, 6);
|
||||
expect(p.heading).toBeCloseTo(st.heading, 6);
|
||||
}
|
||||
const mid = pointAt(0.5);
|
||||
expect(Number.isFinite(mid.x) && Number.isFinite(mid.z) && Number.isFinite(mid.heading)).toBe(true);
|
||||
expect(pointAt(-1)).toEqual(pointAt(0));
|
||||
expect(pointAt(2)).toEqual(pointAt(1));
|
||||
});
|
||||
|
||||
test("sIndex maps route position to a fractional song index and back", () => {
|
||||
const st = stops();
|
||||
st.forEach((stop, i) => expect(sIndex(stop.s)).toBeCloseTo(i, 6));
|
||||
const half = (st[5]!.s + st[6]!.s) / 2;
|
||||
expect(sIndex(half)).toBeCloseTo(5.5, 6);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user