diff --git a/client/css/style.css b/client/css/style.css index a070e21..3845693 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -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 { diff --git a/client/index.html b/client/index.html index dac234c..314b39d 100644 --- a/client/index.html +++ b/client/index.html @@ -322,6 +322,14 @@ + + + diff --git a/client/js/app.js b/client/js/app.js index fc64b9e..c8289db 100644 --- a/client/js/app.js +++ b/client/js/app.js @@ -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. diff --git a/client/js/game_screen.js b/client/js/game_screen.js index d894fbc..c5479f4 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -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() { diff --git a/client/js/loading.js b/client/js/loading.js new file mode 100644 index 0000000..e5a414a --- /dev/null +++ b/client/js/loading.js @@ -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(); diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js index d54c34b..1e92c4b 100644 --- a/client/js/map_view/entities.js +++ b/client/js/map_view/entities.js @@ -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 diff --git a/client/js/map_view/modes.js b/client/js/map_view/modes.js index eb4761b..b3065c9 100644 --- a/client/js/map_view/modes.js +++ b/client/js/map_view/modes.js @@ -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(); diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js index 49fa01b..f9a18ae 100644 --- a/client/js/map_view/terrain.js +++ b/client/js/map_view/terrain.js @@ -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 diff --git a/client/js/map_view/webgl.js b/client/js/map_view/webgl.js index fe9addd..68d4682 100644 --- a/client/js/map_view/webgl.js +++ b/client/js/map_view/webgl.js @@ -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); diff --git a/tests/loading_test.js b/tests/loading_test.js new file mode 100644 index 0000000..2e6329f --- /dev/null +++ b/tests/loading_test.js @@ -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); + } + } +} diff --git a/tests/map_modes_test.js b/tests/map_modes_test.js index f3cd91a..b1e05c9 100644 --- a/tests/map_modes_test.js +++ b/tests/map_modes_test.js @@ -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; diff --git a/tests/webgl_geometry_test.js b/tests/webgl_geometry_test.js index e9b2c90..4fc108b 100644 --- a/tests/webgl_geometry_test.js +++ b/tests/webgl_geometry_test.js @@ -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() {