Marching into unexplored or enemy land reveals a tile every step, and each reveal rebuilt the whole-map fog mesh: about 1.8M vertices and 50 MB on a 160x160 map, regenerated at the rebuild throttle. Walking through already-explored home land reveals nothing, which is why the stutter only showed up on a border crossing, with a single unit. - buildKnowledge now takes a row range and builds only the rows on screen; the row table stays full length so the existing row-banded draw keeps working. - _flushFog rebuilds on a visibility change or a pan to a new row band, still coalesced, and the draw clamps to the band that was built. - A mode/zoom change schedules the band rebuild instead of building the whole mesh, and uniform interior tiles skip the per-vertex blur kernel.
231 lines
9.4 KiB
JavaScript
231 lines
9.4 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { setupDom, teardownDom } from "./framework/dom.js";
|
|
import { MapView } from "../client/js/map_view.js";
|
|
import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
|
|
import {
|
|
ECONOMIC_GRADIENT,
|
|
ECONOMIC_MODES,
|
|
economicColour,
|
|
economicFraction,
|
|
} from "../client/js/map_view/economic.js";
|
|
import { smallState, mapLayers } from "./framework/helpers.js";
|
|
|
|
function cssRgb(hex) {
|
|
const value = hex.replace("#", "");
|
|
const r = parseInt(value.slice(0, 2), 16);
|
|
const g = parseInt(value.slice(2, 4), 16);
|
|
const b = parseInt(value.slice(4, 6), 16);
|
|
return `rgb(${r}, ${g}, ${b})`;
|
|
}
|
|
|
|
function luminance(hex) {
|
|
const value = hex.replace("#", "");
|
|
const r = parseInt(value.slice(0, 2), 16);
|
|
const g = parseInt(value.slice(2, 4), 16);
|
|
const b = parseInt(value.slice(4, 6), 16);
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
}
|
|
|
|
export class MapModesTest extends TestCase {
|
|
async test_economic_fraction_clamps_and_centres_a_flat_range() {
|
|
this.assertApprox(economicFraction(0, 0, 10), 0, 1e-9, "the minimum is zero");
|
|
this.assertApprox(economicFraction(10, 0, 10), 1, 1e-9, "the maximum is one");
|
|
this.assertApprox(economicFraction(5, 0, 10), 0.5, 1e-9, "the middle is halfway");
|
|
this.assertApprox(economicFraction(-4, 0, 10), 0, 1e-9, "below the range clamps");
|
|
this.assertApprox(economicFraction(40, 0, 10), 1, 1e-9, "above the range clamps");
|
|
this.assertApprox(economicFraction(7, 7, 7), 0.5, 1e-9, "a flat range sits mid-scale");
|
|
}
|
|
|
|
async test_economic_colour_runs_from_yellow_to_blue() {
|
|
const low = economicColour(0, 0, 100);
|
|
const high = economicColour(100, 0, 100);
|
|
this.assertEqual(low, ECONOMIC_GRADIENT[0], "the poorest tile is the first stop");
|
|
this.assertEqual(high, ECONOMIC_GRADIENT[ECONOMIC_GRADIENT.length - 1],
|
|
"the richest tile is the last stop");
|
|
this.assertGreater(luminance(low), luminance(high),
|
|
"the scale darkens as the value rises, so magnitude reads without colour");
|
|
const mid = economicColour(50, 0, 100);
|
|
this.assertTrue(/^#[0-9a-f]{6}$/.test(mid), `a valid colour: ${mid}`);
|
|
}
|
|
|
|
async test_the_population_scale_is_logarithmic() {
|
|
const scale = ECONOMIC_MODES.population.scale;
|
|
this.assertTrue(typeof scale === "function", "population reads through a log scale");
|
|
const linear = economicFraction(100000, 1000, 1000000);
|
|
const logged = economicFraction(100000, 1000, 1000000, scale);
|
|
this.assertGreater(logged, linear, "the log scale lifts smaller towns off the floor");
|
|
// A second MapView mode's figures reach the same scale through the legend.
|
|
const state = smallState();
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
map.applySnapshot(state.snapshot(0));
|
|
map.setMapMode("population");
|
|
map.setEconomicData("population", new Map(), 1000, 1000000);
|
|
this.assertEqual(map.economicScale, scale, "the active scale reaches the renderer");
|
|
const mid = Math.round(Math.expm1((Math.log1p(1000) + Math.log1p(1000000)) / 2));
|
|
this.assertEqual(
|
|
env.$("#map-legend-mid").text(),
|
|
ECONOMIC_MODES.population.format(mid),
|
|
"the legend's midpoint is the geometric middle"
|
|
);
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_every_economic_mode_has_a_label_and_formatter() {
|
|
for (const id of ["gdp", "population"]) {
|
|
const meta = ECONOMIC_MODES[id];
|
|
this.assertNotNull(meta, `${id} is catalogued`);
|
|
this.assertTrue(typeof meta.label === "string" && meta.label.length > 0, "it has a label");
|
|
this.assertTrue(typeof meta.format === "function", "it has a formatter");
|
|
this.assertTrue(meta.format(1234).length > 0, "the formatter returns text");
|
|
}
|
|
}
|
|
|
|
async test_the_economic_legend_names_its_range() {
|
|
const state = smallState();
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
map.applySnapshot(state.snapshot(0));
|
|
map.setMapMode("gdp");
|
|
const values = new Map();
|
|
for (const k in map.tiles) values.set(k, 1234);
|
|
map.setEconomicData("gdp", values, 100, 1234);
|
|
this.assertTrue(map.economic, "the GDP mode is active");
|
|
this.assertFalse(map.political, "the political fill is off");
|
|
this.assertFalse(env.$("#map-legend").hasClass("hidden"), "the legend is shown");
|
|
this.assertEqual(env.$("#map-legend-title").text(), "GDP per capita", "the legend is titled");
|
|
this.assertEqual(env.$("#map-legend-min").text(), "€100", "the legend shows the minimum");
|
|
this.assertEqual(env.$("#map-legend-max").text(), "€1 234", "the legend shows the maximum");
|
|
// Every land tile carries the top-of-range colour, so the fill is live.
|
|
const top = economicColour(1234, 100, 1234);
|
|
let painted = false;
|
|
env.$("#layer-terrain .hex.terrain").each((_index, element) => {
|
|
const value = element.style.backgroundColor;
|
|
if (value === top || value === cssRgb(top)) painted = true;
|
|
});
|
|
this.assertTrue(painted, "tiles are painted on the colour scale");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_switching_modes_repaints_and_resets_the_legend() {
|
|
const state = smallState();
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
map.applySnapshot(state.snapshot(0));
|
|
// Terrain at this zoom is the default plain map.
|
|
this.assertFalse(map.political, "terrain mode at close zoom is not political");
|
|
this.assertFalse(map.economic, "terrain mode is not economic");
|
|
|
|
map.setMapMode("political");
|
|
this.assertTrue(map.political, "political mode forces the political fill");
|
|
this.assertTrue(map.$world.hasClass("political"), "the world is flagged political");
|
|
this.assertTrue(env.$("#map-legend").hasClass("hidden"), "no economic legend");
|
|
|
|
map.setMapMode("population");
|
|
this.assertTrue(map.economic, "population mode is economic");
|
|
this.assertFalse(map.political, "political is off in an economic mode");
|
|
|
|
map.setMapMode("terrain");
|
|
this.assertFalse(map.economic, "terrain clears the economic fill");
|
|
this.assertTrue(env.$("#map-legend").hasClass("hidden"), "the legend is hidden again");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_fog_rebuilds_are_coalesced_between_frames() {
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
let builds = 0;
|
|
map.glRenderer = { buildKnowledge: () => { builds += 1; } };
|
|
map._fogBuiltAt = 0;
|
|
map._fogDirty = false;
|
|
map._refreshFog();
|
|
map._refreshFog();
|
|
map._refreshFog();
|
|
map._flushFog();
|
|
this.assertEqual(builds, 1, "many visibility changes rebuild the fog once");
|
|
|
|
map._refreshFog();
|
|
map._flushFog();
|
|
this.assertEqual(builds, 1, "a later change waits for the interval");
|
|
|
|
map._fogBuiltAt = 0;
|
|
map._flushFog();
|
|
this.assertEqual(builds, 2, "the pending change is flushed once due");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_country_names_sit_below_every_icon_layer() {
|
|
const env = await setupDom();
|
|
try {
|
|
const order = env.$("#map-world .layer").map((_index, element) => element.id).get();
|
|
const names = order.indexOf("layer-political-labels");
|
|
this.assertGreaterOrEqual(names, 0, "the country-name layer exists");
|
|
for (const id of [
|
|
"layer-highlight", "layer-paths", "layer-targets", "layer-entities", "layer-labels",
|
|
]) {
|
|
this.assertLess(names, order.indexOf(id), `${id} paints above the country names`);
|
|
}
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
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");
|
|
// A mode change schedules the fog for the frame loop's band rebuild
|
|
// rather than building the whole mesh synchronously.
|
|
this.assertTrue(map._fogDirty, "a mode change schedules a fog rebuild");
|
|
map._flushFog();
|
|
this.assertTrue(calls.includes("fog"), "and the frame loop rebuilds 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;
|
|
map.$viewport.height = () => 600;
|
|
map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
|
|
return map;
|
|
}
|
|
}
|