From 965f0bffa90d5860ec8b815083ed5dab6f1022bf Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Wed, 23 Sep 2026 17:44:00 +0200 Subject: [PATCH] Drew the cylindrical map as a repeating world --- client/js/devlog_data.js | 12 +-- client/js/map_view.js | 7 ++ client/js/map_view/constants.js | 7 ++ client/js/map_view/entities.js | 13 ++-- client/js/map_view/input.js | 32 ++++---- client/js/map_view/modes.js | 31 +++++--- client/js/map_view/motion.js | 34 ++++---- client/js/map_view/political.js | 2 +- client/js/map_view/terrain.js | 133 ++++++++++++++++++++++++++++++++ client/js/map_view/utils.js | 3 +- tests/map_modes_test.js | 94 ++++++++++++++++++++++ 11 files changed, 309 insertions(+), 59 deletions(-) diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index 1cbeec1..7b96f78 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -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": "" } ]; diff --git a/client/js/map_view.js b/client/js/map_view.js index 99ba2db..f9e63c9 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -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(); } diff --git a/client/js/map_view/constants.js b/client/js/map_view/constants.js index 9ffae6f..facb35e 100644 --- a/client/js/map_view/constants.js +++ b/client/js/map_view/constants.js @@ -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 diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js index 5d993d7..91c8fc3 100644 --- a/client/js/map_view/entities.js +++ b/client/js/map_view/entities.js @@ -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] diff --git a/client/js/map_view/input.js b/client/js/map_view/input.js index b464bb1..6060df4 100644 --- a/client/js/map_view/input.js +++ b/client/js/map_view/input.js @@ -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) { diff --git a/client/js/map_view/modes.js b/client/js/map_view/modes.js index 0b75403..8ee8dec 100644 --- a/client/js/map_view/modes.js +++ b/client/js/map_view/modes.js @@ -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)); diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js index f24e8b3..e38afd8 100644 --- a/client/js/map_view/motion.js +++ b/client/js/map_view/motion.js @@ -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 diff --git a/client/js/map_view/political.js b/client/js/map_view/political.js index 662fc61..3807eb6 100644 --- a/client/js/map_view/political.js +++ b/client/js/map_view/political.js @@ -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; diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js index ef79d3c..38e48ca 100644 --- a/client/js/map_view/terrain.js +++ b/client/js/map_view/terrain.js @@ -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)) { diff --git a/client/js/map_view/utils.js b/client/js/map_view/utils.js index fd9c068..35bfb6c 100644 --- a/client/js/map_view/utils.js +++ b/client/js/map_view/utils.js @@ -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; diff --git a/tests/map_modes_test.js b/tests/map_modes_test.js index 8ab1c2b..3aa578f 100644 --- a/tests/map_modes_test.js +++ b/tests/map_modes_test.js @@ -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();