Sped up the flat map modes and added a boot-style loading log

This commit is contained in:
2026-09-19 22:56:12 +02:00
parent eca9e39760
commit b385679e52
12 changed files with 516 additions and 113 deletions
+51
View File
@@ -26,6 +26,57 @@ html, body {
.screen { position: absolute; inset: 0; display: none; }
.screen.active { display: block; }
/* Boot-style loading log, sitting over every screen while a world is built. */
.loading-overlay {
position: absolute;
inset: 0;
z-index: 200;
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 28px 34px;
background:
radial-gradient(circle at 30% 0%, rgba(40, 70, 110, 0.25), transparent 60%),
#05070c;
overflow: hidden;
}
.loading-overlay.hidden { display: none; }
.loading-overlay.done { opacity: 0.0; transition: opacity 0.6s ease; }
.loading-panel {
width: min(720px, 100%);
max-height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.loading-title {
font-size: 13px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.loading-log {
overflow: hidden auto;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(120, 200, 255, 0.25);
background: rgba(4, 8, 14, 0.9);
box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.6);
font-family: "DejaVu Sans Mono", "Consolas", "Courier New", monospace;
font-size: 12.5px;
line-height: 1.55;
color: #b9d4e8;
white-space: pre;
}
.loading-line { color: #9fc3dc; }
.loading-line.loading-total {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid rgba(120, 200, 255, 0.2);
color: #ffd77a;
font-weight: 600;
}
/* ---------------------------------------------------------------- menus -- */
#screen-menu, #screen-new, #screen-join, #screen-choose {
+8
View File
@@ -322,6 +322,14 @@
</section>
</div>
<!-- Boot-style log shown while a game's world is generated and uploaded. -->
<div id="loading-overlay" class="loading-overlay hidden">
<div class="loading-panel">
<div class="loading-title">Battle for 'Tismo — loading</div>
<div class="loading-log"></div>
</div>
</div>
<script src="vendor/jquery.min.js"></script>
<script type="module" src="js/app.js"></script>
</body>
+8 -1
View File
@@ -3,6 +3,7 @@
import { Network } from "./net.js";
import { GameScreen } from "./game_screen.js";
import { bootLog } from "./loading.js";
import { CIVILISATIONS } from "../../shared/data.js";
const network = new Network();
@@ -280,7 +281,13 @@ function startGameScreen(isAdmin) {
gameScreen = new GameScreen(network, config);
showScreen("screen-game");
gameScreen.enter();
if (isAdmin) gameScreen.sendSetup();
gameScreen.beginLoading();
if (isAdmin) {
gameScreen.sendSetup();
bootLog.step("Requested the world from the server");
} else {
bootLog.step("Waiting for the world from the server");
}
}
// Reconnect notice clears once the player navigates back to the menu.
+14
View File
@@ -11,6 +11,7 @@ import { MapView } from "./map_view.js";
import { CityModal, NationModal, ConfirmModal, NewsModal, MapModesModal } from "./modals.js";
import { GameFeed } from "./feed.js";
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
import { bootLog } from "./loading.js";
import { panelMethods } from "./game_screen/panels.js";
export class GameScreen {
@@ -36,6 +37,7 @@ export class GameScreen {
this._tileRegion = new Map();
this._gdpExpanded = false;
this._battles = new Set();
this._loading = false;
this._researched = new Set();
this._wars = new Set();
this._conflicts = [];
@@ -83,6 +85,13 @@ export class GameScreen {
requestAnimationFrame((t) => this._frame(t));
}
// Shows the boot-style log while the first snapshot's world is generated and
// uploaded. The real app calls this; tests never do, so they pay nothing.
beginLoading() {
this._loading = true;
bootLog.bind($("#loading-overlay")).begin();
}
leave() {
this._entered = false;
this.map.cancelPathfinding();
@@ -165,6 +174,11 @@ export class GameScreen {
this._updateCityModal();
this._updateNationModal();
this._updateNewsModal();
if (this._loading) {
this._loading = false;
bootLog.step("Updated the HUD");
bootLog.finish();
}
}
_bindMap() {
+91
View File
@@ -0,0 +1,91 @@
// A Linux-boot-style log of the client's map setup, shown while a game loads.
// The client regenerates the whole world from the seed and uploads it to the
// GPU, which can take several seconds; this shows each step and how long it
// took. It records nothing unless a log has been started, so importing modules
// (and the test suite) pay no cost.
const now = () =>
(typeof performance !== "undefined" ? performance.now() : Date.now());
export class LoadingLog {
constructor() {
this.$root = null;
this.$log = null;
this.active = false;
this.startedAt = 0;
this.lastAt = 0;
}
bind($root) {
this.$root = $root;
this.$log = $root && $root.length ? $root.find(".loading-log") : null;
return this;
}
// Clears the log and shows the overlay. Safe to call when the overlay is not
// in the page: everything then no-ops.
begin() {
if (!this.$root || !this.$root.length) return;
this.active = true;
this.startedAt = now();
this.lastAt = this.startedAt;
this.$log.empty();
this.$root.removeClass("hidden").removeClass("done");
this.step("Booting the game client");
}
// Records a step, timed from the previous step/measure.
step(label) {
if (!this.active) return;
const time = now();
this._line(time - this.lastAt, label);
this.lastAt = time;
}
// Runs `fn`, logs how long it took under `label` and returns its result.
measure(label, fn) {
if (!this.active) return fn();
const start = now();
try {
return fn();
} finally {
const time = now();
this._line(time - start, label);
this.lastAt = time;
}
}
finish() {
if (!this.active) return;
this.step("Ready");
this._line(now() - this.startedAt, "Total map setup", true);
this.active = false;
if (this.$root) {
const $root = this.$root;
$root.addClass("done");
// Fade out after a beat so the last lines can be read. Hiding is deferred
// through requestAnimationFrame, which the test harness stubs out, so a
// finished log never touches the DOM after the test window is closed.
const hide = () => $root.addClass("hidden");
if (typeof requestAnimationFrame === "function") {
setTimeout(() => requestAnimationFrame(hide), 2500);
} else {
hide();
}
}
}
_line(milliseconds, label, total = false) {
if (!this.$log || !this.$log.length) return;
const value = milliseconds < 1000
? `${milliseconds.toFixed(1)} ms`
: `${(milliseconds / 1000).toFixed(2)} s`;
const line = document.createElement("div");
line.className = total ? "loading-line loading-total" : "loading-line";
line.textContent = `[ ${value.padStart(7, " ")} ] ${label}`;
this.$log[0].appendChild(line);
this.$log[0].scrollTop = this.$log[0].scrollHeight;
}
}
export const bootLog = new LoadingLog();
+26 -6
View File
@@ -5,29 +5,45 @@ import { key, mapToLocal, parseKey } from "../../../shared/hex.js";
import { statusById } from "../../../shared/data.js";
import { BATTLE_SIDE_OFFSET, CAMERA_TILT } from "./constants.js";
import { hashEntries, pathProgress, nearestCorners, inwardOffset } from "./utils.js";
import { bootLog } from "../loading.js";
export const entityMethods = {
setCatalogue(civilisations, protoUnits) {
this.civilisations = civilisations;
this.protoUnits = protoUnits;
// The game screen rebuilds this catalogue object on every snapshot, so a
// signature keeps the political names and colours from being rebuilt each
// time even though the values did not change.
const signature = (civilisations || [])
.map((civ) => `${civ.id}:${civ.name}:${civ.primaryColour}`)
.join("|");
if (signature === this._catalogueSignature) return;
this._catalogueSignature = signature;
if (this.political) this._refreshMapColours();
},
applySnapshot(state) {
bootLog.step("Snapshot received from the server");
this.ensureTerrain(state.seed, state.mapConfig || null);
this.setRoads(state.roads || []);
bootLog.step("Mirrored the road network");
this.localCiv = state.viewer;
this._applyTerritory(state.territory);
bootLog.step("Mirrored the territory and borders");
this._applyRegions(state.regions || []);
this._applyKnowledge(state.explored, state.visible);
bootLog.step("Mirrored the fog of war");
this._applyBattles(state.battles);
this._syncCities(state.cities);
this._syncUnits(state.units);
bootLog.step("Placed the cities and units");
if (!this._centered) this._centerOnCapital(state.cities);
// A new world or snapshot may have arrived while a non-terrain mode was
// active, so recompute the effective fills and rebuild them.
this._syncModeState();
if (this.political || this.economic) this._refreshMapColours();
// A new world resets the mode flags, so the first snapshot after one
// repaints the political/economic surface; later snapshots only do so when
// the flags actually change.
if (this._syncModeState() && (this.political || this.economic)) {
this._repaintMode();
}
this.animate(0);
},
@@ -73,8 +89,12 @@ export const entityMethods = {
}
this._renderBorders();
if (this.glRenderer) this.glRenderer.buildBorders(this);
if (this.political) this._refreshMapColours();
if (this.political) {
// The country colours and names both follow ownership.
this._refreshMapColours();
} else if (this.glRenderer) {
this.glRenderer.buildBorders(this);
}
},
// Mirrors each land tile's region (the city that claims it). The region of a
+32 -14
View File
@@ -20,7 +20,7 @@ export const modeMethods = {
if (!MAP_MODES.includes(mode) || mode === this.mapMode) return;
this.mapMode = mode;
this._syncModeState();
this._refreshMapColours();
this._repaintMode();
},
// Installs the per-tile figures an economic mode paints. Called by the game
@@ -54,30 +54,48 @@ export const modeMethods = {
},
// Recomputes the two effective flags and reflects them on the world element.
// The renderers read `political` / `economic` off the view.
// The renderers read `political` / `economic` off the view. Returns whether
// either flag actually changed, so callers can skip a repaint otherwise.
_syncModeState() {
this.political = this._effectivePolitical();
this.economic = this._isEconomic();
this.$world.toggleClass("political", this.political);
this.$world.toggleClass("economic", this.economic);
const political = this._effectivePolitical();
const economic = this._isEconomic();
const changed = political !== this.political || economic !== this.economic;
// Touch the DOM classes only on a real change: this runs on every camera
// move, where the flags almost never move.
if (political !== this.political) this.$world.toggleClass("political", political);
if (economic !== this.economic) this.$world.toggleClass("economic", economic);
this.political = political;
this.economic = economic;
return changed;
},
// Called from the camera whenever the zoom changes: terrain mode may have
// crossed into or out of the far-out political map.
_updatePoliticalMode() {
const on = this._effectivePolitical();
if (on === this.political) return;
this._syncModeState();
this._refreshMapColours();
if (!this._syncModeState()) return;
this._repaintMode();
},
// Repaints the active fill and the labels/legend that go with it. The DOM
// renderer recolours its chunks; WebGL rebuilds the fill passes.
// A mode or zoom-threshold change: the whole surface flips between relief and
// flat, so the fills, borders, fog and selected-region overlay all repaint.
_repaintMode() {
this._refreshMapColours();
if (this.glRenderer && this.topology) {
this.glRenderer.buildKnowledge(this);
this.glRenderer.buildRegionOverlay(this);
}
},
// Repaints the active fills, the borders over them and the labels/legend that
// go with them. This is the per-territory-change path, so it deliberately
// leaves the fog and region overlay alone: their geometry only depends on the
// mode, not on who owns which tile.
_refreshMapColours() {
if (this.glRenderer) {
if (this.glRenderer && this.topology) {
this.glRenderer.buildPolitical(this);
this.glRenderer.buildEconomic(this);
} else if (this.topology) {
this.glRenderer.buildBorders(this);
} else if (!this.glRenderer && this.topology) {
this._rebuildDomTerrain();
}
if (this.political) this._renderPoliticalLabels();
+14 -4
View File
@@ -15,6 +15,7 @@ import { TerrainStats } from "../../../shared/terrain_stats.js";
import { MAP_CONFIG } from "../../../shared/data.js";
import { CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIMIT, CAMERA_TILT } from "./constants.js";
import { terrainLayers, textureRepeat } from "./textures.js";
import { bootLog } from "../loading.js";
export const terrainMethods = {
ensureTerrain(seed, mapConfig = null) {
@@ -34,9 +35,13 @@ export const terrainMethods = {
this._roadsSignature = null;
this.topology = new MapTopology(config.mapSize, config.topology === "cylindrical");
const generator = new MapGenerator(config, this.topology);
generator.generate(seed);
bootLog.measure("Generated the terrain, settlements and roads", () => {
generator.generate(seed);
});
this.tiles = generator.tiles;
this.terrainStats = new TerrainStats(this.tiles);
bootLog.measure("Indexed the terrain for pathfinding", () => {
this.terrainStats = new TerrainStats(this.tiles);
});
this._maxStepLength = this._computeMaxStepLength();
this._period = this.topology.periodPixels();
// World pixels per tiling-texture repeat, snapped to the wrap period.
@@ -63,6 +68,9 @@ export const terrainMethods = {
this._regionsSignature = null;
this._exploredCount = -1;
this._visibleSignature = null;
// Visibility is per-world; the snapshot reapplies it right after this.
this.explored = new Set();
this.visible = new Set();
this._chunkDirty = true;
this._chunkView = { x: null, y: null, zoom: null };
@@ -94,8 +102,10 @@ export const terrainMethods = {
this._chunkDirty = true;
// A WebGL build reads the whole world out of the view, so hand it the new
// terrain before any of the per-layer update hooks run.
if (this.glRenderer) this.glRenderer.setWorld(this);
this._syncChunks();
if (this.glRenderer) {
bootLog.measure("Built the map layers (WebGL)", () => this.glRenderer.setWorld(this));
}
bootLog.measure("Built the visible map chunks", () => this._syncChunks());
},
// Pixel bounds of the whole world, used to draw the wrapped copies of the
+161 -84
View File
@@ -61,6 +61,7 @@ import { createHeightField } from "./heightmap.js";
import { coastDistanceField } from "./coast.js";
import { generateWaterBump } from "./water.js";
import { economicColour } from "./economic.js";
import { bootLog } from "../loading.js";
// Full terrain vertex layout, in floats: x, y, u, v, wx, wy, height, the base
// colour (grass, sand, atlas), one weight per overlay, then the two slope
@@ -87,6 +88,9 @@ const FOG_FLOATS = 7;
const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 };
const ROAD = [138 / 255, 90 / 255, 43 / 255, 1];
// Unclaimed land on the political map, a flat muted olive. Painting it here
// lets the political/economic modes skip the textured terrain mesh entirely.
const UNCLAIMED_LAND = [0x5b / 255, 0x5f / 255, 0x49 / 255, 1];
// The terrain atlas is a single row of equal cells, one per terrain type.
const ATLAS_COLS = 7;
// Texture unit per sampler.
@@ -816,7 +820,10 @@ export class GLMapRenderer {
const gl = this.gl;
if (!pass.buffer) pass.buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, pass.buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(arr), usage || gl.STATIC_DRAW);
// A buffer that is already a typed array (the terrain mesh preallocates its
// exact size) is uploaded as is; the flat passes hand over plain arrays.
const data = arr instanceof Float32Array ? arr : new Float32Array(arr);
gl.bufferData(gl.ARRAY_BUFFER, data, usage || gl.STATIC_DRAW);
pass.count = arr.length / floats;
pass.floats = floats;
// Only terrain and fog are row-banded; everything else draws whole.
@@ -834,18 +841,30 @@ export class GLMapRenderer {
// the renderer needs.
setWorld(view) {
this.view = view;
this.heightField = createHeightField(view);
this._heightView = view;
this._warp = this._buildWarpSet(view);
this._buildCoastTexture(view);
bootLog.measure("Built the relief field", () => {
this.heightField = createHeightField(view);
this._heightView = view;
this._warp = this._buildWarpSet(view);
});
bootLog.measure("Built the coastal wave field", () => this._buildCoastTexture(view));
this.terrainLevels = [];
this.terrain = this._buildTerrainPass(view, 0);
this.terrainLevels[0] = this.terrain;
this.buildOcean(view);
this.buildRoads(view);
this.buildBorders(view);
this.buildKnowledge(view);
this.buildRegionOverlay(view);
bootLog.measure("Built the terrain mesh", () => {
this.terrain = this._buildTerrainPass(view, 0);
this.terrainLevels[0] = this.terrain;
});
bootLog.measure("Built the ocean", () => this.buildOcean(view));
bootLog.measure("Built the roads and borders", () => {
this.buildRoads(view);
this.buildBorders(view);
});
// A brand-new world has no knowledge yet, so the fog built here would cover
// the whole map only to be thrown away when the snapshot's visibility
// arrives a moment later. Skip it and let that update build it once.
bootLog.measure("Built the fog of war", () => {
if (view.explored && view.explored.size > 0) this.buildKnowledge(view);
else this._upload(this.fog, []);
});
bootLog.measure("Built the selected-region overlay", () => this.buildRegionOverlay(view));
}
// The per-tile distance-to-land field the water shader samples for its
@@ -901,6 +920,13 @@ export class GLMapRenderer {
this.edgeWaviness(view, x, y), this._warp);
}
// Whether the view currently ignores the terrain relief: the political and
// economic fills replace the terrain, so every ground layer drawn with them
// (fill, borders, fog, region) is pinned flat instead of riding the heightmap.
_ignoresRelief(view) {
return !!(view && (view.political || view.economic));
}
// The relief field for a view, rebuilt when a builder invoked directly (as the
// tests do) passes a world `setWorld` never saw.
_heightField(view) {
@@ -941,7 +967,6 @@ export class GLMapRenderer {
const segments = lod.segments;
const subdivisions = lod.subdivisions || 0;
const field = this._heightField(view);
const arr = [];
const tiles = view.tiles || {};
const topo = view.topology;
const seed = view.seed | 0;
@@ -951,14 +976,28 @@ export class GLMapRenderer {
// cell along the hexagon's border.
const uPad = 0.5 / HEX_W;
const vPad = 0.5 / HEX_H;
const originX = topo.originX;
const originY = topo.originY;
// Count the vertices first so the mesh is written straight into a typed
// array. At the closest level of detail this is millions of floats, and
// growing a plain array and copying it into the buffer afterwards was the
// single biggest cost of building a game.
const trianglesPerTile = CORNERS.length * segments * (4 ** subdivisions);
let vertexCount = 0;
for (let y = originY; y < originY + topo.height; y++) {
for (let x = originX; x < originX + topo.width; x++) {
const tile = tiles[key(x, y)];
if (tile && !isWaterTile(tile)) vertexCount += trianglesPerTile * 3;
}
}
const buffer = new Float32Array(vertexCount * TERRAIN_FLOATS);
let offset = 0;
// Built row by row so the renderer can draw only the tile rows on screen.
// `rowStart[i]` is the first vertex of tile row i; the last entry is the
// total, which makes a visible range one draw call.
const rowStart = [];
const originX = topo.originX;
const originY = topo.originY;
for (let y = originY; y < originY + topo.height; y++) {
rowStart.push(arr.length / TERRAIN_FLOATS);
rowStart.push(offset / TERRAIN_FLOATS);
for (let x = originX; x < originX + topo.width; x++) {
const tile = tiles[key(x, y)];
if (!tile) continue;
@@ -1008,15 +1047,33 @@ export class GLMapRenderer {
weights: this._textureWeights(tile, ring, centre.x, centre.y),
};
const push = (vertex) => {
const uv = this._cellUV(
column,
vertex.idealX - centre.x,
vertex.idealY - centre.y,
uPad,
vPad
);
this._pushTerrain(arr, vertex.x, vertex.y, uv.u, uv.v,
vertex.idealX, vertex.idealY, vertex.h, vertex.dx, vertex.dy, vertex.weights);
// The terrain vertex layout, written in place: x, y, u, v, wx, wy,
// height, the four base weights, the four overlay weights, then the
// two slope components. Keeping this here avoids a per-vertex call
// and a push into a growing array.
let uc = (vertex.idealX - centre.x + HEX_W / 2) / HEX_W;
let vc = (vertex.idealY - centre.y + HEX_H / 2) / HEX_H;
uc = Math.min(Math.max(uc, uPad), 1 - uPad);
vc = Math.min(Math.max(vc, vPad), 1 - vPad);
const w = vertex.weights;
buffer[offset] = vertex.x;
buffer[offset + 1] = vertex.y;
buffer[offset + 2] = (column + uc) / ATLAS_COLS;
buffer[offset + 3] = vc;
buffer[offset + 4] = vertex.idealX;
buffer[offset + 5] = vertex.idealY;
buffer[offset + 6] = vertex.h;
buffer[offset + 7] = w[0];
buffer[offset + 8] = w[1];
buffer[offset + 9] = w[2];
buffer[offset + 10] = w[5];
buffer[offset + 11] = w[3];
buffer[offset + 12] = w[4];
buffer[offset + 13] = w[6];
buffer[offset + 14] = 0;
buffer[offset + 15] = vertex.dx;
buffer[offset + 16] = vertex.dy;
offset += TERRAIN_FLOATS;
};
// The midpoint of an edge re-samples the field and the texture blend at
// its own ideal position, so a subdivided triangle follows the relief
@@ -1064,9 +1121,9 @@ export class GLMapRenderer {
}
}
}
rowStart.push(arr.length / TERRAIN_FLOATS);
rowStart.push(offset / TERRAIN_FLOATS);
const pass = { buffer: null, count: 0 };
this._upload(pass, arr, undefined, TERRAIN_FLOATS);
this._upload(pass, buffer, undefined, TERRAIN_FLOATS);
pass.rowStart = rowStart;
pass.originY = originY;
pass.rows = topo.height;
@@ -1128,16 +1185,6 @@ export class GLMapRenderer {
return ring;
}
// UV of a point at local offset (dx, dy) from the tile centre, inside the
// tile's atlas cell and inset by half a texel.
_cellUV(column, dx, dy, uPad, vPad) {
let uc = (dx + HEX_W / 2) / HEX_W;
let vc = (dy + HEX_H / 2) / HEX_H;
uc = Math.min(Math.max(uc, uPad), 1 - uPad);
vc = Math.min(Math.max(vc, vPad), 1 - vPad);
return { u: (column + uc) / ATLAS_COLS, v: vc };
}
// The ocean is a single flat quad over the whole world, drawn before the
// land. Because the sea tiles are not part of the terrain mesh, every part of
// the map the land does not cover reads as water, and the surface is exactly
@@ -1290,7 +1337,10 @@ export class GLMapRenderer {
}
const seed = view.seed | 0;
const period = periodUnits(view.topology);
const field = this._heightField(view);
// The political and economic maps sit on a flat sheet: their borders must
// not ride (or be dotted by) the terrain relief that is no longer shown.
const flat = !!this._ignoresRelief(view);
const field = flat ? null : this._heightField(view);
const glow = 6;
const edgesByOwner = new Map();
for (const [k, owner] of territory) {
@@ -1550,7 +1600,7 @@ export class GLMapRenderer {
const period = periodUnits(topo);
const explored = view.explored || new Set();
const visible = view.visible || new Set();
const field = this._heightField(view);
const field = this._ignoresRelief(view) ? null : this._heightField(view);
const segments = EDGE_SEGMENTS;
const rowStart = [];
const originX = topo.originX;
@@ -1635,7 +1685,7 @@ export class GLMapRenderer {
const segments = EDGE_SEGMENTS;
const boundaryLength = CORNERS.length * segments;
const territory = view.territory;
const field = this._heightField(view);
const field = this._ignoresRelief(view) ? null : this._heightField(view);
for (const [k, cityId] of regions) {
if (cityId !== region) continue;
const coords = parseKey(k);
@@ -1675,68 +1725,89 @@ export class GLMapRenderer {
}
// The far-out political map: every owned tile is filled with its country's
// colour. The hexagon rides the same warped boundary and relief as the
// terrain it covers, so the colour stops exactly on the coastline. The land
// the terrain already draws supplies the neutral ground for unclaimed tiles;
// here we only need to paint what a nation owns.
// colour. The hexagon follows the same warped boundary as the terrain it
// covers, so the colour stops exactly on the coastline, but it is drawn flat:
// this map is about territory, not relief. The land the terrain already draws
// supplies the neutral ground for unclaimed tiles; here we paint what a
// nation owns.
buildPolitical(view) {
const arr = [];
const territory = view && view.territory;
if (!view || !view.political || !territory || territory.size === 0 ||
!view.topology || !view.civilisations) {
if (!view || !view.political || !view.topology || !view.tiles) {
this._upload(this.politicalFill, arr);
return;
}
const seed = view.seed | 0;
const period = periodUnits(view.topology);
const field = this._heightField(view);
for (const [k, owner] of territory) {
const civ = view.civilisations[owner];
if (!civ) continue;
const rgb = parseColour(civ.primaryColour);
const colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
this._pushTileFill(arr, view, parseKey(k), colour, seed, period, field);
const topo = view.topology;
const period = periodUnits(topo);
const territory = view.territory;
const civilisations = view.civilisations;
const rowStart = [];
for (let y = topo.originY; y < topo.originY + topo.height; y++) {
rowStart.push(arr.length / COLOUR_FLOATS);
for (let x = topo.originX; x < topo.originX + topo.width; x++) {
const k = key(x, y);
const tile = view.tiles[k];
if (!tile || isWaterTile(tile)) continue;
const owner = territory ? territory.get(k) : undefined;
const civ = owner !== undefined && civilisations ? civilisations[owner] : null;
let colour = UNCLAIMED_LAND;
if (civ) {
const rgb = parseColour(civ.primaryColour);
colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
}
this._pushTileFill(arr, x, y, colour, seed, period);
}
}
rowStart.push(arr.length / COLOUR_FLOATS);
this._upload(this.politicalFill, arr, this.gl.DYNAMIC_DRAW);
this.politicalFill.rowStart = rowStart;
this.politicalFill.originY = topo.originY;
this.politicalFill.rows = topo.height;
}
// The economic modes: every tile in the value map is filled with its place on
// the shared colour scale. The geometry is identical to the political fill,
// so it rides the same warped boundary and relief.
// The economic modes: every tile with a figure is filled with its place on the
// shared colour scale. The geometry is identical to the political fill, flat
// on the ground and row-banded like the terrain.
buildEconomic(view) {
const arr = [];
if (!view || !view.economic || !view.economicValues ||
view.economicValues.size === 0 || !view.topology) {
view.economicValues.size === 0 || !view.topology || !view.tiles) {
this._upload(this.economicFill, arr);
return;
}
const seed = view.seed | 0;
const period = periodUnits(view.topology);
const field = this._heightField(view);
const topo = view.topology;
const period = periodUnits(topo);
const { min, max } = view.economicRange || { min: 0, max: 0 };
for (const [k, value] of view.economicValues) {
const rgb = parseColour(economicColour(value, min, max));
const colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
this._pushTileFill(arr, view, parseKey(k), colour, seed, period, field);
const values = view.economicValues;
const rowStart = [];
for (let y = topo.originY; y < topo.originY + topo.height; y++) {
rowStart.push(arr.length / COLOUR_FLOATS);
for (let x = topo.originX; x < topo.originX + topo.width; x++) {
const value = values.get(key(x, y));
if (value === undefined) continue;
const rgb = parseColour(economicColour(value, min, max));
const colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
this._pushTileFill(arr, x, y, colour, seed, period);
}
}
rowStart.push(arr.length / COLOUR_FLOATS);
this._upload(this.economicFill, arr, this.gl.DYNAMIC_DRAW);
this.economicFill.rowStart = rowStart;
this.economicFill.originY = topo.originY;
this.economicFill.rows = topo.height;
}
// One filled, relief-following hexagon for a tile, appended to a flat-colour
// buffer.
_pushTileFill(arr, view, coords, colour, seed, period, field) {
const centre = mapToLocal(coords.x, coords.y);
// One flat, filled hexagon for a tile, appended to a flat-colour buffer.
_pushTileFill(arr, x, y, colour, seed, period) {
const centre = mapToLocal(x, y);
const boundary = distortedBoundary(seed, centre, period, 1, null, this._warp);
const heightRing = field ? field.ring(coords.x, coords.y) : null;
const heightAt = (point) =>
(field && heightRing ? field.blend(heightRing, point.x, point.y) : 0);
const centreHeight = heightAt(centre);
for (let i = 0; i < boundary.length; i++) {
const a = boundary[i];
const b = boundary[(i + 1) % boundary.length];
this._push(arr, centre.x, centre.y, centreHeight, colour);
this._push(arr, a.x, a.y, heightAt(a), colour);
this._push(arr, b.x, b.y, heightAt(b), colour);
this._push(arr, centre.x, centre.y, 0, colour);
this._push(arr, a.x, a.y, 0, colour);
this._push(arr, b.x, b.y, 0, colour);
}
}
@@ -1873,11 +1944,14 @@ export class GLMapRenderer {
if (this.canvas.height !== bufferHeight) this.canvas.height = bufferHeight;
const camera = view.camera;
const political = !!view.political;
const economic = !!view.economic;
// Pick the terrain mesh for the camera distance. A coarser level is built
// the first time the camera reaches it, which increments `version`, so this
// runs before the frame signature is taken.
// runs before the frame signature is taken. The flat modes do not draw the
// textured mesh, so they never build a level for it either.
const level = terrainLod(camera.zoom);
const terrainPass = this.texReady && view.topology
const terrainPass = !political && !economic && this.texReady && view.topology
? this._ensureTerrainLevel(view, level)
: this.terrain;
// The water animates, so the frame is repainted while the clock advances.
@@ -1923,13 +1997,16 @@ export class GLMapRenderer {
const period = view._period || 0;
const shifts = this._neededShifts(view, width);
const rows = this._visibleRows(view, width, height);
// The flat ocean sits under everything; the land mesh is drawn over it.
// The flat ocean sits under everything. The textured land mesh is only drawn
// on the terrain map: the political and economic fills cover every land tile
// themselves (unclaimed land included), so drawing the mesh too would only
// double the geometry and pay for the expensive land shader twice.
this._drawPass(this.ocean, 0, shifts, period);
if (this.texReady && terrainPass) this._drawPass(terrainPass, 0, shifts, period, rows);
const political = !!view.political;
const economic = !!view.economic;
if (political) this._drawPass(this.politicalFill, 1, shifts, period);
else if (economic) this._drawPass(this.economicFill, 1, shifts, period);
if (!political && !economic && this.texReady && terrainPass) {
this._drawPass(terrainPass, 0, shifts, period, rows);
}
if (political) this._drawPass(this.politicalFill, 1, shifts, period, rows);
else if (economic) this._drawPass(this.economicFill, 1, shifts, period, rows);
// Roads would only clutter the flat colours; borders stay.
if (!political && !economic) this._drawPass(this.roads, 1, shifts, period);
this._drawPass(this.borders, 1, shifts, period);
+46
View File
@@ -0,0 +1,46 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { LoadingLog } from "../client/js/loading.js";
export class LoadingTest extends TestCase {
async test_steps_are_logged_with_their_timings() {
const env = await setupDom();
try {
const log = new LoadingLog().bind(env.$("#loading-overlay"));
log.begin();
this.assertFalse(env.$("#loading-overlay").hasClass("hidden"), "the overlay is shown");
this.assertSize(env.$("#loading-overlay .loading-line"), 1, "the boot line is first");
log.step("did a thing");
log.measure("did a bigger thing", () => 42);
log.finish();
const lines = env.$("#loading-overlay .loading-line");
this.assertGreaterOrEqual(lines.length, 4, "every step is a line");
const text = lines.map((_index, element) => env.$(element).text()).get().join("\n");
this.assertTrue(text.includes("did a thing"), "the step label is shown");
this.assertTrue(text.includes("did a bigger thing"), "the measured label is shown");
this.assertTrue(text.includes("Total map setup"), "the total is shown");
this.assertTrue(/ms|s/.test(text), "each line carries a duration");
} finally {
teardownDom(env);
}
}
async test_measure_returns_the_wrapped_value_and_logs_nothing_when_idle() {
const idle = new LoadingLog();
this.assertEqual(idle.measure("noop", () => 7), 7, "the value is returned when idle");
idle.step("ignored");
this.assertNull(idle.$root, "an unbound log stays inert");
const env = await setupDom();
try {
const log = new LoadingLog().bind(env.$("#loading-overlay"));
log.begin();
this.assertEqual(log.measure("value", () => 7), 7, "the value is returned while active");
this.assertGreater(env.$("#loading-overlay .loading-line").length, 1, "the measure logs a line");
} finally {
teardownDom(env);
}
}
}
+34
View File
@@ -114,6 +114,40 @@ export class MapModesTest extends TestCase {
}
}
async test_territory_changes_skip_the_fog_and_region_rebuilds() {
const state = smallState();
const env = await setupDom();
const map = this.buildMap(env);
const calls = [];
try {
map.applySnapshot(state.snapshot(0));
// Install the spy only after the world is built, so it replaces the GL
// renderer rather than being asked to build the whole world itself.
map.glRenderer = {
buildPolitical: () => calls.push("political"),
buildEconomic: () => calls.push("economic"),
buildBorders: () => calls.push("borders"),
buildKnowledge: () => calls.push("fog"),
buildRegionOverlay: () => calls.push("region"),
};
map.political = true;
calls.length = 0;
map._applyTerritory([[0, 0, 0], [1, 0, 1]]);
this.assertTrue(calls.includes("political"), "the political fill follows ownership");
this.assertFalse(calls.includes("fog"), "a territory change does not rebuild the fog");
this.assertFalse(calls.includes("region"), "nor the region overlay");
calls.length = 0;
map.mapMode = "terrain";
map.political = false;
map.setMapMode("political");
this.assertTrue(calls.includes("fog"), "a mode change does rebuild the fog");
this.assertTrue(calls.includes("region"), "and the region overlay");
} finally {
teardownDom(env);
}
}
buildMap(env) {
const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
map.$viewport.width = () => 800;
+31 -4
View File
@@ -272,6 +272,23 @@ export class WebglGeometryTest extends TestCase {
this.assertTrue(lifted, "border vertices ride the heightmap");
}
async test_political_and_economic_borders_are_flat() {
for (const mode of ["political", "economic"]) {
const renderer = makeRenderer();
const state = view(renderer, {
territory: [["0,0", 0], ["1,0", 1]],
terrainAt: (x) => (x === 0 ? "Mountain" : "Land"),
});
state[mode] = true;
renderer.buildBorders(state);
const arr = renderer.captured.borders;
this.assertGreater(arr.length, 0, `${mode} borders are built`);
for (let i = 0; i < arr.length; i += 8) {
this.assertEqual(arr[i + 2], 0, `${mode} borders ignore the relief`);
}
}
}
async test_the_political_fill_paints_owned_tiles_only() {
const renderer = makeRenderer();
const state = view(renderer, { territory: [["0,0", 0], ["1,0", 1]] });
@@ -282,11 +299,18 @@ export class WebglGeometryTest extends TestCase {
state.political = true;
renderer.buildPolitical(state);
const arr = renderer.captured.political;
// One flat hexagon per owned tile: six triangles, 8 floats a vertex.
this.assertEqual(arr.length, 2 * 6 * 3 * 8, "two owned tiles are painted");
for (const value of arr) {
this.assertTrue(Number.isFinite(value), "every political vertex is finite");
// The 3x3 map is all land, so every tile is painted: owned tiles in their
// country's colour, the rest in the neutral land colour. Six triangles and
// 8 floats a vertex.
this.assertEqual(arr.length, 9 * 6 * 3 * 8, "every land tile is painted");
const colours = new Set();
for (let i = 0; i < arr.length; i += 8) {
colours.add([arr[i + 4], arr[i + 5], arr[i + 6]].join(","));
this.assertTrue(Number.isFinite(arr[i + 2]), "every political vertex is finite");
this.assertEqual(arr[i + 2], 0, "the political fill is flat");
}
this.assertTrue(colours.has([1, 0, 0].join(",")), "an owned tile uses its country colour");
this.assertTrue(colours.size >= 2, "unclaimed land is a second colour");
}
async test_the_economic_fill_paints_every_valued_tile() {
@@ -305,6 +329,9 @@ export class WebglGeometryTest extends TestCase {
for (const value of arr) {
this.assertTrue(Number.isFinite(value), "every economic vertex is finite");
}
for (let i = 0; i < arr.length; i += 8) {
this.assertEqual(arr[i + 2], 0, "the economic fill is flat");
}
}
async test_sea_edges_are_straight_except_against_land() {