Files
Battle-for-Tismo/tests/map_view_geometry_test.js
T
adrien 1c6f0ae1f1 Cut the per-broadcast and per-frame cost on a full-sized map
The server sent the whole army on every snapshot and the client re-cloned
the whole icon layer whenever a unit moved, so a settled world with a
standing army drained the broadcast loop and the frame loop alike.

- Ship only the units and city list that changed, keyed by a per-peer
  fingerprint and a serialised hash, and merge those deltas on the client.
- Memoise the battlefield scan on a battle version bumped when a unit
  joins, leaves or changes side.
- Skip the DOM writes for units whose snapshot object is unchanged, and
  cache the unit-view inner nodes.
- Fold units, cities and tile markers onto the nearest world copy instead
  of mirroring the icon layers, which re-cloned thousands of views every
  frame; the small decoration layers still mirror, and only on a change.

Frame work at full zoom-out with units moving fell from ~190ms to ~6.5ms.
2026-09-24 00:33:51 +02:00

327 lines
12 KiB
JavaScript

import { teardownDom } from "./framework/dom.js";
import { MapViewFixture } from "./framework/map_view_fixture.js";
import { mapToLocal, COL_STEP, HEX_W, parseKey } from "../shared/hex.js";
import { inwardOffset } from "../client/js/map_view.js";
import { CAMERA_TILT, HEIGHT_LIFT } from "../client/js/map_view/constants.js";
import { smallState } from "./framework/helpers.js";
export class MapViewGeometryTest extends MapViewFixture {
async test_centers_on_the_capital() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const local = mapToLocal(capital.coords.x, capital.coords.y);
this.assertApprox(map.camera.x, local.x, 1e-6);
this.assertApprox(map.camera.y, local.y, 1e-6);
this.assertTrue(env.$("#map-world").css("transform").includes("scale(1)"));
} finally {
teardownDom(env);
}
}
async test_tile_in_view_follows_the_camera() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const coords = { x: capital.coords.x, y: capital.coords.y };
map.centerOn(coords);
this.assertTrue(map.tileInView(coords), "the centred tile is in view");
this.assertTrue(map.tileInView(coords, 1), "still in view with a margin");
// Pan away along the (unwrapped) north-south axis: the tile drops out.
map.camera.y += 100000;
this.assertFalse(map.tileInView(coords), "a far tile is out of view");
} finally {
teardownDom(env);
}
}
async test_tile_at_screen_round_trips() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const local = mapToLocal(capital.coords.x, capital.coords.y);
const screenX = 400 + (local.x - map.camera.x) * map.camera.zoom;
const screenY = 300 + (local.y - map.camera.y) * map.camera.zoom * CAMERA_TILT;
this.assertEqual(map.tileAtScreen(screenX, screenY), capital.coords);
} finally {
teardownDom(env);
}
}
async test_tile_at_screen_accounts_for_the_height_lift() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const local = mapToLocal(capital.coords.x, capital.coords.y);
const height = 0.5;
map.glRenderer = { pointHeight: () => height };
const screenX = 400 + (local.x - map.camera.x) * map.camera.zoom;
// Where the raised tile centre is actually drawn.
const screenY = 300 +
((local.y - map.camera.y) * CAMERA_TILT - height * HEIGHT_LIFT) * map.camera.zoom;
this.assertEqual(map.tileAtScreen(screenX, screenY), capital.coords,
"the raised tile is selected where it is drawn");
} finally {
teardownDom(env);
}
}
async test_surface_inversion_picks_the_frontmost_crossing() {
const state = smallState();
const { env, map } = await this.build(state);
try {
// A plateau 10..30px south of the flat estimate. The pixel shows both the
// flat ground at y=90 and the raised plateau at y = 90 + 0.6*HEIGHT_LIFT/
// tilt; the plateau is drawn last, so it must win.
map.glRenderer = {
pointHeight: (x, y) => (y >= 100 && y <= 130 ? 0.6 : 0),
};
const expected = 90 + 0.6 * (HEIGHT_LIFT / CAMERA_TILT);
this.assertApprox(map._surfaceY(0, 90), expected, 0.5, "the frontmost surface wins");
} finally {
teardownDom(env);
}
}
async test_outline_runs_split_solid_and_dotted() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const coords = [
{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 },
];
const hidden = [false, true, true, false];
this.assertEqual(map._outlineRuns(coords, hidden, false), "M0.00 10.00L0.00 0.00",
"the visible segment is its own run");
this.assertEqual(map._outlineRuns(coords, hidden, true),
"M0.00 0.00L10.00 0.00L10.00 10.00L0.00 10.00", "the hidden segments chain together");
} finally {
teardownDom(env);
}
}
async test_wrapped_tile_lookup_stays_in_range() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const coords = map.tileAtScreen(100000, 300);
const minX = map.topology.originX;
const maxX = map.topology.originX + map.topology.width - 1;
this.assert(coords.x >= minX && coords.x <= maxX, `x ${coords.x} wrapped`);
} finally {
teardownDom(env);
}
}
async test_terrain_wraps_across_the_seam() {
const state = smallState();
const { env, map } = await this.build(state);
try {
this.assertGreater(map._period, 0, "the map has a wrap period");
const rec = map._chunks.values().next().value;
this.assertNotNull(rec, "a chunk is materialised");
const chunkKey = rec.cx + "," + rec.cy;
const beforeShift = rec.shift;
// Scrolling exactly one period must land on the same picture: every chunk
// advances to its next wrapped copy, cancelling the camera movement.
map.panBy(map._period, 0);
const after = map._chunks.get(chunkKey);
this.assertNotNull(after, "the same chunk stays materialised one period over");
this.assertApprox(after.shift, beforeShift + map._period, 1e-6);
this.assertTrue(
after.terrainEl.style.transform.includes(`translateX(${after.x + after.shift}px)`),
`expected translateX(${after.x + after.shift}px), got ${after.terrainEl.style.transform}`
);
} finally {
teardownDom(env);
}
}
async test_chunks_meet_seamlessly_at_the_wrap() {
const state = smallState();
const { env, map } = await this.build(state);
try {
// Sit on the seam (just past the east edge) so both the east-edge chunk
// and the wrapped copy of the west-edge chunk are on screen together.
const t = map.topology;
map.camera.x = (t.originX + t.width) * COL_STEP;
map.camera.y = 0;
map._applyCamera();
const rows = new Map();
for (const rec of map._chunks.values()) {
if (!rows.has(rec.cy)) rows.set(rec.cy, []);
rows.get(rec.cy).push(rec);
}
let row = null;
for (const list of rows.values()) {
if (!row || list.length > row.length) row = list;
}
this.assertGreater(row.length, 1, "several chunks share the seam row");
row.sort((a, b) => (a.x + a.shift) - (b.x + b.shift));
for (let i = 1; i < row.length; i++) {
const previousRight = row[i - 1].x + row[i - 1].shift + row[i - 1].px;
const currentLeft = row[i].x + row[i].shift;
this.assertApprox(currentLeft - previousRight, COL_STEP, 1e-6);
}
} finally {
teardownDom(env);
}
}
async test_dragging_wraps_with_transforms_not_layout() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const rec = map._chunks.values().next().value;
const beforeLeft = rec.terrainEl.style.left;
// The drag path updates the camera but defers the chunk sync to the render
// loop; either way the chunk must be moved with a transform, not layout.
map.camera.x += map._period;
map._applyCamera(false);
map.animate(0);
this.assertEqual(rec.terrainEl.style.left, beforeLeft, "left is never rewritten");
this.assertTrue(rec.terrainEl.style.transform.includes("translateX"));
} finally {
teardownDom(env);
}
}
async test_wrap_shift_picks_the_copy_nearest_the_camera() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const period = map._period;
map.camera.x = period;
this.assertEqual(map._wrapShift(0), period);
this.assertEqual(map._wrapShift(period), 0);
// A point already beside the camera is never shifted.
this.assertEqual(map._wrapShift(period - 10), 0);
} finally {
teardownDom(env);
}
}
async test_far_zoom_keeps_the_detailed_terrain() {
const state = smallState();
const { env, map } = await this.build(state);
try {
map.camera.zoom = 0.01;
map._applyCamera();
this.assertFalse(env.$("#map-world").hasClass("lod"));
this.assertTrue(env.$("#layer-terrain").hasClass("detail-layer"));
this.assertSize(env.$("#layer-overview"), 0, "there is no simplified overview");
} finally {
teardownDom(env);
}
}
async test_icon_layers_fold_and_decoration_layers_mirror() {
const state = smallState();
const { env, map } = await this.build(state);
try {
// Far enough out that more than one map period fits the viewport.
map.camera.zoom = 0.25;
// The icon layers are folded onto the nearest copy instead of being
// cloned: re-cloning thousands of moving views every frame was the worst
// cost in the frame.
for (const id of ["layer-entities", "layer-labels"]) {
const mirrored = map._wrapCopies.find((e) => e.source[0].id === id);
this.assertFalse(!!mirrored, `${id} is folded, not mirrored`);
}
const [id, motion] = map._unitMotion.entries().next().value;
const view = map._unitViews.get(id);
this.assertNotNull(view, "a unit view exists");
const raw = mapToLocal(motion.coords.x, motion.coords.y);
// Park the camera exactly one period east of the unit.
map.camera.x = raw.x + map._period;
map._applyEntityWrap();
const offset = motion.offset || { x: 0, y: 0 };
this.assertApprox(parseFloat(view.style.left), raw.x + map._period + offset.x, 1e-6,
"the unit is folded a whole period nearer the camera");
// The decoration layers are still mirrored, and only re-clone when their
// source actually changes.
map.$paths[0].appendChild(env.window.document.createElement("div"));
map._syncWrapCopies();
const entry = map._wrapCopies.find((e) => e.source.is("#layer-paths"));
this.assertNotNull(entry, "the paths layer is mirrored");
const copy = entry.copies.find((c) => c.style.display !== "none");
this.assertNotNull(copy, "a paths copy is shown once several periods fit");
this.assertSize(copy.children, 1, "the copy mirrors the paths layer");
let calls = 0;
const original = map._mirrorLayer.bind(map);
map._mirrorLayer = (source, target) => {
calls += 1;
return original(source, target);
};
map._syncWrapCopies();
this.assertEqual(calls, 0, "an unchanged frame does not re-clone");
map.$paths[0].firstChild.style.left = "123px";
map._syncWrapCopies();
this.assertGreater(calls, 0, "a changed layer is re-cloned");
} finally {
teardownDom(env);
}
}
async test_border_offset_points_towards_the_owner() {
const a = { x: 0, y: 0 };
const b = { x: 0, y: 10 };
this.assertEqual(inwardOffset(a, b, { x: -5, y: 5 }, 3), 3);
this.assertEqual(inwardOffset(a, b, { x: 5, y: 5 }, 3), -3);
}
async test_neighbouring_borders_are_inset_not_overlaid() {
const state = smallState();
const { env, map } = await this.build(state);
try {
// Bring the two-tile territory into view so its chunk is materialised.
map.camera.x = 0;
map.camera.y = 0;
map._applyCamera();
map.civilisations = [{ primaryColour: "#111111" }, { primaryColour: "#eeeeee" }];
map.territory = new Map([["0,0", 0], ["1,0", 1]]);
map._renderBorders();
// Each segment is nudged towards its owner's tile, so the two sides of a
// shared edge sit next to each other instead of painting over each other.
const segments = env.$("#layer-borders .border-seg").toArray();
this.assertGreater(segments.length, 0);
for (const segment of segments) {
this.assert(
segment.style.transform.includes("translateY"),
`expected an inset border, got ${segment.style.transform}`
);
}
} finally {
teardownDom(env);
}
}
async test_pan_moves_the_camera() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const beforeX = map.camera.x;
const beforeY = map.camera.y;
map.panBy(10, -5);
this.assertApprox(map.camera.x, beforeX + 10, 1e-9);
this.assertApprox(map.camera.y, beforeY - 5, 1e-9);
} finally {
teardownDom(env);
}
}
}