- Political map country names were invisible because the label SVG had a zero-sized viewport, which some browsers never paint; size it to the world and guard it with a test. - Added a Devlog button showing the ten latest commits, generated into client/js/devlog_data.js by scripts/generate-devlog.js and refreshed by the pre-commit hook. - Worked through FIXME.md: the city button and a unit double-click now open the city, and the stat tooltip escapes the HUD so the unit panel cannot cover it. - The server creates its game at startup and clients only join it; the Create game screen and the configure_game path are gone, and testing is a --testing server flag. - A page on a loopback host signs in as adrien, claims France and gets the free, instant testing buttons, while the server grants free orders only to a loopback socket whose Origin is also loopback.
328 lines
13 KiB
JavaScript
328 lines
13 KiB
JavaScript
// IMPORTANT: a human may modify these values. They should not affect how tests
|
|
// behave.
|
|
// 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 = 5;
|
|
|
|
// 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");
|
|
// An outer SVG clips to its viewport, and a zero-sized one is not painted
|
|
// at all in some browsers, so the viewport is given the world's size (the
|
|
// labels' coordinate space). `overflow: visible` still shows the wrapped
|
|
// copies that fall outside it.
|
|
svg.setAttribute("width", String(this._worldWidth || 1));
|
|
svg.setAttribute("height", String(this._worldHeight || 1));
|
|
// 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;
|
|
},
|
|
};
|