Added political and economic map modes
This commit is contained in:
@@ -418,6 +418,27 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
so they stay legible instead of being covered by whatever is nearby. The
|
||||
layer needs an explicit z-index because those icons give their own children
|
||||
z-index 2-4, which would otherwise paint over this layer. */
|
||||
/* Country names for the far-out political map, drawn as stretched SVG text on
|
||||
their own layer below the city labels. The SVG has no size of its own; the
|
||||
overflow is visible so the rotated text can sit anywhere on the map. */
|
||||
.country-label-layer { pointer-events: none; z-index: 4; }
|
||||
.political-label-svg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.political-label-svg text {
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Roads are hidden on the political map: flat country colours read better
|
||||
without them, while the national borders stay to separate the nations. */
|
||||
#map-world.political #layer-roads { display: none; }
|
||||
|
||||
.city-label-layer { pointer-events: none; z-index: 5; }
|
||||
.city-label {
|
||||
position: absolute;
|
||||
@@ -510,6 +531,42 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
text-shadow: 0 1px 2px #000;
|
||||
}
|
||||
|
||||
/* Colour scale for the economic map modes, in its own space below the FPS
|
||||
readout. The gradient itself is painted from the shared ramp in JS. */
|
||||
.map-legend {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 38px;
|
||||
width: 190px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
}
|
||||
.map-legend-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.map-legend-bar {
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.map-legend-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The inspected tile / city panel is anchored to the bottom right. */
|
||||
#tile-panel {
|
||||
right: 14px;
|
||||
@@ -579,11 +636,48 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.dock-buttons { position: relative; display: flex; gap: 8px; pointer-events: auto; }
|
||||
.game-corner .news-button {
|
||||
pointer-events: auto;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* The map-mode picker, anchored directly above the buttons. */
|
||||
.map-modes-popover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
min-width: 170px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
border-radius: 10px;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
|
||||
pointer-events: auto;
|
||||
z-index: 45;
|
||||
}
|
||||
.map-modes-title {
|
||||
padding: 2px 8px 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.map-mode-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.map-mode-option:hover { background: rgba(110, 175, 255, 0.2); }
|
||||
.map-mode-option input { accent-color: #6eafff; cursor: pointer; }
|
||||
.feed-line {
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
|
||||
+32
-1
@@ -108,6 +108,7 @@
|
||||
<div class="layer" id="layer-paths"></div>
|
||||
<div class="layer" id="layer-targets"></div>
|
||||
<div class="layer" id="layer-entities"></div>
|
||||
<div class="layer country-label-layer" id="layer-political-labels"></div>
|
||||
<div class="layer city-label-layer" id="layer-labels"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,6 +134,17 @@
|
||||
</div>
|
||||
<div id="fps-label">60 FPS</div>
|
||||
|
||||
<!-- Colour scale for the economic map modes, hidden otherwise. -->
|
||||
<div id="map-legend" class="map-legend hidden">
|
||||
<div id="map-legend-title" class="map-legend-title"></div>
|
||||
<div id="map-legend-bar" class="map-legend-bar"></div>
|
||||
<div class="map-legend-scale">
|
||||
<span id="map-legend-min"></span>
|
||||
<span id="map-legend-mid"></span>
|
||||
<span id="map-legend-max"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tile-panel" class="panel hidden">
|
||||
<div id="tile-info" class="info-section hidden">
|
||||
<div id="tile-desc" class="info-title"></div>
|
||||
@@ -158,7 +170,26 @@
|
||||
<div id="left-dock">
|
||||
<div class="game-corner">
|
||||
<div id="game-feed" class="game-feed"></div>
|
||||
<button id="news-button" class="aero news-button" title="News">News</button>
|
||||
<div class="dock-buttons">
|
||||
<button id="news-button" class="aero news-button" title="News">News</button>
|
||||
<button id="map-modes-button" class="aero news-button" title="Map modes">Map modes</button>
|
||||
<!-- Opens directly above its button. -->
|
||||
<div id="map-modes-popover" class="map-modes-popover hidden">
|
||||
<div class="map-modes-title">Map mode</div>
|
||||
<label class="map-mode-option">
|
||||
<input type="radio" name="map-mode" value="terrain" checked><span>Terrain</span>
|
||||
</label>
|
||||
<label class="map-mode-option">
|
||||
<input type="radio" name="map-mode" value="political"><span>Political</span>
|
||||
</label>
|
||||
<label class="map-mode-option">
|
||||
<input type="radio" name="map-mode" value="gdp"><span>GDP per capita</span>
|
||||
</label>
|
||||
<label class="map-mode-option">
|
||||
<input type="radio" name="map-mode" value="population"><span>Population</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="unit-panel" class="panel hidden">
|
||||
<div id="unit-info" class="info-section hidden">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// snapshot plumbing and the order flow.
|
||||
|
||||
import { MapView } from "./map_view.js";
|
||||
import { CityModal, NationModal, ConfirmModal, NewsModal } from "./modals.js";
|
||||
import { CityModal, NationModal, ConfirmModal, NewsModal, MapModesModal } from "./modals.js";
|
||||
import { GameFeed } from "./feed.js";
|
||||
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
|
||||
import { panelMethods } from "./game_screen/panels.js";
|
||||
@@ -65,6 +65,7 @@ export class GameScreen {
|
||||
this.nationModal = new NationModal();
|
||||
this.confirmModal = new ConfirmModal();
|
||||
this.newsModal = new NewsModal();
|
||||
this.mapModesModal = new MapModesModal();
|
||||
this.feed = new GameFeed($("#game-feed"));
|
||||
this.$stackMenu = $("#stack-menu");
|
||||
this.$statTooltip = $("#stat-tooltip");
|
||||
@@ -89,6 +90,7 @@ export class GameScreen {
|
||||
this.feed.clear();
|
||||
this._seenNews = null;
|
||||
this._hideStackMenu();
|
||||
this.mapModesModal.close();
|
||||
$(document).off("mousedown.stackmenu");
|
||||
}
|
||||
|
||||
@@ -153,6 +155,8 @@ export class GameScreen {
|
||||
|
||||
this.map.setCatalogue(this.civilisations, this.protoUnits);
|
||||
this.map.applySnapshot(state);
|
||||
// Refresh an active economic map mode against the new figures.
|
||||
this._refreshEconomicMode();
|
||||
this.selectedUnitIds = this.selectedUnitIds.filter((id) => this._unit(id));
|
||||
this.map.setSelectedUnits(this.selectedUnitIds);
|
||||
this._updateTileHighlight();
|
||||
@@ -344,6 +348,7 @@ export class GameScreen {
|
||||
this.nationModal.close();
|
||||
this.confirmModal.close();
|
||||
this.newsModal.close();
|
||||
this.mapModesModal.close();
|
||||
if (!modalOpen) this.map.clearSelection();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -23,13 +23,42 @@ import {
|
||||
import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors } from "../../../shared/rules.js";
|
||||
import { dateString } from "../../../shared/game_clock.js";
|
||||
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
|
||||
import { parseKey } from "../../../shared/hex.js";
|
||||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||||
import { isEconomicMode } from "../map_view/economic.js";
|
||||
import { isWaterTile } from "../map_view/textures.js";
|
||||
|
||||
// A tiny memo cache keyed by bucket and key, used while a per-tile economic
|
||||
// figure is computed for every tile at once.
|
||||
function memo(caches, bucket, key, compute) {
|
||||
if (!caches) return compute();
|
||||
let cache = caches[bucket];
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
caches[bucket] = cache;
|
||||
}
|
||||
if (!cache.has(key)) cache.set(key, compute());
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
export const panelMethods = {
|
||||
_bindHud() {
|
||||
$("#city-manage").on("click", () => this._openCityModal());
|
||||
$("#civ-flag").on("click", () => this._openNationModal());
|
||||
$("#news-button").on("click", () => this._openNewsModal());
|
||||
$("#map-modes-button").on("click", (event) => {
|
||||
event.stopPropagation();
|
||||
this.mapModesModal.toggle(this.map.mapMode);
|
||||
});
|
||||
this.mapModesModal.onMode = (mode) => this._setMapMode(mode);
|
||||
// A click anywhere outside the picker dismisses it.
|
||||
$(document).on("mousedown.mapmodes", (event) => {
|
||||
if (this.mapModesModal.isShowing() &&
|
||||
!$(event.target).closest("#map-modes-popover").length &&
|
||||
!$(event.target).closest("#map-modes-button").length) {
|
||||
this.mapModesModal.close();
|
||||
}
|
||||
});
|
||||
|
||||
this.cityModal.onTrain = (cityId, protoIndex) =>
|
||||
this.sendOrder({ type: "train", city: cityId, proto: protoIndex });
|
||||
@@ -696,15 +725,17 @@ export const panelMethods = {
|
||||
// it, mirroring the server's getTileGdpPerCapita through the shared helper.
|
||||
// The region that develops the tile supplies the building bonus; government
|
||||
// and research supply the nation-wide one.
|
||||
_tileGdpBreakdown(coords, owner, city, tile) {
|
||||
_tileGdpBreakdown(coords, owner, city, tile, caches = null) {
|
||||
const k = `${coords.x},${coords.y}`;
|
||||
const regionCity =
|
||||
city || (this.snapshot.cities || []).find((c) => c.id === this._tileRegion.get(k));
|
||||
const regionalModifier = regionCity ? this._cityGdpModifier(regionCity) : 0;
|
||||
const nationalModifier = this._nationalGdpModifier(owner);
|
||||
const regionalModifier = regionCity
|
||||
? memo(caches, "regional", regionCity.id, () => this._cityGdpModifier(regionCity))
|
||||
: 0;
|
||||
const nationalModifier = memo(caches, "national", owner, () => this._nationalGdpModifier(owner));
|
||||
const factors = gdpPerCapitaFactors(tile, {
|
||||
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
|
||||
nextToCity: !city && this._isNextToCity(coords, owner),
|
||||
nextToCity: !city && memo(caches, "nextToCity", k, () => this._isNextToCity(coords, owner)),
|
||||
pillagePenalty: this._tileGdpPenalties.has(k) ? this._tileGdpPenalties.get(k) : 1,
|
||||
battleDeficit: this._tileBattleGdpDeficits.get(k) || 0,
|
||||
regionalMultiplier: 1 + regionalModifier,
|
||||
@@ -718,6 +749,59 @@ export const panelMethods = {
|
||||
};
|
||||
},
|
||||
|
||||
// -------------------------------------------------------- map modes -----
|
||||
|
||||
// Applied when the Map modes picker reports a choice. An economic mode needs
|
||||
// its per-tile figures before the map switches to them.
|
||||
_setMapMode(mode) {
|
||||
if (mode === this.map.mapMode) return;
|
||||
if (isEconomicMode(mode)) {
|
||||
const { values, min, max } = this._computeEconomicValues(mode);
|
||||
this.map.setEconomicData(mode, values, min, max);
|
||||
}
|
||||
this.map.setMapMode(mode);
|
||||
},
|
||||
|
||||
// Recomputes the active economic mode's figures after a snapshot, so the map
|
||||
// and its legend always show current numbers.
|
||||
_refreshEconomicMode() {
|
||||
const mode = this.map.mapMode;
|
||||
if (!isEconomicMode(mode)) return;
|
||||
const { values, min, max } = this._computeEconomicValues(mode);
|
||||
this.map.setEconomicData(mode, values, min, max);
|
||||
},
|
||||
|
||||
// One figure per land tile for an economic mode, with the range the legend
|
||||
// and the colour scale need. GDP per capita mirrors the tile panel's formula;
|
||||
// population is the snapshot's raw per-tile count.
|
||||
_computeEconomicValues(mode) {
|
||||
const values = new Map();
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
if (!this.map || !this.map.tiles) return { values, min: 0, max: 0 };
|
||||
const cities = this.snapshot.cities || [];
|
||||
const cityByKey = new Map(cities.map((c) => [`${c.coords[0]},${c.coords[1]}`, c]));
|
||||
const caches = mode === "gdp" ? {} : null;
|
||||
for (const k in this.map.tiles) {
|
||||
const tile = this.map.tiles[k];
|
||||
if (isWaterTile(tile)) continue;
|
||||
let value;
|
||||
if (mode === "population") {
|
||||
value = this._population.get(k) || 0;
|
||||
} else {
|
||||
const coords = parseKey(k);
|
||||
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
|
||||
const city = cityByKey.get(k) || null;
|
||||
value = this._tileGdpBreakdown(coords, owner, city, tile, caches).total;
|
||||
}
|
||||
values.set(k, value);
|
||||
if (value < min) min = value;
|
||||
if (value > max) max = value;
|
||||
}
|
||||
if (!Number.isFinite(min)) return { values, min: 0, max: 0 };
|
||||
return { values, min, max };
|
||||
},
|
||||
|
||||
// The GDP bonus a single city's own buildings give.
|
||||
_cityGdpModifier(city) {
|
||||
let total = 0;
|
||||
|
||||
+29
-1
@@ -12,6 +12,8 @@ import { terrainMethods } from "./map_view/terrain.js";
|
||||
import { entityMethods } from "./map_view/entities.js";
|
||||
import { motionMethods } from "./map_view/motion.js";
|
||||
import { inputMethods } from "./map_view/input.js";
|
||||
import { politicalMethods } from "./map_view/political.js";
|
||||
import { modeMethods } from "./map_view/modes.js";
|
||||
import { createGLMapRenderer } from "./map_view/webgl.js";
|
||||
import { CAMERA_TILT } from "./map_view/constants.js";
|
||||
|
||||
@@ -54,6 +56,15 @@ export class MapView {
|
||||
this.$labels = $('<div id="layer-labels" class="layer city-label-layer"></div>');
|
||||
this.$world.append(this.$labels);
|
||||
}
|
||||
// Country names for the political map live below the city labels, so a city
|
||||
// name is never hidden behind a (much larger) country name.
|
||||
this.$politicalLabels = layers.politicalLabels && layers.politicalLabels.length
|
||||
? layers.politicalLabels
|
||||
: $("#layer-political-labels");
|
||||
if (!this.$politicalLabels.length) {
|
||||
this.$politicalLabels = $('<div id="layer-political-labels" class="layer country-label-layer"></div>');
|
||||
this.$world.append(this.$politicalLabels);
|
||||
}
|
||||
|
||||
// The orthographic camera tilts the ground plane, so the terrain and every
|
||||
// ground overlay are scaled on Y while the sprite layers stay upright.
|
||||
@@ -85,6 +96,21 @@ export class MapView {
|
||||
this.protoUnits = [];
|
||||
// Battlefield tiles, keyed "x,y" and mapped to { defender, attacker }.
|
||||
this._battles = new Map();
|
||||
// The active map mode, plus the effective fills derived from it. `political`
|
||||
// is also true while terrain mode is zoomed far out; `economic` is true for
|
||||
// the GDP/population modes and comes with a per-tile value map and range.
|
||||
this.mapMode = options.mapMode || "terrain";
|
||||
this.political = false;
|
||||
this.economic = false;
|
||||
this.economicKind = null;
|
||||
this.economicValues = new Map();
|
||||
this.economicRange = { min: 0, max: 0 };
|
||||
this._economicSignatureDone = null;
|
||||
// The colour scale shown while an economic mode is active.
|
||||
this.$legend = $("#map-legend");
|
||||
this._labelSvg = null;
|
||||
this._labelEntries = [];
|
||||
this._countryLabels = [];
|
||||
|
||||
this.camera = { x: 0, y: 0, zoom: 1.0 };
|
||||
this._centered = false;
|
||||
@@ -197,5 +223,7 @@ Object.assign(
|
||||
terrainMethods,
|
||||
entityMethods,
|
||||
motionMethods,
|
||||
inputMethods
|
||||
inputMethods,
|
||||
politicalMethods,
|
||||
modeMethods
|
||||
);
|
||||
|
||||
@@ -78,6 +78,16 @@ export function terrainLod(zoom) {
|
||||
return TERRAIN_LODS.length - 1;
|
||||
}
|
||||
|
||||
// Past this zoom the map stops being a terrain map and becomes a political one:
|
||||
// each country's territory is painted in its colour and its name is drawn once
|
||||
// per contiguous holding. It matches the threshold between the two coarsest
|
||||
// terrain levels of detail, where tile art is already sub-pixel.
|
||||
export const POLITICAL_ZOOM = 0.5;
|
||||
|
||||
export function politicalMode(zoom) {
|
||||
return zoom <= POLITICAL_ZOOM;
|
||||
}
|
||||
|
||||
// A battle spreads the two stacks to either side of the tile centre, with the
|
||||
// battle icon between them. Side offset is in pixels; step is the spacing
|
||||
// between the (half-size) units of one stack.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// The economic map modes: a colour scale that turns a per-tile figure (GDP per
|
||||
// capita or raw population) into a hex colour, plus the catalogue describing
|
||||
// each mode.
|
||||
//
|
||||
// The gradient runs from yellow (poorest / smallest) to blue (richest /
|
||||
// largest). Those two hues are the pair most colour-blind players can still
|
||||
// tell apart, and the stops are the reversed cividis palette, which keeps a
|
||||
// monotonic lightness across the whole ramp so the magnitude also reads without
|
||||
// colour vision.
|
||||
|
||||
import { parseColour } from "./utils.js";
|
||||
import { groupDigits } from "../../../shared/text_format.js";
|
||||
|
||||
export const ECONOMIC_GRADIENT = [
|
||||
"#ffea46",
|
||||
"#e4cf5b",
|
||||
"#c4b56c",
|
||||
"#a69d75",
|
||||
"#8a8779",
|
||||
"#707173",
|
||||
"#575d6c",
|
||||
"#39486b",
|
||||
"#00306f",
|
||||
"#00204d",
|
||||
];
|
||||
|
||||
export const ECONOMIC_MODES = {
|
||||
gdp: {
|
||||
id: "gdp",
|
||||
label: "GDP per capita",
|
||||
format: (value) => `€${groupDigits(Math.round(value))}`,
|
||||
},
|
||||
population: {
|
||||
id: "population",
|
||||
label: "Population",
|
||||
format: (value) => groupDigits(Math.round(value)),
|
||||
},
|
||||
};
|
||||
|
||||
export function isEconomicMode(mode) {
|
||||
return mode === "gdp" || mode === "population";
|
||||
}
|
||||
|
||||
// Where a value sits on the scale, clamped to 0..1. A flat range (every tile
|
||||
// the same) sits in the middle so the map reads as a single mid colour.
|
||||
export function economicFraction(value, min, max) {
|
||||
if (!(max > min)) return 0.5;
|
||||
const t = (value - min) / (max - min);
|
||||
return Math.max(0, Math.min(1, t));
|
||||
}
|
||||
|
||||
// The colour for a value, as a "#rrggbb" string, interpolated between the two
|
||||
// gradient stops that bracket it.
|
||||
export function economicColour(value, min, max, stops = ECONOMIC_GRADIENT) {
|
||||
const t = economicFraction(value, min, max);
|
||||
const scaled = t * (stops.length - 1);
|
||||
const index = Math.min(stops.length - 2, Math.floor(scaled));
|
||||
const blend = scaled - index;
|
||||
const a = parseColour(stops[index]);
|
||||
const b = parseColour(stops[index + 1]);
|
||||
const channel = (i) => Math.round(a[i] + (b[i] - a[i]) * blend);
|
||||
return `#${toHex(channel(0))}${toHex(channel(1))}${toHex(channel(2))}`;
|
||||
}
|
||||
|
||||
function toHex(value) {
|
||||
return value.toString(16).padStart(2, "0");
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const entityMethods = {
|
||||
setCatalogue(civilisations, protoUnits) {
|
||||
this.civilisations = civilisations;
|
||||
this.protoUnits = protoUnits;
|
||||
if (this.political) this._refreshMapColours();
|
||||
},
|
||||
|
||||
applySnapshot(state) {
|
||||
@@ -23,6 +24,10 @@ export const entityMethods = {
|
||||
this._syncCities(state.cities);
|
||||
this._syncUnits(state.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();
|
||||
this.animate(0);
|
||||
},
|
||||
|
||||
@@ -69,6 +74,7 @@ export const entityMethods = {
|
||||
|
||||
this._renderBorders();
|
||||
if (this.glRenderer) this.glRenderer.buildBorders(this);
|
||||
if (this.political) this._refreshMapColours();
|
||||
},
|
||||
|
||||
// Mirrors each land tile's region (the city that claims it). The region of a
|
||||
|
||||
@@ -38,8 +38,11 @@ export const inputMethods = {
|
||||
const tx = width / 2 - x * zoom;
|
||||
const ty = height / 2 - y * zoom * CAMERA_TILT;
|
||||
this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`);
|
||||
// A zoom step may have crossed into or out of the political map.
|
||||
this._updatePoliticalMode();
|
||||
if (applyWrap) this._syncChunks();
|
||||
if (this._selectedTile) this._positionHighlight();
|
||||
this._positionPoliticalLabels();
|
||||
this._glRender();
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// The map-mode switch: terrain, political, and the economic colour modes. It
|
||||
// owns which fill the map is showing, keeps the effective political flag in
|
||||
// step with the camera zoom (terrain mode still turns political when far out),
|
||||
// and drives the economic fill and its legend. The political names themselves
|
||||
// live in ./political.js; this file only decides what is on screen.
|
||||
|
||||
import { HEX_W } from "../../../shared/hex.js";
|
||||
import { POLITICAL_ZOOM } from "./constants.js";
|
||||
import { ECONOMIC_GRADIENT, ECONOMIC_MODES, economicColour, isEconomicMode } from "./economic.js";
|
||||
import { NEUTRAL_LAND, POLITICAL_WATER } from "./political.js";
|
||||
import { isWaterTile } from "./textures.js";
|
||||
|
||||
export const MAP_MODES = ["terrain", "political", "gdp", "population"];
|
||||
|
||||
export const modeMethods = {
|
||||
// Selects a mode and repaints. Terrain mode keeps the far-out auto-switch, so
|
||||
// "political" forces the political map at any zoom while "terrain" only shows
|
||||
// it once the camera has pulled back.
|
||||
setMapMode(mode) {
|
||||
if (!MAP_MODES.includes(mode) || mode === this.mapMode) return;
|
||||
this.mapMode = mode;
|
||||
this._syncModeState();
|
||||
this._refreshMapColours();
|
||||
},
|
||||
|
||||
// Installs the per-tile figures an economic mode paints. Called by the game
|
||||
// screen whenever a snapshot arrives while such a mode is active. A cheap
|
||||
// signature skips the rebuild when nothing moved.
|
||||
setEconomicData(kind, values, min, max) {
|
||||
this.economicKind = kind;
|
||||
this.economicValues = values instanceof Map ? values : new Map(values || []);
|
||||
this.economicRange = { min: min || 0, max: max || 0 };
|
||||
const signature = this._economicSignature();
|
||||
if (signature === this._economicSignatureDone) return;
|
||||
this._economicSignatureDone = signature;
|
||||
if (this.economic) this._refreshMapColours();
|
||||
},
|
||||
|
||||
_economicSignature() {
|
||||
let sum = 0;
|
||||
for (const value of this.economicValues.values()) sum += value;
|
||||
return `${this.economicKind}:${this.economicValues.size}:${Math.round(sum)}`;
|
||||
},
|
||||
|
||||
// Political shows when the user asked for it, or while terrain mode is
|
||||
// zoomed far enough out. Economic modes are exclusive with it.
|
||||
_effectivePolitical() {
|
||||
return this.mapMode === "political" ||
|
||||
(this.mapMode === "terrain" && this.camera.zoom <= POLITICAL_ZOOM);
|
||||
},
|
||||
|
||||
_isEconomic() {
|
||||
return isEconomicMode(this.mapMode);
|
||||
},
|
||||
|
||||
// Recomputes the two effective flags and reflects them on the world element.
|
||||
// The renderers read `political` / `economic` off the view.
|
||||
_syncModeState() {
|
||||
this.political = this._effectivePolitical();
|
||||
this.economic = this._isEconomic();
|
||||
this.$world.toggleClass("political", this.political);
|
||||
this.$world.toggleClass("economic", this.economic);
|
||||
},
|
||||
|
||||
// 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();
|
||||
},
|
||||
|
||||
// Repaints the active fill and the labels/legend that go with it. The DOM
|
||||
// renderer recolours its chunks; WebGL rebuilds the fill passes.
|
||||
_refreshMapColours() {
|
||||
if (this.glRenderer) {
|
||||
this.glRenderer.buildPolitical(this);
|
||||
this.glRenderer.buildEconomic(this);
|
||||
} else if (this.topology) {
|
||||
this._rebuildDomTerrain();
|
||||
}
|
||||
if (this.political) this._renderPoliticalLabels();
|
||||
else if (this.$politicalLabels) this.$politicalLabels.empty();
|
||||
this._updateLegend();
|
||||
},
|
||||
|
||||
// Rebuilds every materialised chunk so a change of mode or territory colour
|
||||
// is reflected in the DOM terrain. Only the chunks near the camera exist, so
|
||||
// this stays cheap.
|
||||
_rebuildDomTerrain() {
|
||||
if (!this._chunks) return;
|
||||
this._destroyAllChunks();
|
||||
this._chunkDirty = true;
|
||||
this._chunkView = { x: null, y: null, zoom: null };
|
||||
this._syncChunks();
|
||||
},
|
||||
|
||||
// Shows the gradient scale while an economic mode is active, with the range's
|
||||
// low, middle and high figures for the current mode.
|
||||
_updateLegend() {
|
||||
const $legend = this.$legend;
|
||||
if (!$legend || !$legend.length) return;
|
||||
const meta = ECONOMIC_MODES[this.mapMode];
|
||||
if (!this.economic || !meta) {
|
||||
$legend.addClass("hidden");
|
||||
return;
|
||||
}
|
||||
$legend.removeClass("hidden");
|
||||
$legend.find("#map-legend-title").text(meta.label);
|
||||
const { min, max } = this.economicRange;
|
||||
$legend.find("#map-legend-min").text(meta.format(min));
|
||||
$legend.find("#map-legend-mid").text(meta.format((min + max) / 2));
|
||||
$legend.find("#map-legend-max").text(meta.format(max));
|
||||
$legend.find("#map-legend-bar")
|
||||
.css("background", `linear-gradient(to right, ${ECONOMIC_GRADIENT.join(", ")})`);
|
||||
},
|
||||
|
||||
// Paints one DOM terrain tile with its economic colour. Water and tiles with
|
||||
// no figure stay neutral; the atlas mask still clips the hexagon.
|
||||
_applyEconomicColour(div, tile, k) {
|
||||
let colour = POLITICAL_WATER;
|
||||
if (!isWaterTile(tile)) {
|
||||
const value = this.economicValues.get(k);
|
||||
colour = value === undefined
|
||||
? NEUTRAL_LAND
|
||||
: economicColour(value, this.economicRange.min, this.economicRange.max);
|
||||
}
|
||||
div.style.backgroundImage = "none";
|
||||
div.style.backgroundColor = colour;
|
||||
const maskPosition = `${-tile.col * HEX_W}px 0`;
|
||||
div.style.maskPosition = maskPosition;
|
||||
div.style.webkitMaskPosition = maskPosition;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
// The political view: when the camera pulls far out the terrain gives way to a
|
||||
// flat map of each country's colour, and every country's name is drawn once per
|
||||
// contiguous holding, fitted to the shape of that holding.
|
||||
//
|
||||
// The name placement is pure geometry: `countryLabels` groups the territory
|
||||
// into connected components, finds the largest rotated band that fits inside
|
||||
// each one, and sizes the text to fill it. The view only has to anchor the
|
||||
// result on the map. Keeping the fitting here means it can be tested without a
|
||||
// browser, and the renderer stays a thin drawing layer.
|
||||
|
||||
import { mapToLocal, key, parseKey, HEX_W, HEX_H } from "../../../shared/hex.js";
|
||||
import { isWaterTile } from "./textures.js";
|
||||
import { CAMERA_TILT } from "./constants.js";
|
||||
|
||||
// Neutral ground and open water, used by the DOM fallback where there is no
|
||||
// terrain atlas to fall back on.
|
||||
export const NEUTRAL_LAND = "#5b5f49";
|
||||
export const POLITICAL_WATER = "#16324a";
|
||||
|
||||
// A holding needs at least this many tiles before it earns a name; one-hex
|
||||
// islands would only add clutter (and unreadable text).
|
||||
const MIN_TILES = 3;
|
||||
// Below these the text would be unreadable, so the label is dropped instead.
|
||||
export const MIN_FONT = 7;
|
||||
const MIN_TEXT_LENGTH = 20;
|
||||
// A name is never rotated further than this from the horizontal, so it stays
|
||||
// easy to read no matter how the holding is shaped.
|
||||
export const MAX_LABEL_ANGLE = (45 * Math.PI) / 180;
|
||||
// Space kept clear of the holding's edge, as a fraction of the band and with a
|
||||
// floor so even a small holding has breathing room.
|
||||
const MARGIN_FRACTION = 0.16;
|
||||
const MIN_MARGIN = 8;
|
||||
// The text is vertically centred on the band, so the font size is the usable
|
||||
// band height times this safety factor (ascent plus descent is a little over
|
||||
// one em for most faces).
|
||||
const HEIGHT_PAD = 0.82;
|
||||
// Kerning may be opened up to this many times the natural run, but the letters
|
||||
// themselves are never squashed or pulled closer together.
|
||||
export const MAX_KERNING = 2;
|
||||
|
||||
// Splits a territory map (tile key -> civ id) into connected components. On a
|
||||
// cylindrical world the neighbour lookup already folds the seam, so a holding
|
||||
// that wraps is still one component; each tile is placed in an unwrapped pixel
|
||||
// frame by walking the component and accumulating the shortest pixel deltas, so
|
||||
// the seam never splits the shape it is fitted to.
|
||||
export function territoryComponents(territory, topology) {
|
||||
const byCiv = new Map();
|
||||
for (const [k, civ] of territory) {
|
||||
if (!byCiv.has(civ)) byCiv.set(civ, []);
|
||||
byCiv.get(civ).push(k);
|
||||
}
|
||||
const components = [];
|
||||
for (const [civ, keys] of byCiv) {
|
||||
const remaining = new Set(keys);
|
||||
while (remaining.size > 0) {
|
||||
const startKey = remaining.values().next().value;
|
||||
remaining.delete(startKey);
|
||||
const startCoords = parseKey(startKey);
|
||||
const startPos = mapToLocal(startCoords.x, startCoords.y);
|
||||
const positions = new Map([[startKey, startPos]]);
|
||||
const coordsByKey = new Map([[startKey, startCoords]]);
|
||||
const memberKeys = [];
|
||||
const queue = [startKey];
|
||||
while (queue.length > 0) {
|
||||
const k = queue.pop();
|
||||
memberKeys.push(k);
|
||||
const coords = coordsByKey.get(k);
|
||||
const pos = positions.get(k);
|
||||
for (const neighbour of topology.neighbours(coords.x, coords.y)) {
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
if (!remaining.has(nk)) continue;
|
||||
remaining.delete(nk);
|
||||
const delta = topology.pixelDelta(coords, neighbour);
|
||||
positions.set(nk, { x: pos.x + delta.x, y: pos.y + delta.y });
|
||||
coordsByKey.set(nk, neighbour);
|
||||
queue.push(nk);
|
||||
}
|
||||
}
|
||||
components.push({
|
||||
civ,
|
||||
keys: memberKeys,
|
||||
points: memberKeys.map((k) => positions.get(k)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return components;
|
||||
}
|
||||
|
||||
// The principal axis of a point cloud and its extent along that axis, as
|
||||
// { cx, cy, angle, cos, sin }. The axis is the first eigenvector of the
|
||||
// covariance matrix (for a 2D cloud this has a closed form), normalised so the
|
||||
// text is never upside down.
|
||||
export function principalAxis(points) {
|
||||
const n = points.length;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (const point of points) {
|
||||
cx += point.x;
|
||||
cy += point.y;
|
||||
}
|
||||
cx /= n;
|
||||
cy /= n;
|
||||
let sxx = 0;
|
||||
let syy = 0;
|
||||
let sxy = 0;
|
||||
for (const point of points) {
|
||||
const dx = point.x - cx;
|
||||
const dy = point.y - cy;
|
||||
sxx += dx * dx;
|
||||
syy += dy * dy;
|
||||
sxy += dx * dy;
|
||||
}
|
||||
sxx /= n;
|
||||
syy /= n;
|
||||
sxy /= n;
|
||||
let angle = 0.5 * Math.atan2(2 * sxy, sxx - syy);
|
||||
while (angle > Math.PI / 2) angle -= Math.PI;
|
||||
while (angle <= -Math.PI / 2) angle += Math.PI;
|
||||
return { cx, cy, angle, cos: Math.cos(angle), sin: Math.sin(angle) };
|
||||
}
|
||||
|
||||
// The largest rotated band that fits inside one component. The points are
|
||||
// projected onto the principal axis, binned along it, and every contiguous run
|
||||
// of bins is scored by the area of the band whose height is set by the overlap
|
||||
// of the bins' perpendicular spans. The best run wins, which gives a label that
|
||||
// hugs the widest, longest part of an irregular holding instead of spilling out
|
||||
// of a concave notch.
|
||||
export function fitLabelBand(points) {
|
||||
const axis = principalAxis(points);
|
||||
const { cx, cy } = axis;
|
||||
// Clamp the base angle to a readable near-horizontal range; the band is then
|
||||
// fitted along that clamped direction.
|
||||
const angle = Math.max(-MAX_LABEL_ANGLE, Math.min(MAX_LABEL_ANGLE, axis.angle));
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const binSize = HEX_W;
|
||||
const bins = new Map();
|
||||
for (const point of points) {
|
||||
const dx = point.x - cx;
|
||||
const dy = point.y - cy;
|
||||
const u = dx * cos + dy * sin;
|
||||
const v = -dx * sin + dy * cos;
|
||||
const index = Math.floor(u / binSize);
|
||||
let bin = bins.get(index);
|
||||
if (!bin) {
|
||||
bin = { index, vmin: Infinity, vmax: -Infinity };
|
||||
bins.set(index, bin);
|
||||
}
|
||||
if (v < bin.vmin) bin.vmin = v;
|
||||
if (v > bin.vmax) bin.vmax = v;
|
||||
}
|
||||
const ordered = [...bins.values()].sort((a, b) => a.index - b.index);
|
||||
// A tile occupies half a hex either side of its centre, so the true
|
||||
// perpendicular room is half a hex larger than the centre span.
|
||||
const pad = HEX_H / 2;
|
||||
let best = null;
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
let maxVmin = -Infinity;
|
||||
let minVmax = Infinity;
|
||||
for (let j = i; j < ordered.length; j++) {
|
||||
if (j > i && ordered[j].index !== ordered[j - 1].index + 1) break;
|
||||
maxVmin = Math.max(maxVmin, ordered[j].vmin);
|
||||
minVmax = Math.min(minVmax, ordered[j].vmax);
|
||||
const clearance = (minVmax - maxVmin) / 2 + pad;
|
||||
if (clearance <= 0) break;
|
||||
const u0 = (ordered[i].index + 0.5) * binSize;
|
||||
const u1 = (ordered[j].index + 0.5) * binSize;
|
||||
const span = u1 - u0 + binSize;
|
||||
const area = span * clearance;
|
||||
if (!best || area > best.area) {
|
||||
best = {
|
||||
area,
|
||||
span,
|
||||
clearance,
|
||||
vCenter: (minVmax + maxVmin) / 2,
|
||||
uCenter: (u0 + u1) / 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
// Keep a margin off every edge on both axes before the text is sized.
|
||||
const heightMargin = Math.max(MIN_MARGIN, best.clearance * MARGIN_FRACTION);
|
||||
const usableHeight = best.clearance - heightMargin;
|
||||
const fontSize = 2 * usableHeight * HEIGHT_PAD;
|
||||
if (fontSize < MIN_FONT) return null;
|
||||
const lengthMargin = Math.max(MIN_MARGIN, best.span * MARGIN_FRACTION);
|
||||
const maxLength = best.span - 2 * lengthMargin;
|
||||
if (maxLength < MIN_TEXT_LENGTH) return null;
|
||||
const x = cx + best.uCenter * cos - best.vCenter * sin;
|
||||
const y = cy + best.uCenter * sin + best.vCenter * cos;
|
||||
return { x, y, angle, fontSize, maxLength };
|
||||
}
|
||||
|
||||
// Decides the final font size and run for one name, given the width it takes at
|
||||
// the proposed size. The letters are never compressed: if the natural run is
|
||||
// wider than the band the font shrinks to fit instead, and if it is narrower
|
||||
// the gap between glyphs is opened up by at most MAX_KERNING times the natural
|
||||
// run. A null `textLength` means the name is left at its natural spacing.
|
||||
export function fitTextWidth(naturalWidth, fontSize, maxLength) {
|
||||
if (!(naturalWidth > 0) || !(maxLength > 0)) return { fontSize, textLength: null };
|
||||
if (naturalWidth > maxLength) {
|
||||
return { fontSize: fontSize * (maxLength / naturalWidth), textLength: null };
|
||||
}
|
||||
const target = Math.min(maxLength, naturalWidth * MAX_KERNING);
|
||||
if (target > naturalWidth * (1 + 1e-3)) {
|
||||
return { fontSize, textLength: target };
|
||||
}
|
||||
return { fontSize, textLength: null };
|
||||
}
|
||||
|
||||
// One label per contiguous holding, ready to be anchored on the map. `civ` is
|
||||
// the owner id, so the caller can tint or group them.
|
||||
export function countryLabels(territory, civilisations, topology) {
|
||||
const labels = [];
|
||||
if (!territory || !topology) return labels;
|
||||
for (const component of territoryComponents(territory, topology)) {
|
||||
if (component.points.length < MIN_TILES) continue;
|
||||
const civ = civilisations ? civilisations[component.civ] : null;
|
||||
if (!civ || !civ.name) continue;
|
||||
const band = fitLabelBand(component.points);
|
||||
if (!band) continue;
|
||||
labels.push({
|
||||
civ: component.civ,
|
||||
name: civ.name,
|
||||
colour: civ.primaryColour || "#ffffff",
|
||||
tiles: component.points.length,
|
||||
...band,
|
||||
});
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
export const politicalMethods = {
|
||||
// Builds one SVG text per contiguous holding. Each name is measured and then
|
||||
// fitted to its band: a run wider than the band shrinks the font, a narrower
|
||||
// one is opened up with spacing only (`lengthAdjust="spacing"`), capped so the
|
||||
// letters are never pulled together or spread more than MAX_KERNING.
|
||||
_renderPoliticalLabels() {
|
||||
const $layer = this.$politicalLabels;
|
||||
if (!$layer || !$layer.length || !this.topology) return;
|
||||
const labels = countryLabels(this.territory, this.civilisations, this.topology);
|
||||
this._countryLabels = labels;
|
||||
if (this._labelSvg) {
|
||||
this._labelSvg.remove();
|
||||
this._labelSvg = null;
|
||||
}
|
||||
this._labelEntries = [];
|
||||
const doc = document;
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const svg = doc.createElementNS(ns, "svg");
|
||||
svg.setAttribute("class", "political-label-svg");
|
||||
// Attach before measuring: a detached text node has no layout to measure.
|
||||
$layer[0].appendChild(svg);
|
||||
for (const label of labels) {
|
||||
const el = doc.createElementNS(ns, "text");
|
||||
el.setAttribute("text-anchor", "middle");
|
||||
el.setAttribute("dominant-baseline", "central");
|
||||
el.setAttribute("lengthAdjust", "spacing");
|
||||
el.setAttribute("fill", "#ffffff");
|
||||
el.setAttribute("stroke", "rgba(0, 0, 0, 0.75)");
|
||||
el.setAttribute("paint-order", "stroke");
|
||||
// Set the size before measuring: the natural run has to be read at the
|
||||
// size the name will actually be drawn at, or fitting it back would
|
||||
// squash every glyph onto its neighbour.
|
||||
el.setAttribute("font-size", label.fontSize.toFixed(2));
|
||||
el.textContent = label.name;
|
||||
svg.appendChild(el);
|
||||
const natural = typeof el.getComputedTextLength === "function"
|
||||
? el.getComputedTextLength()
|
||||
: 0;
|
||||
const fitted = fitTextWidth(natural, label.fontSize, label.maxLength);
|
||||
if (fitted.fontSize < MIN_FONT) {
|
||||
svg.removeChild(el);
|
||||
continue;
|
||||
}
|
||||
el.setAttribute("font-size", fitted.fontSize.toFixed(2));
|
||||
el.setAttribute("stroke-width", (fitted.fontSize * 0.08).toFixed(2));
|
||||
if (fitted.textLength) el.setAttribute("textLength", fitted.textLength.toFixed(2));
|
||||
this._labelEntries.push({ el, label });
|
||||
}
|
||||
this._labelSvg = svg;
|
||||
this._positionPoliticalLabels();
|
||||
},
|
||||
|
||||
// Anchors every name on the wrapped copy nearest the camera. The ground is
|
||||
// foreshortened, so the Y is tilted and the on-screen angle is the ground
|
||||
// angle folded through the same tilt; the text itself stays upright.
|
||||
_positionPoliticalLabels() {
|
||||
if (!this._labelEntries) return;
|
||||
const tilt = CAMERA_TILT;
|
||||
for (const { el, label } of this._labelEntries) {
|
||||
const x = label.x + this._wrapShift(label.x);
|
||||
const y = label.y * tilt;
|
||||
const angle = Math.atan2(Math.sin(label.angle) * tilt, Math.cos(label.angle));
|
||||
const degrees = (angle * 180) / Math.PI;
|
||||
el.setAttribute("transform",
|
||||
`translate(${x.toFixed(2)} ${y.toFixed(2)}) rotate(${degrees.toFixed(2)})`);
|
||||
}
|
||||
},
|
||||
|
||||
// Paints one DOM terrain tile as a flat political colour. The atlas mask on
|
||||
// `.hex.terrain` still clips it to the hexagon, so no per-tile clip is needed.
|
||||
_applyPoliticalColour(div, tile, k) {
|
||||
let background = POLITICAL_WATER;
|
||||
if (!isWaterTile(tile)) {
|
||||
const owner = this.territory ? this.territory.get(k) : undefined;
|
||||
const civ = owner !== undefined && this.civilisations
|
||||
? this.civilisations[owner]
|
||||
: null;
|
||||
background = civ ? civ.primaryColour : NEUTRAL_LAND;
|
||||
}
|
||||
div.style.backgroundImage = "none";
|
||||
div.style.backgroundColor = background;
|
||||
const maskPosition = `${-tile.col * HEX_W}px 0`;
|
||||
div.style.maskPosition = maskPosition;
|
||||
div.style.webkitMaskPosition = maskPosition;
|
||||
},
|
||||
};
|
||||
@@ -68,6 +68,17 @@ export const terrainMethods = {
|
||||
|
||||
this.$entities.empty();
|
||||
this.$labels.empty();
|
||||
if (this.$politicalLabels) this.$politicalLabels.empty();
|
||||
this._labelSvg = null;
|
||||
this._labelEntries = [];
|
||||
// The new world invalidates any political or economic colouring; the next
|
||||
// camera sync re-applies the mode if the camera is still far out.
|
||||
this.political = false;
|
||||
this.economic = false;
|
||||
this.economicValues = new Map();
|
||||
this._economicSignatureDone = null;
|
||||
this.$world.removeClass("political");
|
||||
this.$world.removeClass("economic");
|
||||
this._unitViews.clear();
|
||||
this._unitMotion.clear();
|
||||
this._unitData.clear();
|
||||
@@ -341,7 +352,9 @@ export const terrainMethods = {
|
||||
div.className = "hex terrain detail-layer";
|
||||
div.style.left = `${local.x - rec.x - HEX_W / 2}px`;
|
||||
div.style.top = `${local.y - rec.y - HEX_H / 2}px`;
|
||||
this._applyTerrainTexture(div, tile, local);
|
||||
if (this.economic) this._applyEconomicColour(div, tile, k);
|
||||
else if (this.political) this._applyPoliticalColour(div, tile, k);
|
||||
else this._applyTerrainTexture(div, tile, local);
|
||||
fragment.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
import { createHeightField } from "./heightmap.js";
|
||||
import { coastDistanceField } from "./coast.js";
|
||||
import { generateWaterBump } from "./water.js";
|
||||
import { economicColour } from "./economic.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
|
||||
@@ -362,6 +363,10 @@ export class GLMapRenderer {
|
||||
this.fog = { buffer: null, count: 0 };
|
||||
// The selected/inspected tile's region: a soft fill plus dotted borders.
|
||||
this.regionOverlay = { buffer: null, count: 0 };
|
||||
// The far-out political map: one flat country colour per owned tile.
|
||||
this.politicalFill = { buffer: null, count: 0 };
|
||||
// The economic modes: one flat colour per tile, from the shared scale.
|
||||
this.economicFill = { buffer: null, count: 0 };
|
||||
// The fog is rendered off-screen and blurred before it is composited, so
|
||||
// its frontier is a soft gradient rather than the hex-tiling edges.
|
||||
this.fullscreenProgram = null;
|
||||
@@ -430,7 +435,7 @@ export class GLMapRenderer {
|
||||
if (!gl) return;
|
||||
for (const pass of [
|
||||
this.terrain, this.ocean, this.roads, this.borders, this.fog, this.regionOverlay,
|
||||
...this.terrainLevels,
|
||||
this.politicalFill, this.economicFill, ...this.terrainLevels,
|
||||
]) {
|
||||
if (!pass || !pass.buffer) continue;
|
||||
gl.deleteBuffer(pass.buffer);
|
||||
@@ -1669,6 +1674,72 @@ export class GLMapRenderer {
|
||||
this._upload(this.regionOverlay, arr, this.gl.DYNAMIC_DRAW);
|
||||
}
|
||||
|
||||
// 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.
|
||||
buildPolitical(view) {
|
||||
const arr = [];
|
||||
const territory = view && view.territory;
|
||||
if (!view || !view.political || !territory || territory.size === 0 ||
|
||||
!view.topology || !view.civilisations) {
|
||||
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);
|
||||
}
|
||||
this._upload(this.politicalFill, arr, this.gl.DYNAMIC_DRAW);
|
||||
}
|
||||
|
||||
// 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.
|
||||
buildEconomic(view) {
|
||||
const arr = [];
|
||||
if (!view || !view.economic || !view.economicValues ||
|
||||
view.economicValues.size === 0 || !view.topology) {
|
||||
this._upload(this.economicFill, arr);
|
||||
return;
|
||||
}
|
||||
const seed = view.seed | 0;
|
||||
const period = periodUnits(view.topology);
|
||||
const field = this._heightField(view);
|
||||
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);
|
||||
}
|
||||
this._upload(this.economicFill, arr, this.gl.DYNAMIC_DRAW);
|
||||
}
|
||||
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- render --
|
||||
|
||||
// The horizontal periods whose copy of the world intersects the view. On a
|
||||
@@ -1855,7 +1926,12 @@ export class GLMapRenderer {
|
||||
// The flat ocean sits under everything; the land mesh is drawn over it.
|
||||
this._drawPass(this.ocean, 0, shifts, period);
|
||||
if (this.texReady && terrainPass) this._drawPass(terrainPass, 0, shifts, period, rows);
|
||||
this._drawPass(this.roads, 1, shifts, period);
|
||||
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);
|
||||
// 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);
|
||||
this._drawFog(bufferWidth, bufferHeight, shifts, period, rows);
|
||||
this._drawPass(this.regionOverlay, 1, shifts, period);
|
||||
|
||||
@@ -10,4 +10,5 @@ export { CityModal } from "./modals/city.js";
|
||||
export { NationModal } from "./modals/nation.js";
|
||||
export { ConfirmModal } from "./modals/confirm.js";
|
||||
export { NewsModal } from "./modals/news.js";
|
||||
export { MapModesModal } from "./modals/map_modes.js";
|
||||
export { describeNews } from "./modals/format.js";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// The map-mode picker: a small popover of radio options anchored above the
|
||||
// Map modes button. It only reports the chosen mode; the game screen applies
|
||||
// it, so the modal stays free of map state.
|
||||
|
||||
export class MapModesModal {
|
||||
constructor() {
|
||||
this.$popover = $("#map-modes-popover");
|
||||
this.onMode = () => {};
|
||||
this.$popover.on("change", "input[name='map-mode']", (event) => {
|
||||
const mode = $(event.currentTarget).val();
|
||||
this.close();
|
||||
this.onMode(mode);
|
||||
});
|
||||
}
|
||||
|
||||
isShowing() {
|
||||
return this.$popover.length > 0 && !this.$popover.hasClass("hidden");
|
||||
}
|
||||
|
||||
show(current) {
|
||||
const $inputs = this.$popover.find("input[name='map-mode']");
|
||||
$inputs.prop("checked", false);
|
||||
$inputs.filter(`[value='${current}']`).prop("checked", true);
|
||||
this.$popover.removeClass("hidden");
|
||||
}
|
||||
|
||||
toggle(current) {
|
||||
if (this.isShowing()) this.close();
|
||||
else this.show(current);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$popover.addClass("hidden");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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_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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { MapView } from "../client/js/map_view.js";
|
||||
import { MapTopology, key, parseKey } from "../shared/hex.js";
|
||||
import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
|
||||
import { politicalMode, POLITICAL_ZOOM } from "../client/js/map_view/constants.js";
|
||||
import {
|
||||
countryLabels,
|
||||
fitLabelBand,
|
||||
fitTextWidth,
|
||||
territoryComponents,
|
||||
MAX_KERNING,
|
||||
MAX_LABEL_ANGLE,
|
||||
MIN_FONT,
|
||||
} from "../client/js/map_view/political.js";
|
||||
import { smallState, mapLayers } from "./framework/helpers.js";
|
||||
|
||||
const CIVS = [
|
||||
{ id: "a", name: "France", primaryColour: "#123456" },
|
||||
{ id: "b", name: "Britain", primaryColour: "#abcdef" },
|
||||
];
|
||||
|
||||
function territoryOf(entries) {
|
||||
return new Map(entries.map(([x, y, civ]) => [key(x, y), civ]));
|
||||
}
|
||||
|
||||
export class PoliticalMapTest extends TestCase {
|
||||
async test_components_split_by_owner_and_contiguity() {
|
||||
const topo = new MapTopology({ x: 8, y: 8 }, false);
|
||||
const territory = territoryOf([
|
||||
[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0],
|
||||
[5, 5, 0],
|
||||
[0, 3, 1], [1, 3, 1],
|
||||
]);
|
||||
const components = territoryComponents(territory, topo);
|
||||
this.assertSize(components, 3, "one holding per contiguous run and owner");
|
||||
const sizes = components.map((c) => c.points.length).sort((a, b) => a - b);
|
||||
this.assertEqual(sizes, [1, 2, 4], "each holding keeps its own tiles");
|
||||
for (const component of components) {
|
||||
this.assertGreater(component.points.length, 0, "every holding has points");
|
||||
}
|
||||
}
|
||||
|
||||
async test_a_holding_across_the_wrap_is_unwrapped() {
|
||||
const topo = new MapTopology({ x: 8, y: 4 }, true);
|
||||
// On a width-8 cylindrical world the columns are -4..3, so 3 and -4 are the
|
||||
// two sides of the seam and are neighbours.
|
||||
const territory = territoryOf([[3, 0, 0], [-4, 0, 0], [-4, 1, 0]]);
|
||||
const components = territoryComponents(territory, topo);
|
||||
this.assertSize(components, 1, "the seam does not split the holding");
|
||||
const points = components[0].points;
|
||||
let span = 0;
|
||||
for (const a of points) {
|
||||
for (const b of points) span = Math.max(span, Math.hypot(a.x - b.x, a.y - b.y));
|
||||
}
|
||||
this.assertLess(span, 4 * 63, "the wrapped tiles are placed side by side");
|
||||
}
|
||||
|
||||
async test_a_wide_holding_is_labelled_horizontally() {
|
||||
const topo = new MapTopology({ x: 10, y: 8 }, false);
|
||||
const entries = [];
|
||||
for (let y = 0; y < 2; y++) {
|
||||
for (let x = 0; x < 6; x++) entries.push([x, y, 0]);
|
||||
}
|
||||
const band = fitLabelBand(territoryComponents(territoryOf(entries), topo)[0].points);
|
||||
this.assertNotNull(band, "a block gets a band");
|
||||
this.assertLess(Math.abs(band.angle), 0.35, "the long axis is horizontal");
|
||||
this.assertGreater(band.fontSize, 0, "the text has a size");
|
||||
this.assertGreater(band.maxLength, 0, "the text has a length");
|
||||
}
|
||||
|
||||
async test_a_tall_holding_is_never_rotated_far_from_horizontal() {
|
||||
const topo = new MapTopology({ x: 8, y: 10 }, false);
|
||||
const entries = [];
|
||||
for (let y = 0; y < 6; y++) {
|
||||
for (let x = 0; x < 2; x++) entries.push([x, y, 0]);
|
||||
}
|
||||
const band = fitLabelBand(territoryComponents(territoryOf(entries), topo)[0].points);
|
||||
this.assertNotNull(band, "a tall block gets a band");
|
||||
this.assertLess(Math.abs(band.angle), MAX_LABEL_ANGLE + 1e-6,
|
||||
"the base angle stays inside the readable range");
|
||||
}
|
||||
|
||||
async test_text_is_never_compressed_and_kerning_is_capped() {
|
||||
// A run already wider than the band shrinks the font instead of squeezing
|
||||
// the letters together.
|
||||
const shrunk = fitTextWidth(300, 20, 200);
|
||||
this.assertApprox(shrunk.fontSize, (20 * 200) / 300, 1e-9, "the font shrinks to fit");
|
||||
this.assertNull(shrunk.textLength, "no spacing is forced when the font shrank");
|
||||
|
||||
// A short run is opened up, but never by more than MAX_KERNING times.
|
||||
const widened = fitTextWidth(50, 20, 900);
|
||||
this.assertApprox(widened.fontSize, 20, 1e-9, "the font is untouched");
|
||||
this.assertApprox(widened.textLength, 50 * MAX_KERNING, 1e-9, "kerning is capped");
|
||||
|
||||
// A run that already fits and needs no help is left alone.
|
||||
const natural = fitTextWidth(100, 20, 100);
|
||||
this.assertNull(natural.textLength, "a fitting run keeps its natural spacing");
|
||||
}
|
||||
|
||||
async test_the_label_keeps_a_margin_off_the_holding() {
|
||||
const topo = new MapTopology({ x: 12, y: 10 }, false);
|
||||
const entries = [];
|
||||
for (let y = 0; y < 4; y++) {
|
||||
for (let x = 0; x < 9; x++) entries.push([x, y, 0]);
|
||||
}
|
||||
const component = territoryComponents(territoryOf(entries), topo)[0];
|
||||
const band = fitLabelBand(component.points);
|
||||
this.assertNotNull(band, "the block gets a band");
|
||||
const axis = Math.cos(band.angle);
|
||||
const projected = component.points
|
||||
.map((p) => p.x * axis + p.y * Math.sin(band.angle))
|
||||
.sort((a, b) => a - b);
|
||||
const span = projected[projected.length - 1] - projected[0];
|
||||
this.assertLess(band.maxLength, span, "the text run stops short of the extremes");
|
||||
this.assertGreater(band.fontSize, MIN_FONT, "the text is still readable");
|
||||
}
|
||||
|
||||
async test_the_name_is_drawn_once_per_contiguous_holding() {
|
||||
const topo = new MapTopology({ x: 10, y: 10 }, false);
|
||||
const entries = [];
|
||||
for (let y = 0; y < 3; y++) {
|
||||
for (let x = 0; x < 4; x++) entries.push([x, y, 0]);
|
||||
}
|
||||
// A second, separate holding of the same nation.
|
||||
for (let y = 6; y < 8; y++) {
|
||||
for (let x = 6; x < 9; x++) entries.push([x, y, 0]);
|
||||
}
|
||||
// A holding of another nation, away from the first.
|
||||
for (let y = 5; y < 8; y++) {
|
||||
for (let x = 0; x < 4; x++) entries.push([x, y, 1]);
|
||||
}
|
||||
const labels = countryLabels(territoryOf(entries), CIVS, topo);
|
||||
this.assertSize(labels, 3, "one label per contiguous holding");
|
||||
const names = labels.map((label) => label.name).sort();
|
||||
this.assertEqual(names, ["Britain", "France", "France"], "the owner names are used");
|
||||
const france = labels.filter((label) => label.civ === 0);
|
||||
this.assertEqual(france.length, 2, "the split holding is labelled twice");
|
||||
}
|
||||
|
||||
async test_a_label_sits_inside_its_holding() {
|
||||
const topo = new MapTopology({ x: 12, y: 10 }, false);
|
||||
const entries = [];
|
||||
for (let y = 1; y < 5; y++) {
|
||||
for (let x = 2; x < 9; x++) entries.push([x, y, 0]);
|
||||
}
|
||||
const territory = territoryOf(entries);
|
||||
const component = territoryComponents(territory, topo)[0];
|
||||
const band = fitLabelBand(component.points);
|
||||
this.assertNotNull(band, "the block gets a band");
|
||||
const xs = component.points.map((p) => p.x);
|
||||
const ys = component.points.map((p) => p.y);
|
||||
this.assertTrue(band.x >= Math.min(...xs) - 63 && band.x <= Math.max(...xs) + 63,
|
||||
"the label anchors over the holding");
|
||||
this.assertTrue(band.y >= Math.min(...ys) - 56 && band.y <= Math.max(...ys) + 56,
|
||||
"the label anchors over the holding");
|
||||
// The band height is bounded by the room across the holding.
|
||||
this.assertLess(band.fontSize, 4 * 56, "the text fits across the holding");
|
||||
}
|
||||
|
||||
async test_tiny_holdings_get_no_name() {
|
||||
const topo = new MapTopology({ x: 6, y: 6 }, false);
|
||||
const labels = countryLabels(territoryOf([[0, 0, 0]]), CIVS, topo);
|
||||
this.assertEmpty(labels, "a lone tile is not labelled");
|
||||
}
|
||||
|
||||
async test_political_mode_matches_the_zoom_threshold() {
|
||||
this.assertTrue(politicalMode(POLITICAL_ZOOM), "the threshold itself is political");
|
||||
this.assertTrue(politicalMode(0.1), "far out is political");
|
||||
this.assertFalse(politicalMode(POLITICAL_ZOOM + 0.01), "closer in is terrain");
|
||||
}
|
||||
|
||||
async test_the_dom_map_paints_country_colours_and_clears_them() {
|
||||
const state = smallState();
|
||||
const env = await setupDom();
|
||||
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);
|
||||
const snapshot = state.snapshot(0);
|
||||
try {
|
||||
map.applySnapshot(snapshot);
|
||||
this.assertGreater(map.territory.size, 0, "the snapshot has territory");
|
||||
map.camera.zoom = 0.2;
|
||||
map._applyCamera();
|
||||
this.assertTrue(map.political, "the far zoom switches to the political map");
|
||||
this.assertTrue(map.$world.hasClass("political"), "the world is flagged political");
|
||||
const owned = snapshottedColourTile(env);
|
||||
this.assertNotNull(owned, "an owned tile is painted a flat country colour");
|
||||
const style = owned.getAttribute("style") || "";
|
||||
this.assertFalse(/terrain\.png|textures\//.test(style), "the terrain texture is gone");
|
||||
this.assertTrue(/background-color/.test(style), "the tile keeps a country colour");
|
||||
|
||||
map.camera.zoom = 1.0;
|
||||
map._applyCamera();
|
||||
this.assertFalse(map.political, "zooming back restores the terrain map");
|
||||
this.assertFalse(map.$world.hasClass("political"), "the political flag is cleared");
|
||||
this.assertSize(env.$("#layer-political-labels text"), 0, "the names are removed");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The first on-screen terrain hex whose colour is one of the catalogue's, i.e.
|
||||
// a tile a nation owns. jsdom turns the colour into `rgb(...)`, so compare the
|
||||
// numeric channels of every civ rather than the original hex string.
|
||||
function snapshottedColourTile(env) {
|
||||
const colours = new Set();
|
||||
for (const civ of CIVILISATIONS) {
|
||||
colours.add(civ.primaryColour.toLowerCase());
|
||||
colours.add(cssColour(civ.primaryColour));
|
||||
}
|
||||
let found = null;
|
||||
env.$("#layer-terrain .hex.terrain").each((_index, element) => {
|
||||
if (!found && colours.has(element.style.backgroundColor)) found = element;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function cssColour(hex) {
|
||||
const value = hex.replace("#", "");
|
||||
return `rgb(${parseInt(value.slice(0, 2), 16)}, ${parseInt(value.slice(2, 4), 16)}, ${parseInt(value.slice(4, 6), 16)})`;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ function makeRenderer() {
|
||||
renderer.borders = { name: "borders" };
|
||||
renderer.roads = { name: "roads" };
|
||||
renderer.regionOverlay = { name: "region" };
|
||||
renderer.politicalFill = { name: "political" };
|
||||
renderer.economicFill = { name: "economic" };
|
||||
renderer._upload = function (pass, arr) {
|
||||
this.captured[pass.name] = arr;
|
||||
pass.count = arr.length / 8;
|
||||
@@ -270,6 +272,41 @@ export class WebglGeometryTest extends TestCase {
|
||||
this.assertTrue(lifted, "border vertices ride the heightmap");
|
||||
}
|
||||
|
||||
async test_the_political_fill_paints_owned_tiles_only() {
|
||||
const renderer = makeRenderer();
|
||||
const state = view(renderer, { territory: [["0,0", 0], ["1,0", 1]] });
|
||||
renderer._warp = renderer._buildWarpSet(state);
|
||||
renderer.buildPolitical(state);
|
||||
this.assertEmpty(renderer.captured.political, "the terrain map has no political fill");
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
async test_the_economic_fill_paints_every_valued_tile() {
|
||||
const renderer = makeRenderer();
|
||||
const state = view(renderer);
|
||||
renderer._warp = renderer._buildWarpSet(state);
|
||||
renderer.buildEconomic(state);
|
||||
this.assertEmpty(renderer.captured.economic, "no figures means no economic fill");
|
||||
|
||||
state.economic = true;
|
||||
state.economicValues = new Map([["0,0", 10], ["1,0", 20], ["0,1", 30]]);
|
||||
state.economicRange = { min: 0, max: 30 };
|
||||
renderer.buildEconomic(state);
|
||||
const arr = renderer.captured.economic;
|
||||
this.assertEqual(arr.length, 3 * 6 * 3 * 8, "one flat hexagon per valued tile");
|
||||
for (const value of arr) {
|
||||
this.assertTrue(Number.isFinite(value), "every economic vertex is finite");
|
||||
}
|
||||
}
|
||||
|
||||
async test_sea_edges_are_straight_except_against_land() {
|
||||
const renderer = makeRenderer();
|
||||
const state = view(renderer, { terrainAt: (x) => (x === 0 ? "Sea" : "Land") });
|
||||
|
||||
Reference in New Issue
Block a user