Files
Battle-for-Tismo/client/js/map_view/modes.js
T
adrien 6f73e2cc51 Reworked the world around a fixed sea share and settled coasts
World generation now aims at a fixed share of the ice-free tiles (60%) instead of a minimum continent count, bisecting the water level for it rather than sweeping every step, and still joins the ocean into one body. Ice and tundra are the outermost rows only, scaled down on small maps so the fixtures keep usable land.

Cities score their site, coastal plains best and landlocked worst, avoiding tundra and desert. Every nation that holds a coast gets a port city on each island it owns, and any landmass left empty is settled by the nearest nation, so no land starts unowned. A coastal city opens with a port already built.

Bundles the in-progress work in the tree: the commodity monthly price series and inflation the central bank's chart reads, and the rolling city-to-city migration graph the population map draws as flow arrows.
2026-09-22 17:00:38 +02:00

316 lines
13 KiB
JavaScript

// 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, mapToLocal } 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";
import { makeSegment, makeArrow } from "./utils.js";
import { compact } from "../../../shared/text_format.js";
import { RESOURCES } from "../../../shared/data/resources.js";
export const MAP_MODES = ["terrain", "political", "gdp", "population"];
// The migration arrows: one hue so they stay legible over the population
// gradient, with the month's flow mapped between these thicknesses.
const MIGRATION_COLOUR = "#ff3ec8";
const MIGRATION_ARROW_MIN = 1.5;
const MIGRATION_ARROW_MAX = 12;
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._repaintMode();
},
// 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 };
// The scale a mode reads its figures through (the population mode is
// logarithmic). Kept on the view so both renderers agree.
this.economicScale = (ECONOMIC_MODES[kind] || {}).scale || null;
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)}`;
},
// -------------------------------------------------- migration graph -----
// Replaces the migration graph shipped in the snapshot. It is only drawn in
// the population mode, which is an economic mode, so a political or terrain
// map never reveals it.
setMigrationGraph(graph) {
if (!graph) return;
this.migrationGraph = {
links: Array.isArray(graph.links) ? graph.links : [],
};
this._renderResourceGraph();
},
// ------------------------------------------------- delivery graph ------
// Replaces the delivery graph shipped in the snapshot. It is only drawn in
// the economic modes, so a political or terrain map never reveals it.
setResourceGraph(graph) {
if (!graph) return;
this.resourceGraph = {
nodes: Array.isArray(graph.nodes) ? graph.nodes : [],
links: Array.isArray(graph.links) ? graph.links : [],
};
this._renderResourceGraph();
},
// Draws the economic overlay's graph. The population map carries the
// migration arrows; every other economic mode carries the delivery graph of
// storage nodes and delivery legs. The layer shares the world's coordinate
// system, so pan and zoom move it for free.
_renderResourceGraph() {
if (!this.$resources || !this.$resources.length) return;
const migration = this.mapMode === "population";
const graph = migration ? this.migrationGraph : this.resourceGraph;
if (!this.economic || graph.links.length === 0) {
if (this.$resources[0].firstChild) this.$resources.empty();
this._renderGraphLegend();
return;
}
if (migration) this._renderMigrationArrows(graph.links);
else this._renderDeliveryLines(graph);
this._renderGraphLegend();
},
// One line per delivery leg and a dot on every storage node.
_renderDeliveryLines(graph) {
const colours = Object.fromEntries(RESOURCES.map((r) => [r.id, r.colour]));
const fragment = document.createDocumentFragment();
for (const link of graph.links) {
const [fromX, fromY, toX, toY, resource] = link;
const a = mapToLocal(fromX, fromY);
const b = mapToLocal(toX, toY);
fragment.appendChild(makeSegment(a, b, colours[resource] || "#ffffff", 3));
}
for (const node of graph.nodes) {
const point = mapToLocal(node[0], node[1]);
const dot = document.createElement("div");
dot.className = "resource-node";
dot.style.left = `${point.x}px`;
dot.style.top = `${point.y}px`;
fragment.appendChild(dot);
}
this.$resources.empty()[0].appendChild(fragment);
},
// One arrow per city pair, its thickness scaled to the people who moved over
// the month so the volume reads at a glance.
_renderMigrationArrows(links) {
let max = 0;
for (const link of links) if (link[4] > max) max = link[4];
const fragment = document.createDocumentFragment();
for (const link of links) {
const [fromX, fromY, toX, toY, people] = link;
const a = mapToLocal(fromX, fromY);
const b = mapToLocal(toX, toY);
const width = MIGRATION_ARROW_MIN +
(MIGRATION_ARROW_MAX - MIGRATION_ARROW_MIN) * (max > 0 ? people / max : 0);
fragment.appendChild(makeArrow(a, b, MIGRATION_COLOUR, width));
}
this.$resources.empty()[0].appendChild(fragment);
},
// The key for the economic overlay's graph. The population mode keys the
// migration arrows; every other mode keys the commodities and storage nodes.
// Shown only with a graph.
_renderGraphLegend() {
const $el = this.$legendGraph;
if (!$el || !$el.length) return;
const migration = this.mapMode === "population";
const graph = migration ? this.migrationGraph : this.resourceGraph;
if (!this.economic || graph.links.length === 0) {
$el.addClass("hidden");
return;
}
$el.removeClass("hidden");
if (this.$legendGraphTitle && this.$legendGraphTitle.length) {
this.$legendGraphTitle.text(migration ? "Migration (last month)" : "Trade routes");
}
const $list = this.$legendResources.empty();
if (migration) {
let max = 0;
for (const link of graph.links) if (link[4] > max) max = link[4];
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-arrow'></span>"))
.append($("<span></span>").text("People between cities"))
);
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-note'></span>"))
.append($("<span></span>").text(`Busiest route: ${compact(max)}`))
);
return;
}
const present = new Set(graph.links.map((link) => link[4]));
for (const resource of RESOURCES) {
if (!present.has(resource.id)) continue;
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-swatch'></span>").css("background", resource.colour))
.append($("<span></span>").text(resource.name))
);
}
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-node'></span>"))
.append($("<span></span>").text("Storage node"))
);
},
// 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. Returns whether
// either flag actually changed, so callers can skip a repaint otherwise.
_syncModeState() {
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() {
if (!this._syncModeState()) return;
this._repaintMode();
},
// 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) {
// The fog only needs the rows on screen, so leave it to the frame loop's
// coalesced rebuild instead of building the whole mesh here. Clearing the
// cover makes the next flush treat it as an escape, so it runs at once.
this._fogDirty = true;
this._fogBuiltAt = 0;
this._fogCover = undefined;
this._fogChanged = null;
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 && this.topology) {
this.glRenderer.buildPolitical(this);
this.glRenderer.buildEconomic(this);
this.glRenderer.buildBorders(this);
} else if (!this.glRenderer && this.topology) {
this._rebuildDomTerrain();
}
if (this.political) this._renderPoliticalLabels();
else if (this.$politicalLabels) this.$politicalLabels.empty();
this._renderResourceGraph();
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(this._economicMidpoint(min, max, meta.scale)));
$legend.find("#map-legend-max").text(meta.format(max));
$legend.find("#map-legend-bar")
.css("background", `linear-gradient(to right, ${ECONOMIC_GRADIENT.join(", ")})`);
},
// The value whose scaled position is the midpoint of the ramp, so a
// logarithmic legend still labels the middle colour honestly.
_economicMidpoint(min, max, scale) {
if (scale && max > min) {
const mid = (scale(min) + scale(max)) / 2;
return Math.expm1(mid);
}
return (min + max) / 2;
},
// 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,
ECONOMIC_GRADIENT, this.economicScale
);
}
div.style.backgroundImage = "none";
div.style.backgroundColor = colour;
const maskPosition = `${-tile.col * HEX_W}px 0`;
div.style.maskPosition = maskPosition;
div.style.webkitMaskPosition = maskPosition;
},
};