Drew the cylindrical map as a repeating world

This commit is contained in:
2026-09-23 17:44:00 +02:00
parent 4600b16399
commit 965f0bffa9
11 changed files with 309 additions and 59 deletions
+6 -6
View File
@@ -1,6 +1,12 @@
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
// The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [
{
"hash": "4600b16",
"date": "2026-09-23",
"subject": "Stockpiled a month of every material and cached the trade walks",
"body": "Food, steel, luxury and high-tech now share one flat month-long reserve. A region keeps bidding for the shortfall while its store is thin, so a war that empties a pantry refills it from the grid instead of starving; the harvest is served before the city load, unmet food bids the price up, the army's rations count as demand, and a converter that cannot cover its power bill idles instead of making a loss.\n\nThe delivery graph walks sea lanes any distance, so its walks are cached across days behind a road/railway/work/territory stamp and only dropped by a wartime unit move. Units are indexed per tile, market access and node populations are memoised for the day, and the server can settle a fresh world before serving (--warmup, 150 days by default).\n\nRoadmap records the remaining follow-ups -- the politics/migration hot path and the luxury/high-tech price plateau -- and that freight's heavy energy draw is intended."
},
{
"hash": "3418e80",
"date": "2026-09-23",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-22",
"subject": "Better tile building management",
"body": ""
},
{
"hash": "7c28c45",
"date": "2026-09-22",
"subject": "Fixed a typo for Northumbrian towns",
"body": ""
}
];
+7
View File
@@ -60,6 +60,9 @@ export class MapView {
this.resourceGraph = { nodes: [], links: [], edges: [] };
// The merchandise icons riding the delivery legs, repositioned every frame.
this._deliveryMovers = [];
// Sibling containers that mirror each icon layer at whole-period offsets so
// a cylindrical world is drawn repeated (see `_syncWrapCopies`).
this._wrapCopies = [];
// City-to-city migration over the last month, drawn instead of the delivery
// graph while the population map is active.
this.migrationGraph = { links: [] };
@@ -243,6 +246,10 @@ export class MapView {
this.capabilities = null;
if (this.rendererMode !== "dom") this._setupGLRenderer();
// The repeat copies are icon-layer siblings; set them up once the renderer
// (and therefore whether the ground repeats on its own) is known.
this._setupWrapCopies();
this._setupCameraInput();
}
+7
View File
@@ -107,6 +107,13 @@ export function politicalMode(zoom) {
// battle icon between them. The side offset is in pixels.
export const BATTLE_SIDE_OFFSET = 15;
// How many copies of the world are drawn at once on a cylindrical map. The
// camera sits in one copy and its immediate neighbours fill the rest of the
// viewport, so the world repeats under the player instead of every icon being
// folded onto the single copy nearest the camera. Matches the WebGL renderer's
// cap; on maps whose period is wider than the viewport this is just one.
export const WRAP_COPIES = 4;
// The detailed view is built from square blocks of tiles. Only the blocks that
// intersect the viewport (plus CHUNK_MARGIN blocks of slack on every side) are
// materialised, so panning costs the same no matter how big the map is. Tiles
+7 -6
View File
@@ -153,7 +153,7 @@ export const entityMethods = {
bar.appendChild(fill);
el.appendChild(bar);
}
el.style.left = `${local.x + this._wrapShift(local.x)}px`;
el.style.left = `${local.x}px`;
el.style.top = `${local.y * CAMERA_TILT}px`;
container.appendChild(el);
this._siteViews.set(`${x},${y}`, el);
@@ -173,7 +173,7 @@ export const entityMethods = {
el.dataset.owner = String(info.owner);
el.appendChild(img);
}
el.style.left = `${local.x + this._wrapShift(local.x)}px`;
el.style.left = `${local.x}px`;
el.style.top = `${local.y * CAMERA_TILT}px`;
container.appendChild(el);
this._warfareViews.set(`${kind}:${k}`, el);
@@ -345,17 +345,18 @@ export const entityMethods = {
view._localX = local.x;
view._localY = local.y;
// The sprite layers are not tilted, so a city stands on the foreshortened
// ground position rather than the raw world Y.
const wrappedX = local.x + this._wrapShift(local.x);
// ground position rather than the raw world Y. The repeat copies draw it
// in the neighbouring periods (see `_syncWrapCopies`).
const groundX = local.x;
const groundY = local.y * CAMERA_TILT;
view.style.left = `${wrappedX}px`;
view.style.left = `${groundX}px`;
view.style.top = `${groundY}px`;
view.dataset.cityId = String(id);
view.dataset.civ = String(data.civ);
const label = view._label;
label.textContent = data.name;
label.classList.toggle("capital", !!data.isCapital);
label.style.left = `${wrappedX}px`;
label.style.left = `${groundX}px`;
label.style.top = `${groundY}px`;
const marker = view.querySelector(".marker");
marker.style.background = this.civilisations[data.civ]
+19 -13
View File
@@ -41,7 +41,10 @@ export const inputMethods = {
this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`);
// A zoom step may have crossed into or out of the political map.
this._updatePoliticalMode();
if (applyWrap) this._syncChunks();
if (applyWrap) {
this._syncChunks();
this._syncWrapCopies();
}
if (this._selectedTile) this._positionHighlight();
this._positionPoliticalLabels();
this._glRender();
@@ -315,13 +318,13 @@ export const inputMethods = {
this._highlightHidden = hidden;
},
// Keeps the (already measured) outline on the wrapped copy nearest the camera.
// Puts the (already measured) outline on the selected tile's raw map
// position; the repeat copies draw it in the neighbouring periods.
_positionHighlight() {
if (!this._highlightEl || !this._selectedTile) return;
const local = mapToLocal(this._selectedTile.x, this._selectedTile.y);
const wrapped = this._wrappedPosition(local);
this._highlightEl.style.left = `${wrapped.x - HEX_W / 2}px`;
this._highlightEl.style.top = `${wrapped.y - HEX_H / 2}px`;
this._highlightEl.style.left = `${local.x - HEX_W / 2}px`;
this._highlightEl.style.top = `${local.y - HEX_H / 2}px`;
},
// ------------------------------------------------------- hit-testing --
@@ -336,8 +339,10 @@ export const inputMethods = {
? this._positionAlongPath(motion)
: mapToLocal(motion.coords.x, motion.coords.y);
const offset = motion.offset || { x: 0, y: 0 };
const wrapped = this._wrappedPosition(position);
if (Math.hypot(wrapped.x + offset.x - local.x, wrapped.y + offset.y - local.y) < HEX_W * 0.5) {
// Fold the unit back onto the canonical copy so a click on any repeat of
// it (the world is drawn repeated) still finds it.
const x = this._canonicalX(position.x);
if (Math.hypot(x + offset.x - local.x, position.y + offset.y - local.y) < HEX_W * 0.5) {
result.push(id);
}
}
@@ -352,18 +357,19 @@ export const inputMethods = {
for (const [id, view] of this._cityViews) {
if (view.style.display === "none") continue;
const local = this._clickLocal(coords);
const cx = parseFloat(view.style.left);
// City views are placed on the foreshortened ground.
const cy = parseFloat(view.style.top);
// Fold the city onto the canonical copy so a click on any repeat of it
// finds it; city views are placed on the foreshortened ground.
const cx = this._canonicalX(view._localX);
const cy = view._localY * CAMERA_TILT;
if (Math.hypot(cx - local.x, cy - local.y * CAMERA_TILT) < HEX_W * 0.5) return { id };
}
return null;
},
// World position of a clicked tile at the wrapped copy nearest the camera, so
// hit-testing works across the seam.
// Canonical world position of a clicked tile, so hit-testing works across the
// seam and on any repeat copy the world is drawn at.
_clickLocal(coords) {
return this._wrappedPosition(mapToLocal(coords.x, coords.y));
return mapToLocal(coords.x, coords.y);
},
tileAtScreen(clientX, clientY) {
+20 -11
View File
@@ -127,6 +127,14 @@ export const modeMethods = {
this._renderGraphLegend();
},
// The far end of a leg: the copy of `to` nearest `from`, so a route between
// two neighbours across the cylindrical seam is drawn one tile long and its
// endpoints land on nodes the repeat copies actually draw. The whole overlay
// is duplicated per period by `_syncWrapCopies`, so the leg repeats too.
_legEnd(from, to) {
return this._nearestCopy(to, from);
},
// Every potential route grey, the routes that actually carried goods white
// over them, a dot on every storage node and a merchandise icon per active
// leg, animated along the line by `_animateDeliveryIcons` so the goods moving
@@ -136,14 +144,14 @@ export const modeMethods = {
const movers = [];
for (const edge of graph.edges || []) {
const [fromX, fromY, toX, toY] = edge;
fragment.appendChild(
makeSegment(mapToLocal(fromX, fromY), mapToLocal(toX, toY), DELIVERY_IDLE_COLOUR, 2)
);
const a = mapToLocal(fromX, fromY);
const b = this._legEnd(a, mapToLocal(toX, toY));
fragment.appendChild(makeSegment(a, b, DELIVERY_IDLE_COLOUR, 2));
}
for (const link of graph.links) {
const [fromX, fromY, toX, toY, resource] = link;
const a = mapToLocal(fromX, fromY);
const b = mapToLocal(toX, toY);
const b = this._legEnd(a, mapToLocal(toX, toY));
fragment.appendChild(makeSegment(a, b, DELIVERY_COLOUR, 3));
const meta = RESOURCE_META[resource];
if (!meta || !meta.icon) continue;
@@ -167,8 +175,6 @@ export const modeMethods = {
const point = mapToLocal(node[0], node[1]);
const dot = document.createElement("div");
dot.className = "resource-node";
dot.style.left = `${point.x}px`;
dot.style.top = `${point.y}px`;
fragment.appendChild(dot);
}
this.$resources.empty()[0].appendChild(fragment);
@@ -177,8 +183,9 @@ export const modeMethods = {
},
// Slides every graph icon along its leg (merchandise on the trade routes,
// flags on the migration ones), the whole fleet sharing one wall-clock cycle
// so a snapshot rebuild leaves the animation uninterrupted.
// flags on the migration ones), riding the same drawn leg, the whole fleet
// sharing one wall-clock cycle so a snapshot rebuild leaves the animation
// uninterrupted.
_animateDeliveryIcons() {
const movers = this._deliveryMovers;
if (!movers || movers.length === 0) return;
@@ -186,8 +193,10 @@ export const modeMethods = {
const travel = (now % DELIVERY_PERIOD_MS) / DELIVERY_PERIOD_MS;
for (const mover of movers) {
const t = (travel + mover.phase) % 1;
mover.el.style.left = `${mover.a.x + (mover.b.x - mover.a.x) * t}px`;
mover.el.style.top = `${mover.a.y + (mover.b.y - mover.a.y) * t}px`;
const a = mover.a;
const b = mover.b;
mover.el.style.left = `${a.x + (b.x - a.x) * t}px`;
mover.el.style.top = `${a.y + (b.y - a.y) * t}px`;
// Fade at both ends, so the jump back to the origin is hidden and the
// fleet reads as a continuous flow.
mover.el.style.opacity = Math.min(1, t / 0.1, (1 - t) / 0.1).toFixed(3);
@@ -205,7 +214,7 @@ export const modeMethods = {
for (const link of links) {
const [fromX, fromY, toX, toY, people, civ] = link;
const a = mapToLocal(fromX, fromY);
const b = mapToLocal(toX, toY);
const b = this._legEnd(a, mapToLocal(toX, toY));
const width = MIGRATION_ARROW_MIN +
(MIGRATION_ARROW_MAX - MIGRATION_ARROW_MIN) * (max > 0 ? people / max : 0);
fragment.appendChild(makeArrow(a, b, MIGRATION_COLOUR, width));
+13 -21
View File
@@ -96,15 +96,16 @@ export const motionMethods = {
this._drawPaths();
this._drawStrikeTargets();
this._animateDeliveryIcons();
this._syncWrapCopies();
this._flushFog();
this._glRender();
},
// Repositions every unit and city at the wrapped copy nearest the camera.
// Only writes a coordinate when it actually changed: most of the army is idle
// on any given frame, and rewriting identical left/top values for every unit
// still forces style recalculation, which a high-refresh display pays for
// every frame.
// Places every unit and city at its raw map coordinate; the repeat copies
// (see `_syncWrapCopies`) show it in the neighbouring periods. Only writes a
// coordinate when it actually changed: most of the army is idle on any given
// frame, and rewriting identical left/top values for every unit still forces
// style recalculation, which a high-refresh display pays for every frame.
_applyEntityWrap() {
for (const [id, motion] of this._unitMotion) {
const view = this._unitViews.get(id);
@@ -112,32 +113,25 @@ export const motionMethods = {
const position = motion.path.length >= 2
? this._positionAlongPath(motion)
: mapToLocal(motion.coords.x, motion.coords.y);
// Choose the wrapped copy from the tile centre, then apply the stack
// offset, so members of one stack never split across the wrap seam.
const wrapped = this._wrappedPosition(position);
const offset = motion.offset || { x: 0, y: 0 };
// Sprites stand on the tilted ground, so only the world Y is
// foreshortened; the stack offset stays a plain pixel spread.
this._setPosition(
view,
`${wrapped.x + offset.x}px`,
`${wrapped.y * CAMERA_TILT + offset.y}px`
`${position.x + offset.x}px`,
`${position.y * CAMERA_TILT + offset.y}px`
);
}
for (const view of this._cityViews.values()) {
if (view._localX === undefined) continue;
const left = `${view._localX + this._wrapShift(view._localX)}px`;
const left = `${view._localX}px`;
const top = `${view._localY * CAMERA_TILT}px`;
this._setPosition(view, left, top);
if (view._label) this._setPosition(view._label, left, top);
}
for (const marker of this._battleViews.values()) {
if (marker._localX === undefined) continue;
this._setPosition(
marker,
`${marker._localX + this._wrapShift(marker._localX)}px`,
`${marker._localY * CAMERA_TILT}px`
);
this._setPosition(marker, `${marker._localX}px`, `${marker._localY * CAMERA_TILT}px`);
}
},
@@ -204,9 +198,7 @@ export const motionMethods = {
const delta = this.topology.pixelDelta(from, to);
const next = { x: previous.x + delta.x, y: previous.y + delta.y };
const start = i === index + 1 ? current : previous;
const a = this._wrappedPosition(start);
const b = this._wrappedPosition(next);
fragment.appendChild(makeSegment(a, b, "#ffffff", 4));
fragment.appendChild(makeSegment(start, next, "#ffffff", 4));
previous = next;
}
}
@@ -286,8 +278,8 @@ export const motionMethods = {
? this._unitRenderPosition(targetId)
: mapToLocal(target.x, target.y);
if (!toPoint) continue;
const a = this._wrappedPosition(fromPoint);
const b = this._wrappedPosition(toPoint);
const a = fromPoint;
const b = this._nearestCopy(toPoint, fromPoint);
const distance = Math.hypot(b.x - a.x, b.y - a.y);
// A quadratic Bezier lobs the shell: the control point is lifted above the
// midpoint, its height compensated for the layer's Y foreshortening so the
+1 -1
View File
@@ -298,7 +298,7 @@ export const politicalMethods = {
if (!this._labelEntries) return;
const tilt = CAMERA_TILT;
for (const { el, label } of this._labelEntries) {
const x = label.x + this._wrapShift(label.x);
const x = label.x;
const y = label.y * tilt;
const angle = Math.atan2(Math.sin(label.angle) * tilt, Math.cos(label.angle));
const degrees = (angle * 180) / Math.PI;
+133
View File
@@ -20,6 +20,7 @@ import {
CAMERA_TILT,
FOG_REBUILD_MS,
FOG_BAND_MARGIN,
WRAP_COPIES,
} from "./constants.js";
import { terrainLayers, textureRepeat } from "./textures.js";
import { bootLog } from "../loading.js";
@@ -650,6 +651,138 @@ export const terrainMethods = {
return { x: point.x + this._wrapShift(point.x), y: point.y };
},
// The copy of `point` nearest a reference point, used to draw a route or line
// between two places as their short wrapped link instead of across the seam.
_nearestCopy(point, reference) {
if (!this._period) return { x: point.x, y: point.y };
const k = Math.round((reference.x - point.x) / this._period);
return { x: point.x + k * this._period, y: point.y };
},
// The horizontal offsets (whole periods) at which the repeating world is
// drawn. The first is the copy the camera sits in, the rest its neighbours,
// capped at WRAP_COPIES. A flat map has a single offset, 0.
_visibleWrapOffsets() {
if (!this._period) return [0];
const width = this.$viewport.width();
const zoom = this.camera.zoom || 1;
if (!width) return [0];
const half = width / (2 * zoom);
const viewMin = this.camera.x - half;
const viewMax = this.camera.x + half;
const worldMin = this._worldMinX;
const worldMax = this._worldMinX + this._worldWidth;
const period = this._period;
const kMin = Math.ceil((viewMin - worldMax) / period);
const kMax = Math.floor((viewMax - worldMin) / period);
if (kMax < kMin) return [0];
const centre = Math.max(kMin, Math.min(kMax, Math.round(this.camera.x / period)));
const offsets = [centre * period];
for (let d = 1; offsets.length < WRAP_COPIES && (centre - d >= kMin || centre + d <= kMax); d++) {
if (centre - d >= kMin) offsets.push((centre - d) * period);
if (offsets.length < WRAP_COPIES && centre + d <= kMax) offsets.push((centre + d) * period);
}
return offsets;
},
// The map-local X of a world pixel, folded back into one period so a point
// just past the seam matches the tile it is drawn over. Used by hit-testing,
// which resolves clicks by tile rather than by which copy was clicked.
_canonicalX(x) {
if (!this._period) return x;
const base = COL_STEP * this.topology.originX;
const period = this._period;
return ((x - base) % period + period) % period + base;
},
// ---------------------------------------------------- repeating layers --
//
// A cylindrical world is drawn several times across the viewport: the source
// layers hold one copy at raw map coordinates and each sibling container
// mirrors them at a whole-period offset. Cloning the built nodes (rather than
// folding each one onto the copy nearest the camera) keeps every leg, border
// and marker attached to the copy it belongs to, and repeats tile
// improvements and units in every copy the WebGL ground already draws.
_setupWrapCopies() {
this._wrapCopies = [];
const layers = [
// With WebGL the ground repeats in the shader; the DOM fallback must
// repeat its chunked terrain the same way the icon layers are repeated.
...(this.glRenderer
? []
: [
{ source: this.$terrain, interactive: false },
{ source: this.$roads, interactive: false },
{ source: this.$borders, interactive: false },
{ source: this.$fog, interactive: false },
]),
{ source: this.$highlight, interactive: false },
{ source: this.$paths, interactive: false },
{ source: this.$targets, interactive: false },
{ source: this.$resources, interactive: false },
{ source: this.$entities, interactive: true },
{ source: this.$labels, interactive: true },
{ source: this.$politicalLabels, interactive: false },
];
for (const { source, interactive } of layers) {
if (!source || !source.length) continue;
const copies = [];
for (let i = 1; i < WRAP_COPIES; i++) {
const el = document.createElement("div");
el.className = source[0].className;
el.style.display = "none";
if (!interactive) el.style.pointerEvents = "none";
source[0].after(el);
copies.push(el);
}
this._wrapCopies.push({ source, copies, interactive });
}
},
// Positions the live layers and mirrors their content into each extra copy.
// Runs once per frame; when the viewport fits inside one period (the common
// case on a large map) there are no extra copies and the clones are empty.
_syncWrapCopies() {
if (!this._wrapCopies || this._wrapCopies.length === 0) return;
const offsets = this._visibleWrapOffsets();
const primary = offsets[0];
const extra = offsets.slice(1);
for (const entry of this._wrapCopies) {
const source = entry.source[0];
this._setWrapTransform(source, primary);
for (let i = 0; i < entry.copies.length; i++) {
const copy = entry.copies[i];
const offset = extra[i];
if (offset === undefined) {
if (copy.style.display !== "none") {
copy.style.display = "none";
copy.textContent = "";
}
continue;
}
copy.style.display = "";
this._setWrapTransform(copy, offset);
this._mirrorLayer(source, copy);
}
}
},
// The horizontal offset is applied with a transform so panning never costs a
// layout pass. Tilted layers keep their ground foreshortening in the same
// transform, since an inline transform would otherwise drop the class rule.
_setWrapTransform(el, offset) {
const tilt = el.classList.contains("tilted") ? ` scaleY(${CAMERA_TILT})` : "";
el.style.transform = `translateX(${offset}px)${tilt}`;
},
_mirrorLayer(source, copy) {
const fragment = document.createDocumentFragment();
for (const node of source.childNodes) fragment.appendChild(node.cloneNode(true));
copy.textContent = "";
copy.appendChild(fragment);
},
_computeMaxStepLength() {
let longest = 0;
for (const neighbour of this.topology.neighbours(0, 0)) {
+2 -1
View File
@@ -118,7 +118,8 @@ export function makeSegment(a, b, colour, width, offset = 0) {
// A shaft plus a triangular head, from `a` to `b`, with the tip landing on
// `b`. `width` sets the shaft's thickness; the head grows with it so a thick
// arrow still reads as an arrow.
// arrow still reads as an arrow. A leg shorter than its head is drawn as just
// the head, so the shaft never pokes out behind it.
export function makeArrow(a, b, colour, width) {
const dx = b.x - a.x;
const dy = b.y - a.y;
+94
View File
@@ -9,6 +9,7 @@ import {
economicFraction,
} from "../client/js/map_view/economic.js";
import { smallState, mapLayers } from "./framework/helpers.js";
import { mapToLocal } from "../shared/hex.js";
function cssRgb(hex) {
const value = hex.replace("#", "");
@@ -307,6 +308,99 @@ export class MapModesTest extends TestCase {
}
}
async test_the_graph_follows_the_repeating_world_across_the_seam() {
const state = smallState();
const env = await setupDom();
const map = this.buildMap(env);
try {
map.applySnapshot(state.snapshot(0));
map.setMapMode("gdp");
const { width, originX, originY } = map.topology;
this.assertTrue(map.topology.cylindrical, "the fixture map wraps");
const period = map.topology.periodPixels();
const row = originY + 1;
const west = originX;
// The east edge is a neighbour of the west edge over the seam.
const east = originX + width - 1;
map.setResourceGraph({
nodes: [[west, row, 1], [east, row, 0]],
links: [[west, row, east, row, "steel", 10]],
edges: [[west, row, east, row]],
});
// The seam leg is drawn one tile long at any camera, not across the map:
// the far end snaps to the copy of its neighbour across the seam.
map.camera.x = mapToLocal(Math.round((west + east) / 2), row).x;
map.animate(0);
const segments = env.$("#layer-resources .border-seg");
this.assertSize(segments, 2, "the idle route and the active leg are both drawn");
segments.each((_index, el) => {
this.assertLess(parseFloat(el.style.width), period / 4,
"the seam leg is redrawn one tile long, not a map period");
});
// Zoomed out the viewport spans more than one period, so the whole world
// (graph included) is drawn repeated in each copy instead of folded.
map.camera.zoom = 0.5;
map.camera.x = 0;
map.camera.y = 0;
map.animate(0);
const offsets = map._visibleWrapOffsets();
this.assertGreater(offsets.length, 1, "several world copies are on screen");
const entry = map._wrapCopies.find((e) => e.source.is("#layer-resources"));
this.assertNotNull(entry, "the resource layer repeats");
const liveCopy = entry.copies.find((el) => el.style.display !== "none");
this.assertNotNull(liveCopy, "an extra copy is drawn");
this.assertGreater(
liveCopy.querySelectorAll(".border-seg").length, 0, "the delivery legs repeat"
);
this.assertGreater(
liveCopy.querySelectorAll(".resource-node").length, 0, "the storage nodes repeat"
);
const matched = /translateX\((-?[\d.]+)px\)/.exec(liveCopy.style.transform);
this.assertNotNull(matched, `the copy is offset horizontally: ${liveCopy.style.transform}`);
this.assertEqual(
Math.abs(parseFloat(matched[1])) % period, 0,
"the copy sits a whole number of periods from the live one"
);
} finally {
teardownDom(env);
}
}
async test_tile_improvements_repeat_with_the_world() {
const state = smallState();
const env = await setupDom();
const map = this.buildMap(env);
try {
map.applySnapshot(state.snapshot(0));
map.setWarfare([], [[0, 0, "mine", 0, 100]], 1);
// On the single copy the marker sits at its raw map position.
const marker = env.$("#layer-entities .tile-improvement-marker").eq(0);
this.assertSize(marker, 1, "the improvement is drawn once");
this.assertApprox(parseFloat(marker[0].style.left), mapToLocal(0, 0).x, 1e-6,
"at its raw position, not folded to the camera");
// Zoom out until the viewport spans more than one period: the marker must
// be repeated in each copy, like the terrain the WebGL ground repeats.
map.camera.zoom = 0.5;
map.camera.x = 0;
map.camera.y = 0;
map.animate(0);
this.assertGreater(map._visibleWrapOffsets().length, 1, "several copies on screen");
const entry = map._wrapCopies.find((e) => e.source.is("#layer-entities"));
const liveCopy = entry.copies.find((el) => el.style.display !== "none");
this.assertNotNull(liveCopy, "an extra entity copy is drawn");
this.assertGreater(
liveCopy.querySelectorAll(".tile-improvement-marker").length, 0,
"the tile improvement repeats in the copy"
);
} finally {
teardownDom(env);
}
}
async test_the_merchandise_icons_animate_along_the_line() {
const state = smallState();
const env = await setupDom();