Sped up the flat map modes and added a boot-style loading log
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user