184 lines
6.8 KiB
TypeScript
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, anchorX: 0.5, 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, anchorX: 0.5, 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);
|
|
});
|
|
});
|