deadhead-space-freight-tycoon/apps/web/test/map-layout.test.ts

184 lines
6.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { MAX_ZOOM, MIN_ZOOM } from "../src/map/camera";
import {
backgroundRect,
decorate,
layoutScene,
polar,
spriteScale,
type SceneLayout,
} from "../src/map/layout";
import { buildScene } from "../src/map/scene";
import { mapStyle } from "../src/map/style";
import { samplePlayerState } from "./render";
const viewports = [
{ width: 390, height: 700 },
{ width: 1280, height: 720 },
{ width: 2560, height: 1440 },
{ width: 320, height: 2000 },
];
const pans = [-50_000, -800, -1, 0, 3, 640, 50_000];
const zooms = [MIN_ZOOM, 0.75, 1, 3, 12, MAX_ZOOM];
const images = [
{ width: 1254, height: 1254 },
{ width: 2048, height: 1024 },
];
describe("background placement (decision 0.5.40)", () => {
it("always fully contains the viewport", () => {
for (const view of viewports) {
for (const image of images) {
for (const panX of pans) {
for (const panY of pans) {
for (const zoom of zooms) {
const r = backgroundRect(view, { zoom, panX, panY }, image);
const where = JSON.stringify({ view, image, panX, panY, zoom, r });
expect(r.x, where).toBeLessThanOrEqual(0);
expect(r.y, where).toBeLessThanOrEqual(0);
expect(r.x + r.width, where).toBeGreaterThanOrEqual(view.width - 1e-9);
expect(r.y + r.height, where).toBeGreaterThanOrEqual(view.height - 1e-9);
}
}
}
}
}
});
it("moves a fraction of the pan (parallax) and keeps its aspect ratio", () => {
const view = { width: 1280, height: 720 };
const image = { width: 1254, height: 1254 };
const still = backgroundRect(view, { zoom: 1, panX: 0, panY: 0 }, image);
const panned = backgroundRect(view, { zoom: 1, panX: 100, panY: -60 }, image);
expect(panned.x - still.x).toBeCloseTo(15);
expect(panned.y - still.y).toBeCloseTo(-9);
expect(still.width / still.height).toBeCloseTo(1);
});
it("zooms only slightly with the map", () => {
const view = { width: 1280, height: 720 };
const image = { width: 1254, height: 1254 };
const one = backgroundRect(view, { zoom: 1, panX: 0, panY: 0 }, image);
const far = backgroundRect(view, { zoom: MAX_ZOOM, panX: 0, panY: 0 }, image);
expect(far.width / one.width).toBeGreaterThan(1);
expect(far.width / one.width).toBeLessThan(1.3);
});
});
const dates = [
Date.parse("2100-01-01T00:00:00Z"),
Date.parse("2100-03-17T08:00:00Z"),
Date.parse("2101-11-02T19:30:00Z"),
];
const view = { width: 390, height: 700 };
const cameraAt = (zoom: number) => ({ zoom, panX: 13, panY: -27 });
describe("bodies on screen (decisions 0.5.38, 0.5.39)", () => {
it("keeps the sprite scale within the configured limits at extreme zooms", () => {
const { min, max } = mapStyle.spriteScale;
for (const zoom of [1e-6, MIN_ZOOM, 0.75, 1, 2, MAX_ZOOM, 1e6]) {
expect(spriteScale(zoom)).toBeGreaterThanOrEqual(min);
expect(spriteScale(zoom)).toBeLessThanOrEqual(max);
}
expect(spriteScale(1e-6)).toBe(min);
expect(spriteScale(1e6)).toBe(max);
expect(spriteScale(1)).toBe(1);
});
it("puts the Moon exactly the configured offset from Earth, at any zoom and date", () => {
const { distancePx, angleDeg } = mapStyle.childOffsets.moon!;
for (const now of dates) {
const scene = buildScene(samplePlayerState(), now);
for (const zoom of [MIN_ZOOM, 1, 7, MAX_ZOOM]) {
const layout = layoutScene(scene, view, cameraAt(zoom));
const earth = layout.locations.find((l) => l.id === "earth")!;
const moon = layout.locations.find((l) => l.id === "moon")!;
// The angle is measured from the outward direction, Sun → Earth.
const outward =
(Math.atan2(earth.center.y - layout.sun.center.y, earth.center.x - layout.sun.center.x) *
180) /
Math.PI;
const offset = polar(distancePx, outward + angleDeg);
expect(moon.center.x - earth.center.x).toBeCloseTo(offset.x, 9);
expect(moon.center.y - earth.center.y).toBeCloseTo(offset.y, 9);
// And the two sprites never overlap.
expect(Math.hypot(offset.x, offset.y)).toBeGreaterThanOrEqual((earth.size + moon.size) / 2);
}
}
});
it("starts and ends Moon routes where the Moon is drawn", () => {
const now = dates[0]!;
const state = samplePlayerState({
activeDispatches: [
{
id: "d",
shipId: "loaner",
pilotId: "avatar",
contract: null,
originId: "earth",
destinationId: "moon",
tutorial: true,
departureAt: now,
arrivalAt: now + 60_000,
distanceMillionKm: 0.4,
fuelCost: 0,
fuelBurnedFu: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",
resolvedAt: null,
},
],
});
const layout = layoutScene(buildScene(state, now + 60_000), view, cameraAt(1));
const moon = layout.locations.find((l) => l.id === "moon")!;
expect(layout.flights[0]!.to.x).toBeCloseTo(moon.center.x, 6);
expect(layout.flights[0]!.to.y).toBeCloseTo(moon.center.y, 6);
});
it("never makes a tap target smaller than the minimum", () => {
const scene = buildScene(samplePlayerState(), dates[0]!);
for (const zoom of [1e-6, MIN_ZOOM, 1, MAX_ZOOM]) {
const layout = layoutScene(scene, view, cameraAt(zoom));
for (const item of [layout.sun, ...layout.locations, ...layout.flights]) {
expect(item.tap).toBeGreaterThanOrEqual(mapStyle.minTapPx);
expect(item.tap).toBeGreaterThanOrEqual(item.size);
}
}
});
});
describe("labels and badges (Step 10C.6)", () => {
const body = (id: string, x: number, y: number, size = 32) => ({
id,
center: { x, y },
size,
tap: 44,
});
const layout = (locations: ReturnType<typeof body>[]): SceneLayout => ({
scale: 1,
sun: { center: { x: -1000, y: -1000 }, size: 64, tap: 64 },
locations,
flights: [],
});
const size = () => ({ width: 60, height: 14 });
it("puts a label below its body when that is clear", () => {
const placed = decorate(layout([body("a", 100, 100)]), size);
expect(placed.get("a")!.label).toEqual({ x: 100, y: 120, anchorY: 0 });
});
it("moves a label above its body when below would cover another body", () => {
const placed = decorate(layout([body("a", 100, 100), body("b", 110, 140)]), size);
expect(placed.get("a")!.label).toEqual({ x: 100, y: 80, anchorY: 1 });
});
it("puts the idle badge on a diagonal clear of other bodies", () => {
// The default spot (up-left) is taken by another body.
const placed = decorate(layout([body("a", 100, 100), body("b", 70, 70)]), size);
const badge = placed.get("a")!.badge;
expect(Math.hypot(badge.x - 70, badge.y - 70)).toBeGreaterThan(24);
});
});