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.
327 lines
12 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
}
|