Drew the cylindrical map as a repeating world
This commit is contained in:
@@ -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": ""
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user