Added textured terrain, procedural water and a blurred fog of war

This commit is contained in:
2026-09-19 17:16:41 +02:00
parent 3b4e19b451
commit 08f1b2d154
28 changed files with 2358 additions and 377 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+33 -16
View File
@@ -163,8 +163,7 @@ input:focus, select:focus { border-color: var(--accent); }
}
/* When WebGL owns the map, the DOM geometry layers are left empty and hidden;
the icon layers (paths, targets, entities, labels) still render. */
#map-world.gl-render .detail-layer,
#map-world.gl-render #layer-overview { display: none; }
#map-world.gl-render .detail-layer { display: none; }
/* Promote the map to its own layer only while panning, so a drag is a GPU
translation instead of a repaint of every hex. Keeping it off when idle
avoids holding a huge composited layer in memory. */
@@ -174,13 +173,6 @@ input:focus, select:focus { border-color: var(--accent); }
moved with `transform`; its tiles are positioned relative to it. */
.chunk { position: absolute; left: 0; top: 0; }
/* Zoomed-out level of detail: the detailed hex/fog/border layers are hidden and
a single simplified canvas is shown instead. */
#layer-overview { display: none; }
#map-world.lod #layer-overview { display: block; }
#map-world.lod .detail-layer { display: none; }
.overview-block { position: absolute; }
.hex {
position: absolute;
width: var(--hex-w);
@@ -191,13 +183,27 @@ input:focus, select:focus { border-color: var(--accent); }
background-repeat: no-repeat;
}
.hex.terrain {
background-image: url("../assets/terrain.png");
background-size: 441px 56px;
/* Tiling land textures are painted per tile by terrain.js; the atlas only
supplies the hexagonal alpha mask here. */
background-repeat: repeat;
-webkit-mask-image: url("../assets/terrain.png");
-webkit-mask-size: 441px 56px;
-webkit-mask-repeat: no-repeat;
mask-image: url("../assets/terrain.png");
mask-size: 441px 56px;
mask-repeat: no-repeat;
}
.hex.fog {
pointer-events: none;
background-size: var(--hex-w) var(--hex-h);
}
/* The DOM fallback cannot blend fog per vertex, so blur the whole fog layer:
its edge against the visible map fades instead of stepping at the hex line.
The WebGL path hides the layer entirely and does the blur in the shader. */
#layer-fog {
filter: blur(3.5px);
-webkit-filter: blur(3.5px);
}
.fog-unexplored {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='63' height='56'%3E%3Cpolygon points='63,28 47.25,0 15.75,0 0,28 15.75,56 47.25,56' fill='%2304040a'/%3E%3C/svg%3E");
}
@@ -255,15 +261,24 @@ input:focus, select:focus { border-color: var(--accent); }
box-shadow: 0 0 6px rgba(255, 43, 43, 0.85);
}
/* Outlines the selected or inspected tile. The polygon is built in JS from the
same warped hexagon as the terrain, so it follows the geography. */
.tile-selection {
position: absolute;
width: var(--hex-w);
height: var(--hex-h);
clip-path: polygon(100% 50%, 75% 0, 25% 0, 0 50%, 25% 100%, 75% 100%);
border: none;
box-shadow: inset 0 0 0 3px rgba(255, 255, 255, 0.85);
display: none;
pointer-events: none;
}
.tile-selection.active { display: block; }
.tile-selection svg { display: block; width: 100%; height: 100%; overflow: visible; }
.tile-selection polygon {
fill: rgba(255, 255, 255, 0.12);
stroke: rgba(255, 255, 255, 0.9);
stroke-width: 3;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.entity { position: absolute; }
.unit {
@@ -391,8 +406,10 @@ input:focus, select:focus { border-color: var(--accent); }
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.7);
}
/* City names live in their own layer above every unit, marker and status icon,
so they stay legible instead of being covered by whatever is nearby. */
.city-label-layer { pointer-events: none; }
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. */
.city-label-layer { pointer-events: none; z-index: 5; }
.city-label {
position: absolute;
transform: translate(-50%, -100%);
+1 -1
View File
@@ -100,11 +100,11 @@
<section id="screen-game" class="screen">
<div id="map-viewport">
<div id="map-world">
<div class="layer" id="layer-overview"></div>
<div class="layer" id="layer-terrain"></div>
<div class="layer" id="layer-roads"></div>
<div class="layer" id="layer-borders"></div>
<div class="layer" id="layer-fog"></div>
<div class="layer" id="layer-highlight"></div>
<div class="layer" id="layer-paths"></div>
<div class="layer" id="layer-targets"></div>
<div class="layer" id="layer-entities"></div>
+2 -2
View File
@@ -20,11 +20,11 @@ import { MapView } from "./map_view.js";
const MARKUP = `
<div id="map-viewport">
<div id="map-world">
<div class="layer" id="layer-overview"></div>
<div class="layer" id="layer-terrain"></div>
<div class="layer" id="layer-roads"></div>
<div class="layer" id="layer-borders"></div>
<div class="layer" id="layer-fog"></div>
<div class="layer" id="layer-highlight"></div>
<div class="layer" id="layer-paths"></div>
<div class="layer" id="layer-targets"></div>
<div class="layer" id="layer-entities"></div>
@@ -34,11 +34,11 @@ const MARKUP = `
function mapLayers($) {
return {
overview: $("#layer-overview"),
terrain: $("#layer-terrain"),
roads: $("#layer-roads"),
borders: $("#layer-borders"),
fog: $("#layer-fog"),
highlight: $("#layer-highlight"),
paths: $("#layer-paths"),
targets: $("#layer-targets"),
entities: $("#layer-entities"),
+4 -1
View File
@@ -24,6 +24,8 @@ export class GameScreen {
this.protoUnits = PROTO_UNITS;
this.selectedUnitIds = [];
this.selectedCityId = 0;
// The tile whose info panel is open, if any (right-click inspection).
this._inspectedTile = null;
this._trainingCityId = 0;
this._cityBuildings = new Map();
this._population = new Map();
@@ -48,11 +50,11 @@ export class GameScreen {
$("#map-viewport"),
$("#map-world"),
{
overview: $("#layer-overview"),
terrain: $("#layer-terrain"),
roads: $("#layer-roads"),
borders: $("#layer-borders"),
fog: $("#layer-fog"),
highlight: $("#layer-highlight"),
paths: $("#layer-paths"),
targets: $("#layer-targets"),
entities: $("#layer-entities"),
@@ -153,6 +155,7 @@ export class GameScreen {
this.map.applySnapshot(state);
this.selectedUnitIds = this.selectedUnitIds.filter((id) => this._unit(id));
this.map.setSelectedUnits(this.selectedUnitIds);
this._updateTileHighlight();
this._updateTopPanel(state);
this._updateBottomPanel();
this._updateCityModal();
+30
View File
@@ -118,15 +118,41 @@ export const panelMethods = {
}
this.selectedUnitIds = units.map((unit) => unit.id);
this.selectedCityId = 0;
if (units.length > 0) this._inspectedTile = null;
this.map.setSelectedUnits(this.selectedUnitIds);
this._hideStackMenu();
this._showUnits(units);
this._updateTileHighlight();
},
_selectedUnits() {
return this.selectedUnitIds.map((id) => this._unit(id)).filter(Boolean);
},
// Outlines the tile the current selection or inspection sits on: the first
// selected unit's tile, else the selected city's, else the inspected tile.
_updateTileHighlight() {
if (this.selectedUnitIds.length > 0) {
const unit = this._unit(this.selectedUnitIds[0]);
if (unit) {
this.map.setSelectedTile({ x: unit.coords[0], y: unit.coords[1] });
return;
}
}
if (this.selectedCityId) {
const city = this._city(this.selectedCityId);
if (city) {
this.map.setSelectedTile({ x: city.coords[0], y: city.coords[1] });
return;
}
}
if (this._inspectedTile) {
this.map.setSelectedTile(this._inspectedTile);
return;
}
this.map.clearSelectedTile();
},
_openStackMenu(coords, ids, clientX, clientY) {
const units = [];
for (const id of ids) {
@@ -550,11 +576,13 @@ export const panelMethods = {
this._refreshBottomVisibility();
return;
}
this._inspectedTile = null;
this._renderTileInfo({ x: city.coords[0], y: city.coords[1] });
$("#city-name").text(city.name);
$("#unit-info").addClass("hidden");
$("#tile-info, #city-info").removeClass("hidden");
this._refreshBottomVisibility();
this._updateTileHighlight();
},
_showTile(coords) {
@@ -562,12 +590,14 @@ export const panelMethods = {
// snapshot would immediately switch the panel back to the old selection.
this.selectedCityId = 0;
this.selectedUnitIds = [];
this._inspectedTile = { x: coords.x, y: coords.y };
this.map.setSelectedUnits([]);
this._hideStackMenu();
this._renderTileInfo(coords);
$("#city-info, #unit-info").addClass("hidden");
$("#tile-info").removeClass("hidden");
this._refreshBottomVisibility();
this._updateTileHighlight();
},
// Fills the shared tile fields without touching which section is visible.
+21 -12
View File
@@ -27,6 +27,16 @@ export class MapView {
: $("#layer-roads");
this.$borders = layers.borders;
this.$fog = layers.fog;
// The outline drawn over the selected or inspected tile. It sits above the
// terrain and fog but below the unit, target and city icons.
this.$highlight = layers.highlight && layers.highlight.length
? layers.highlight
: $("#layer-highlight");
if (!this.$highlight.length) {
this.$highlight = $('<div id="layer-highlight" class="layer"></div>');
if (this.$fog && this.$fog.length) this.$fog.after(this.$highlight);
else this.$world.append(this.$highlight);
}
this.$paths = layers.paths;
// Bombardment target lines and rings, drawn over the terrain and paths but
// under the unit and city icons.
@@ -34,15 +44,6 @@ export class MapView {
? layers.targets
: $("#layer-targets");
this.$entities = layers.entities;
// The simplified map used when zoomed out. It is created on demand so
// callers (and tests) that predate it keep working unchanged.
this.$overview = layers.overview && layers.overview.length
? layers.overview
: $("#layer-overview");
if (!this.$overview.length) {
this.$overview = $('<div id="layer-overview" class="layer overview"></div>');
this.$world.prepend(this.$overview);
}
// City names live on their own layer above the entities so they are never
// hidden behind a unit or city marker.
this.$labels = layers.labels && layers.labels.length
@@ -64,6 +65,8 @@ export class MapView {
this.explored = new Set();
this.visible = new Set();
this.territory = new Map();
// Each land tile's region, keyed "x,y" and mapped to the region's city id.
this.regions = new Map();
// Pre-generated road tiles, keyed "x,y".
this.roads = new Set();
this.civilisations = [];
@@ -76,6 +79,11 @@ export class MapView {
this._centered = false;
this._selectedUnitIds = new Set();
this._selectedTile = null;
// Region (city id) of the selected/inspected tile, highlighted as a whole.
this._selectedRegion = null;
// Lazily built SVG outline for the selected/inspected tile.
this._highlightEl = null;
this._highlightPoly = null;
this._unitViews = new Map();
this._unitMotion = new Map();
@@ -88,6 +96,7 @@ export class MapView {
this._maxStepLength = 1.0;
this._territorySignature = "";
this._regionsSignature = null;
this._roadsSignature = null;
this._exploredCount = -1;
this._visibleSignature = null;
@@ -95,6 +104,7 @@ export class MapView {
// Cylindrical wrap state. `_period` is the horizontal pixel period (0 on a
// flat map); every entity is shifted to the copy nearest the camera.
this._period = 0;
this._textureRepeat = 0;
this._worldWidth = 0;
this._worldHeight = 0;
this._worldMinX = 0;
@@ -115,7 +125,6 @@ export class MapView {
this._borderPool = [];
this._roadPool = [];
this._wrapperPool = [];
this.lod = false;
this.onUnitSelected = () => {};
this.onCitySelected = () => {};
@@ -125,8 +134,8 @@ export class MapView {
this.onScheduleOrdered = () => {};
this.onStackMenu = () => {};
// The map (terrain, roads, borders, fog, overview) is drawn on a WebGL
// canvas when the browser provides a context; the icon layers stay DOM.
// The map (terrain, roads, borders, fog) is drawn on a WebGL canvas when
// the browser provides a context; the icon layers stay DOM.
// "dom" forces the classic renderer (tests, benchmark, troubleshooting).
this.glRenderer = null;
this.glCanvas = null;
-19
View File
@@ -12,11 +12,6 @@ export const ZOOM_MAX = 2.0;
export const BATTLE_SIDE_OFFSET = 15;
export const BATTLE_STACK_STEP = 9;
// The zoomed-out overview groups this many tiles per side into one block, so a
// full world is a few hundred flat elements instead of tens of thousands of
// textured ones.
export const OVERVIEW_BLOCK = 4;
// The detailed view is built from square blocks of tiles. Only the blocks that
// intersect the viewport (plus CHUNK_MARGIN blocks of slack on every side) are
// materialised, so panning costs the same no matter how big the map is. Tiles
@@ -27,17 +22,3 @@ export const CHUNK_MARGIN = 1;
// already visited reattaches them instead of rebuilding hundreds of tiles. The
// cap bounds how many detached tile nodes can be held at once.
export const CHUNK_CACHE_LIMIT = 160;
// Flat colours used by the simplified overview, keyed by terrain type. The
// full-resolution view keeps the textured hexes; this is only the LOD stand-in.
export const OVERVIEW_COLOURS = {
Forest: "#2f6b3a",
Hills: "#7a6a44",
Mountain: "#7d7469",
Desert: "#c2b280",
Ice: "#dcecf5",
Land: "#4f8a4f",
Sea: "#1b3f6b",
};
export const OVERVIEW_SEA = "#1b3f6b";
export const OVERVIEW_FOG = "#04040a";
+218
View File
@@ -0,0 +1,218 @@
// Seed-driven, purely cosmetic distortion of the hex grid.
//
// The server's grid must stay perfectly regular: movement, range and
// pathfinding all use the ideal hex centres. This module warps only the
// *drawing* of the terrain, so the world looks a little less mechanical while
// the simulation is untouched. The warp is a pure function of the world seed,
// so every client that joins the same game sees exactly the same shape.
//
// A hexagon vertex is shared by three tiles, so its offset is derived from the
// vertex's position on the lattice rather than from the tile: shared corners
// move by exactly the same amount and the mesh stays watertight (no cracks
// between neighbouring hexes). The lattice is integral in units of HEX_W/4
// horizontally and HEX_H/2 vertically, which are exactly representable, so the
// rounded coordinates are identical for every tile that touches a vertex.
//
// Each edge is then split into EDGE_SEGMENTS pieces and bent by a perpendicular
// wobble that vanishes at both corners, so the outline reads as wavy while the
// corners still meet exactly. Edges are canonicalised by their two lattice
// endpoints, so the two tiles that share an edge bend it identically.
//
// On a cylindrical map the world repeats every `3 * width` lattice columns, so
// folding the horizontal coordinate by that period keeps the wrap seamless.
import { HEX_W, HEX_H } from "../../../shared/hex.js";
// Lattice unit lengths: a vertex is always a whole number of these from the
// tile centre (63/4 and 56/2 are exact binary fractions).
const UNIT_X = HEX_W / 4;
const UNIT_Y = HEX_H / 2;
// How far a vertex may wander, how far the midpoint of an edge may bow, and how
// far a road may drift inside a tile, all in world pixels. The vertex wander
// stays well under half the shortest hexagon edge (~31 px) so edges never fold.
export const DISTORTION = { x: 7, y: 6, wobble: 3.5, road: 3 };
// Every hexagon edge is split into this many wobbled pieces. More pieces make
// the bowed edge read as a smooth curve rather than a jagged run of facets.
export const EDGE_SEGMENTS = 4;
// The six corners of a flat-top hexagon, relative to its centre, in the order
// the rest of the view uses (right, then counter-clockwise).
export const CORNERS = [
{ x: HEX_W / 2, y: 0 },
{ x: HEX_W / 4, y: -HEX_H / 2 },
{ x: -HEX_W / 4, y: -HEX_H / 2 },
{ x: -HEX_W / 2, y: 0 },
{ x: -HEX_W / 4, y: HEX_H / 2 },
{ x: HEX_W / 4, y: HEX_H / 2 },
];
// How many horizontal lattice units one wrap period spans, or 0 on a flat map.
export function periodUnits(topology) {
if (!topology || !topology.cylindrical || !topology.width) return 0;
return topology.width * 3;
}
// A small integer avalanche hash; the lattice coordinates may be negative.
function hash32(value) {
let h = value | 0;
h = Math.imul(h ^ (h >>> 16), 0x7feb352d);
h = Math.imul(h ^ (h >>> 15), 0x846ca68b);
return (h ^ (h >>> 16)) >>> 0;
}
function vertexHash(seed, salt, xi, yi) {
let h = (seed | 0) ^ Math.imul(salt | 0, 0x9e3779b1);
h = Math.imul(h ^ (xi | 0), 0x85ebca6b);
h = Math.imul(h ^ (yi | 0), 0xc2b2ae35);
return hash32(h);
}
function edgeHash(seed, a, b, index) {
let h = (seed | 0) ^ Math.imul(index | 0, 0x165667b1);
h = Math.imul(h ^ (a.xi | 0), 0x9e3779b1);
h = Math.imul(h ^ (a.yi | 0), 0x85ebca6b);
h = Math.imul(h ^ (b.xi | 0), 0xc2b2ae35);
h = Math.imul(h ^ (b.yi | 0), 0x27d4eb2d);
return hash32(h);
}
// Signed [-1, 1) value of an edge/bend hash.
function signedEdgeHash(seed, a, b, index) {
return edgeHash(seed, a, b, index) / 4294967296 * 2 - 1;
}
// A sine that is exactly zero at t=0 and t=1 (sin(PI) is only ~1e-16, which
// an unnormalised hash would turn into a visible crack at the corners).
function envelope(t) {
return t <= 0 || t >= 1 ? 0 : Math.sin(Math.PI * t);
}
function latticeOf(x, y) {
return { xi: Math.round(x / UNIT_X), yi: Math.round(y / UNIT_Y) };
}
// Stable integer key of the lattice vertex nearest a world point. Two tiles that
// share a corner produce exactly the same key, which lets callers stitch their
// borders into one continuous run without floating-point comparison.
export function latticeKey(x, y) {
return `${Math.round(x / UNIT_X)},${Math.round(y / UNIT_Y)}`;
}
function latticeBefore(a, b) {
return a.xi !== b.xi ? a.xi < b.xi : a.yi < b.yi;
}
// Deterministic offset of one lattice vertex, in world pixels.
export function vertexOffset(seed, xi, yi, period) {
let vx = xi | 0;
if (period > 0) vx = ((vx % period) + period) % period;
const nx = vertexHash(seed, 0x1b873593, vx, yi) / 4294967296;
const ny = vertexHash(seed, 0xcc9e2d51, vx, yi) / 4294967296;
return {
x: (nx * 2 - 1) * DISTORTION.x,
y: (ny * 2 - 1) * DISTORTION.y,
};
}
// Displaces an ideal lattice vertex at world (x, y).
export function distortPoint(seed, x, y, period) {
const offset = vertexOffset(seed, Math.round(x / UNIT_X), Math.round(y / UNIT_Y), period);
return { x: x + offset.x, y: y + offset.y };
}
// The six displaced corners of the tile centred at `centre`, in CORNERS order.
export function distortedCorners(seed, centre, period) {
const points = [];
for (const corner of CORNERS) {
points.push(distortPoint(seed, centre.x + corner.x, centre.y + corner.y, period));
}
return points;
}
// A point at parameter t (0..1) along the jagged edge between ideal lattice
// vertices a and b. The edge is canonicalised so both tiles that share it draw
// the same zig-zag, and the perpendicular offset vanishes at t=0 and t=1 so the
// edge still meets its neighbours' corners exactly.
export function edgePoint(seed, a, b, t, period) {
let ia = latticeOf(a.x, a.y);
let ib = latticeOf(b.x, b.y);
// Fold the wrapped axis before ordering and hashing, so the two copies of a
// seam edge are canonicalised and jagged identically.
if (period > 0) {
ia.xi = ((ia.xi % period) + period) % period;
ib.xi = ((ib.xi % period) + period) % period;
}
let p0 = a;
let p1 = b;
let param = t;
if (latticeBefore(ib, ia)) {
const swap = ia;
ia = ib;
ib = swap;
p0 = b;
p1 = a;
param = 1 - t;
}
const o0 = vertexOffset(seed, ia.xi, ia.yi, period);
const o1 = vertexOffset(seed, ib.xi, ib.yi, period);
const ax = p0.x + o0.x;
const ay = p0.y + o0.y;
const bx = p1.x + o1.x;
const by = p1.y + o1.y;
const x = ax + (bx - ax) * param;
const y = ay + (by - ay) * param;
const dx = bx - ax;
const dy = by - ay;
const length = Math.hypot(dx, dy) || 1;
// An independent offset at every sampled point, so each segment kicks a
// different way: a jagged saw-tooth rather than a flowing curve. The offset
// still vanishes exactly at both corners.
const wobble =
signedEdgeHash(seed, ia, ib, Math.round(param * 4096)) *
DISTORTION.wobble *
envelope(param);
return { x: x - (dy / length) * wobble, y: y + (dx / length) * wobble };
}
// The closed wavy boundary of the tile centred at `centre`. Edge i occupies
// entries [i*segments, (i+1)*segments); entries also carry the ideal position
// and the parameter t so callers can derive texture UVs and blend weights.
export function distortedBoundary(seed, centre, period, segments = EDGE_SEGMENTS) {
const points = [];
for (let i = 0; i < CORNERS.length; i++) {
const c0 = CORNERS[i];
const c1 = CORNERS[(i + 1) % CORNERS.length];
const a = { x: centre.x + c0.x, y: centre.y + c0.y };
const b = { x: centre.x + c1.x, y: centre.y + c1.y };
for (let j = 0; j < segments; j++) {
const t = j / segments;
const p = edgePoint(seed, a, b, t, period);
points.push({
x: p.x,
y: p.y,
idealX: a.x + (b.x - a.x) * t,
idealY: a.y + (b.y - a.y) * t,
t,
});
}
}
return points;
}
// A gently bent interior path from `from` to `to` at parameter t. The wobble is
// zero at both ends, so a road leaves a tile centre and reaches the border
// exactly where its neighbouring tile's half does.
export function bentPath(seed, from, to, salt, t) {
const x = from.x + (to.x - from.x) * t;
const y = from.y + (to.y - from.y) * t;
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy) || 1;
const amount =
signedEdgeHash(seed, latticeOf(from.x, from.y), latticeOf(to.x, to.y), salt) *
DISTORTION.road *
envelope(t);
return { x: x - (dy / length) * amount, y: y + (dx / length) * amount };
}
+12 -2
View File
@@ -17,6 +17,7 @@ export const entityMethods = {
this.setRoads(state.roads || []);
this.localCiv = state.viewer;
this._applyTerritory(state.territory);
this._applyRegions(state.regions || []);
this._applyKnowledge(state.explored, state.visible);
this._applyBattles(state.battles);
this._syncCities(state.cities);
@@ -68,7 +69,17 @@ export const entityMethods = {
this._renderBorders();
if (this.glRenderer) this.glRenderer.buildBorders(this);
this._drawOverview();
},
// Mirrors each land tile's region (the city that claims it). The region of a
// selected or inspected tile is drawn as a whole, so this only has to refresh
// the overlay; nothing is drawn for an unselected map.
_applyRegions(entries) {
const signature = hashEntries(entries);
if (signature === this._regionsSignature) return;
this._regionsSignature = signature;
this.regions = new Map(entries.map((entry) => [key(entry[0], entry[1]), entry[2]]));
if (this.glRenderer) this.glRenderer.buildRegionOverlay(this);
},
// Draws a thick tinted segment along every edge shared with a different owner
@@ -155,7 +166,6 @@ export const entityMethods = {
this.visible = new Set(visibleEntries.map((e) => key(e[0], e[1])));
this._refreshFog();
this._drawOverview();
},
_syncCities(cities) {
+79
View File
@@ -0,0 +1,79 @@
// Fog-of-war levels and their blur across the visibility frontier.
//
// Every tile maps to a fog colour and opacity: nothing over currently visible
// ground, a soft veil over explored ground and an opaque shroud over unexplored
// ground. Drawing those values per tile would leave a hard hex edge wherever
// visibility changes, so each *vertex* takes the distance-weighted average of
// the tiles around it. The frontier then ramps over about one tile and the
// explored/unexplored colours mix into each other instead of stepping.
//
// The kernel mirrors the land-texture blend, so a shared hex vertex produces
// the same value whichever of the two tiles evaluates it.
import { HEX_H } from "../../../shared/hex.js";
export const FOG_EXPLORED_RGB = [4 / 255, 6 / 255, 18 / 255];
export const FOG_UNEXPLORED_RGB = [4 / 255, 4 / 255, 10 / 255];
export const FOG_EXPLORED_OPACITY = 0.62;
export const FOG_UNEXPLORED_OPACITY = 1.0;
// Kernel radius. It reaches just past the shared edge (a neighbour's centre is
// about HEX_H away), so the frontier feathers over roughly a third of a tile
// while the fog stays at full strength either side of it. A wider kernel washed
// the fog out across the whole frontier.
const BLEND_RADIUS = HEX_H * 0.72;
function falloff(distance) {
if (distance >= BLEND_RADIUS) return 0;
const t = 1 - distance / BLEND_RADIUS;
return t * t;
}
// The fog colour and opacity of a single tile.
export function fogLevel(visible, explored) {
if (visible) return { rgb: FOG_EXPLORED_RGB, opacity: 0 };
if (explored) return { rgb: FOG_EXPLORED_RGB, opacity: FOG_EXPLORED_OPACITY };
return { rgb: FOG_UNEXPLORED_RGB, opacity: FOG_UNEXPLORED_OPACITY };
}
// The tile has fog of its own; a fully visible tile only needs geometry when a
// neighbour is fogged, which the ring check covers.
export function isFogged(level) {
return !!level && level.opacity > 0;
}
// Blurs the fog of the tiles around a world point.
//
// `samples` is a list of { level, dx, dy } with the offset from the point to
// the tile centre; levels that are missing (off-map neighbours) are ignored.
// Returns { rgb, opacity } where opacity is the faded alpha and rgb the
// opacity-weighted colour of the fog around the point.
export function blurFog(samples) {
let total = 0;
let opacity = 0;
let r = 0;
let g = 0;
let b = 0;
let colourWeight = 0;
for (const sample of samples) {
if (!sample.level) continue;
const weight = falloff(Math.hypot(sample.dx, sample.dy));
if (weight <= 0) continue;
total += weight;
opacity += weight * sample.level.opacity;
if (sample.level.opacity > 0) {
const w = weight * sample.level.opacity;
r += sample.level.rgb[0] * w;
g += sample.level.rgb[1] * w;
b += sample.level.rgb[2] * w;
colourWeight += w;
}
}
if (total <= 0 || colourWeight <= 0) {
return { rgb: FOG_UNEXPLORED_RGB, opacity: 0 };
}
return {
rgb: [r / colourWeight, g / colourWeight, b / colourWeight],
opacity: opacity / total,
};
}
+62 -30
View File
@@ -3,7 +3,7 @@
import { mapToLocal, key, parity, COL_STEP, HEX_W, HEX_H } from "../../../shared/hex.js";
import { DRAG_THRESHOLD, ZOOM_MIN, ZOOM_MAX } from "./constants.js";
import { clamp } from "./utils.js";
import { CORNERS, distortedBoundary, periodUnits } from "./distortion.js";
export const inputMethods = {
// ------------------------------------------------------------- camera --
@@ -32,43 +32,15 @@ export const inputMethods = {
_applyCamera(applyWrap = true) {
const width = this.$viewport.width();
const height = this.$viewport.height();
this._updateLod(width, height);
if (this.lod) {
// With the whole world on screen there is nothing to scroll to; keep the
// camera over the map so the overview never drifts into the void.
this.camera.x = clamp(this.camera.x, this._worldMinX, this._worldMinX + this._worldWidth);
this.camera.y = clamp(this.camera.y, this._worldMinY, this._worldMinY + this._worldHeight);
}
const { x, y, zoom } = this.camera;
const tx = width / 2 - x * zoom;
const ty = height / 2 - y * zoom;
this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`);
if (applyWrap) this._syncChunks();
if (this._selectedTile) this._positionHighlight();
this._glRender();
},
// Switches between the textured hex view and the simplified overview. The
// detailed view only makes sense while the visible world stays within one
// horizontal period (so wrapping is seamless); past that -- when the player
// is zoomed out to see the whole map -- the coarse overview takes over.
_updateLod(width, height) {
let detailedWidth = this._worldWidth;
if (this._period > 0) detailedWidth = Math.min(detailedWidth, this._period) - HEX_W;
const detailed = detailedWidth > 0 &&
width / this.camera.zoom <= detailedWidth &&
height / this.camera.zoom <= this._worldHeight + HEX_H;
const lod = !detailed;
if (lod === this.lod) return;
this.lod = lod;
this.$world.toggleClass("lod", lod);
if (lod) {
this._clearChunks();
this._drawOverview(true);
} else {
this._chunkDirty = true;
}
},
_setupCameraInput() {
let dragging = false;
let dragged = false;
@@ -220,6 +192,66 @@ export const inputMethods = {
this.onUnitSelected(0);
},
// ---------------------------------------------------- tile highlight --
// Outlines the tile under the current selection or inspection. The outline
// follows the same warped hexagon the terrain draws, so it sits exactly on
// the distorted tile; on the DOM renderer (no warp) it is a plain hexagon.
setSelectedTile(coords) {
this._selectedTile = coords ? { x: coords.x, y: coords.y } : null;
this._selectedRegion = this._selectedTile && this.regions
? (this.regions.get(key(this._selectedTile.x, this._selectedTile.y)) ?? null)
: null;
if (!this._selectedTile || !this.topology) {
if (this._highlightEl) this._highlightEl.classList.remove("active");
if (this.glRenderer) this.glRenderer.buildRegionOverlay(this);
return;
}
this._ensureHighlight();
const local = mapToLocal(this._selectedTile.x, this._selectedTile.y);
const outline = this.glRenderer
? distortedBoundary(this.seed | 0, local, periodUnits(this.topology))
: CORNERS.map((corner) => ({ x: local.x + corner.x, y: local.y + corner.y }));
const points = outline
.map((corner) =>
`${(corner.x - local.x + HEX_W / 2).toFixed(2)},` +
`${(corner.y - local.y + HEX_H / 2).toFixed(2)}`)
.join(" ");
this._highlightPoly.setAttribute("points", points);
this._highlightEl.classList.add("active");
this._positionHighlight();
if (this.glRenderer) this.glRenderer.buildRegionOverlay(this);
},
clearSelectedTile() {
this.setSelectedTile(null);
},
_ensureHighlight() {
if (this._highlightEl) return;
const ns = "http://www.w3.org/2000/svg";
const el = document.createElement("div");
el.className = "tile-selection";
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("viewBox", `0 0 ${HEX_W} ${HEX_H}`);
svg.setAttribute("preserveAspectRatio", "none");
const poly = document.createElementNS(ns, "polygon");
svg.appendChild(poly);
el.appendChild(svg);
this.$highlight[0].appendChild(el);
this._highlightEl = el;
this._highlightPoly = poly;
},
// Keeps the (already measured) outline on the wrapped copy nearest the camera.
_positionHighlight() {
if (!this._highlightEl || !this._selectedTile) return;
const local = mapToLocal(this._selectedTile.x, this._selectedTile.y);
const wrapped = this._wrappedPosition(local);
this._highlightEl.style.left = `${wrapped.x - HEX_W / 2}px`;
this._highlightEl.style.top = `${wrapped.y - HEX_H / 2}px`;
},
// ------------------------------------------------------- hit-testing --
unitIdsAt(coords) {
+39 -113
View File
@@ -1,5 +1,4 @@
// Terrain construction, world-space bounds, the coarse overview used at low
// zoom, and the chunked world-space layers.
// Terrain construction, world-space bounds and the chunked world-space layers.
//
// The detailed map is not one element per tile of the whole world. It is built
// from square CHUNK_SIZE x CHUNK_SIZE tile blocks that are materialised only
@@ -14,8 +13,8 @@ import { MapTopology, mapToLocal, key, parseKey, HEX_W, HEX_H, COL_STEP } from "
import { MapGenerator } from "../../../shared/map_generator.js";
import { TerrainStats } from "../../../shared/terrain_stats.js";
import { MAP_CONFIG } from "../../../shared/data.js";
import { OVERVIEW_BLOCK, OVERVIEW_FOG, CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIMIT } from "./constants.js";
import { overviewColour, blendColours } from "./utils.js";
import { CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIMIT } from "./constants.js";
import { terrainLayers, textureRepeat } from "./textures.js";
export const terrainMethods = {
ensureTerrain(seed, mapConfig = null) {
@@ -40,6 +39,8 @@ export const terrainMethods = {
this.terrainStats = new TerrainStats(this.tiles);
this._maxStepLength = this._computeMaxStepLength();
this._period = this.topology.periodPixels();
// World pixels per tiling-texture repeat, snapped to the wrap period.
this._textureRepeat = textureRepeat(this._period);
this._computeWorldBounds();
this._chunkSize = CHUNK_SIZE;
@@ -59,6 +60,7 @@ export const terrainMethods = {
this.$fog.addClass("detail-layer");
this._territorySignature = "";
this._regionsSignature = null;
this._exploredCount = -1;
this._visibleSignature = null;
this._chunkDirty = true;
@@ -74,19 +76,19 @@ export const terrainMethods = {
for (const view of this._battleViews.values()) view.remove();
this._battleViews.clear();
this._battles.clear();
// A new world invalidates the old tile outline; the game screen re-applies
// the current selection once the new snapshot has been read.
this.clearSelectedTile();
this._centered = false;
this._chunkDirty = true;
// A WebGL build reads the whole world out of the view, so hand it the new
// terrain before any of the per-layer update hooks run.
if (this.glRenderer) this.glRenderer.setWorld(this);
// The overview is only built when the player is (or becomes) zoomed out;
// keeping it out of startup leaves the initial view to the visible chunks.
if (this.lod) this._drawOverview(true);
this._syncChunks();
},
// Pixel bounds of the whole world, used to lay out the overview and clamp the
// camera when the simplified map is on screen.
// Pixel bounds of the whole world, used to draw the wrapped copies of the
// terrain in the renderer.
_computeWorldBounds() {
let minX = Infinity;
let maxX = -Infinity;
@@ -251,13 +253,6 @@ export const terrainMethods = {
this._releaseWrapper(rec.borderEl);
},
// Parks every materialised chunk (used when the overview takes over) so
// zooming back in is cheap.
_clearChunks() {
if (!this._chunks || this._chunks.size === 0) return;
for (const [chunkKey, rec] of Array.from(this._chunks)) this._detachChunk(chunkKey, rec);
},
_destroyAllChunks() {
if (this._chunks) {
for (const rec of this._chunks.values()) this._destroyChunk(rec);
@@ -273,7 +268,7 @@ export const terrainMethods = {
// CHUNK_MARGIN blocks) and releases the rest. Cheap enough to run each frame:
// it walks the chunk grid, not the tiles.
_syncChunks() {
if (!this.topology || !this._chunks || this.lod) return;
if (!this.topology || !this._chunks) return;
// With WebGL the map lives in one canvas, not in DOM chunks.
if (this.glRenderer) return;
const width = this.$viewport.width();
@@ -345,13 +340,38 @@ 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`;
div.style.backgroundPosition = `${-tile.col * HEX_W}px 0`;
this._applyTerrainTexture(div, tile, local);
fragment.appendChild(div);
}
}
rec.terrainEl.appendChild(fragment);
},
// Paints one terrain tile in the DOM fallback. The tiling textures are
// positioned from the tile's world origin, so two neighbours continue the
// same pattern; the legacy atlas provides the hexagonal alpha mask. Terrains
// without a tiling texture keep using the atlas art directly.
_applyTerrainTexture(div, tile, local) {
const left = local.x - HEX_W / 2;
const top = local.y - HEX_H / 2;
const repeat = this._textureRepeat || 0;
const layers = terrainLayers(tile.terrainType);
const maskPosition = `${-tile.col * HEX_W}px 0`;
if (layers.length && repeat > 0) {
const position = `${-left}px ${-top}px`;
div.style.backgroundImage = layers.map((src) => `url("${src}")`).join(", ");
div.style.backgroundSize = `${repeat}px ${repeat}px`;
// The overlay (if any) is the top layer and shares the base's position.
div.style.backgroundPosition = layers.map(() => position).join(", ");
} else {
div.style.backgroundImage = 'url("assets/terrain.png")';
div.style.backgroundSize = "441px 56px";
div.style.backgroundPosition = maskPosition;
}
div.style.maskPosition = maskPosition;
div.style.webkitMaskPosition = maskPosition;
},
// Fog is rebuilt per materialised chunk, so a change in visibility only costs
// the few hundred tiles actually on screen instead of the whole map.
_buildFog(rec) {
@@ -477,7 +497,7 @@ export const terrainMethods = {
// [camera - P/2, camera + P/2) keeps neighbouring tiles contiguous, so the
// world tiles seamlessly in both directions.
_wrapShift(centerX) {
if (!this._period || this.lod) return 0;
if (!this._period) return 0;
const period = this._period;
return Math.ceil((this.camera.x - period / 2 - centerX) / period) * period;
},
@@ -486,100 +506,6 @@ export const terrainMethods = {
return { x: point.x + this._wrapShift(point.x), y: point.y };
},
// ---------------------------------------------------------------- LOD --
// Builds the simplified map as a coarse grid: one flat block per
// OVERVIEW_BLOCK x OVERVIEW_BLOCK tiles. At low zoom these few hundred
// elements replace the detailed chunks, so seeing the entire map stays cheap.
_drawOverview(force = false) {
if (this.glRenderer) {
if (force || this.lod) this.glRenderer.buildOverview(this);
return;
}
if (!this.$overview || !this.$overview.length || !this.topology) return;
// While the detailed view is on screen the overview is hidden; keep the
// redraw for the moment the player zooms out rather than paying for it on
// every hourly snapshot.
if (!force && !this.lod) return;
const step = OVERVIEW_BLOCK;
const startX = this.topology.originX;
const startY = this.topology.originY;
const endX = startX + this.topology.width;
const endY = startY + this.topology.height;
const fragment = document.createDocumentFragment();
for (let by = startY; by < endY; by += step) {
for (let bx = startX; bx < endX; bx += step) {
const block = this._overviewBlock(bx, by, step);
if (block) fragment.appendChild(block);
}
}
this.$overview.empty()[0].appendChild(fragment);
},
// The block's geometry and flat colour, independent of how it is drawn. The
// DOM overview turns it into a positioned div; the WebGL renderer turns it
// into a coloured quad. Returns null for a block with no tiles.
_overviewBlockData(bx, by, step) {
const endX = Math.min(bx + step, this.topology.originX + this.topology.width);
const endY = Math.min(by + step, this.topology.originY + this.topology.height);
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
let representative = null;
let land = null;
let owner = -1;
let visible = false;
let explored = false;
for (let y = by; y < endY; y++) {
for (let x = bx; x < endX; x++) {
const k = key(x, y);
const tile = this.tiles[k];
if (!tile) continue;
if (!representative) representative = tile;
if (tile.terrainClass !== "Sea" && !land) land = tile;
const local = mapToLocal(x, y);
minX = Math.min(minX, local.x - HEX_W / 2);
maxX = Math.max(maxX, local.x + HEX_W / 2);
minY = Math.min(minY, local.y - HEX_H / 2);
maxY = Math.max(maxY, local.y + HEX_H / 2);
const tileOwner = this.territory.get(k);
if (tileOwner !== undefined && tileOwner >= 0 && this.civilisations[tileOwner]) {
owner = tileOwner;
}
if (this.visible.has(k)) visible = true;
if (this.explored.has(k)) explored = true;
}
}
if (!representative) return null;
let colour = overviewColour(land || representative);
if (owner >= 0) colour = blendColours(colour, this.civilisations[owner].primaryColour, 0.45);
if (!visible) colour = blendColours(colour, OVERVIEW_FOG, explored ? 0.62 : 1);
return {
minX,
minY,
width: maxX - minX,
height: maxY - minY,
colour,
};
},
_overviewBlock(bx, by, step) {
const data = this._overviewBlockData(bx, by, step);
if (!data) return null;
const div = document.createElement("div");
div.className = "overview-block";
div.style.left = `${data.minX}px`;
div.style.top = `${data.minY}px`;
div.style.width = `${data.width}px`;
div.style.height = `${data.height}px`;
div.style.background = data.colour;
return div;
},
_computeMaxStepLength() {
let longest = 0;
for (const neighbour of this.topology.neighbours(0, 0)) {
+192
View File
@@ -0,0 +1,192 @@
// Texture assignment and blending for the world map.
//
// The land is no longer drawn from the per-tile terrain.png atlas. Every land
// terrain sits on a seamless tiling base (grass, or sand for the desert) and
// forest, hills and mountains composite their own translucent overlay on top.
// Sea is flagged for the procedural water shader and ice keeps the legacy atlas
// art, so neither is textured or blended here.
//
// The tiling textures are addressed in *world* space, not per tile: every
// vertex carries its undistorted world position and the shader divides it by a
// fixed repeat. Two tiles that share an edge therefore sample the same part of
// the same texture, so the grain runs continuously across the wavy, distorted
// hex boundary instead of restarting in every tile. On a cylindrical map the
// repeat is snapped so the world width is a whole number of repeats, which keeps
// the pattern coherent across the wrap seam as well.
//
// Neighbouring land tiles are blended by a distance kernel over the tile
// centres. The weight only depends on the world position, so both tiles on a
// shared edge compute the same mix and the transition has no seam. A wider
// kernel fades the sand texture into non-mountain coasts as a beach.
import { HEX_H } from "../../../shared/hex.js";
export const TEXTURE_DIR = "assets/textures/";
export const TEXTURE_GRASS = "grass";
export const TEXTURE_SAND = "sand";
export const TEXTURE_FOREST = "forest";
export const TEXTURE_HILLS = "hills";
export const TEXTURE_MOUNTAIN = "mountain";
// Tangent-space normal maps that give the hills and mountains their relief.
export const TEXTURE_HILLS_NORMAL = "hills_normal_map";
export const TEXTURE_MOUNTAIN_NORMAL = "mountains_normal_map";
// The overlay layers, in the order the shader composites them.
export const OVERLAY_TEXTURES = [TEXTURE_FOREST, TEXTURE_HILLS, TEXTURE_MOUNTAIN];
// The normal map that lights an overlay, or null when it has none.
export function overlayNormal(overlay) {
if (overlay === TEXTURE_HILLS) return TEXTURE_HILLS_NORMAL;
if (overlay === TEXTURE_MOUNTAIN) return TEXTURE_MOUNTAIN_NORMAL;
return null;
}
// The tiling textures are 128x128, so one repeat every 128 world pixels keeps
// them at roughly native density at zoom 1.
export const TEXTURE_DESIRED_REPEAT = 128;
// Which terrains use a tiling base texture and which overlay, if any, sits on
// top of it. A terrain missing from this table is drawn from the terrain.png
// atlas.
const LAND_TEXTURES = {
Forest: { base: TEXTURE_GRASS, overlay: TEXTURE_FOREST },
Hills: { base: TEXTURE_GRASS, overlay: TEXTURE_HILLS },
Land: { base: TEXTURE_GRASS },
Desert: { base: TEXTURE_SAND },
Mountain: { base: TEXTURE_GRASS, overlay: TEXTURE_MOUNTAIN },
};
// Blend radius of the land kernel. A neighbour's centre is one edge-width away
// (about HEX_H), so a shared edge lands at an even 50/50 while a tile centre
// stays almost entirely its own texture.
const BLEND_RADIUS = HEX_H;
// Beaches reach a little further than the ordinary land blend, so the sand
// spreads over most of the coastal tile instead of clinging to the shoreline,
// and are capped below 1 so a hint of the ground shows through.
const BEACH_RADIUS = HEX_H * 1.35;
const BEACH_STRENGTH = 1.25;
// The base/overlay description for a terrain type, or null for the atlas
// terrains (Sea and Ice).
export function landTexture(terrainType) {
return LAND_TEXTURES[terrainType] || null;
}
export function isTexturedLand(tile) {
return !!(tile && LAND_TEXTURES[tile.terrainType]);
}
// Sea is drawn by the procedural water shader rather than a tiling texture, but
// it still rides in the same vertex weights as a flag, so the renderer knows
// where the water should replace the atlas art.
export function isWaterTile(tile) {
return !!tile && tile.terrainType === "Sea";
}
// World pixels per texture repeat. On a cylindrical world the repeat is snapped
// so that the wrap period is a whole number of repeats; otherwise two copies of
// the same tile would show different texture phases at the seam.
export function textureRepeat(periodPixels) {
if (!periodPixels || periodPixels <= 0) return TEXTURE_DESIRED_REPEAT;
const whole = Math.max(1, Math.round(periodPixels / TEXTURE_DESIRED_REPEAT));
return periodPixels / whole;
}
// The layer list for a terrain type, topmost first: the terrain's overlay (when
// it has one) then its base. Sea and ice return an empty list and keep the
// atlas art.
export function terrainLayers(terrainType) {
const texture = LAND_TEXTURES[terrainType];
if (!texture) return [];
const layers = [`${TEXTURE_DIR}${texture.base}.png`];
if (texture.overlay) layers.unshift(`${TEXTURE_DIR}${texture.overlay}.png`);
return layers;
}
// A smooth (squared) falloff that reaches zero at the blend radius.
function falloff(distance) {
if (distance >= BLEND_RADIUS) return 0;
const t = 1 - distance / BLEND_RADIUS;
return t * t;
}
// The wider falloff used for the coastal sand.
function beachFalloff(distance) {
if (distance >= BEACH_RADIUS) return 0;
const t = 1 - distance / BEACH_RADIUS;
return t * t;
}
// Blends the tiling textures of the land tiles near a world point.
//
// `samples` is a list of { tile, dx, dy }: `tile` is the tile data and (dx, dy)
// is the offset from the point to that tile's centre. The result carries the
// grass/sand base split (summing to 1) and the opacity of each overlay, or null
// when no textured-land tile is close enough. The value depends only on the
// point and its surroundings, never on which tile asked, so shared vertices
// agree.
export function blendTextures(samples) {
let grass = 0;
let sand = 0;
let total = 0;
const overlays = {};
for (const name of OVERLAY_TEXTURES) overlays[name] = 0;
for (const sample of samples) {
const texture = sample.tile && LAND_TEXTURES[sample.tile.terrainType];
if (!texture) continue;
const weight = falloff(Math.hypot(sample.dx, sample.dy));
if (weight <= 0) continue;
total += weight;
if (texture.base === TEXTURE_SAND) sand += weight;
else grass += weight;
if (texture.overlay) overlays[texture.overlay] += weight;
}
if (total <= 0) return null;
const blend = { grass: grass / total, sand: sand / total };
for (const name of OVERLAY_TEXTURES) blend[name] = overlays[name] / total;
return blend;
}
// How much of the sand texture the coast blends into a land tile at a point,
// from 0 inland to about BEACH_STRENGTH/2 on the shoreline. The value depends
// only on the point and its surroundings, so both the land side and its
// neighbouring sea compute the same shoreline.
export function blendBeach(samples) {
let sea = 0;
let total = 0;
for (const sample of samples) {
if (!sample.tile) continue;
const weight = beachFalloff(Math.hypot(sample.dx, sample.dy));
if (weight <= 0) continue;
total += weight;
if (isWaterTile(sample.tile)) sea += weight;
}
if (total <= 0) return 0;
return sea / total;
}
// Per-vertex texture weights, packed for the shader as
// [grass, sand, atlas, forest, hills, mountain, water, beach]. Atlas terrains
// are fully atlas; textured land is fully texture, with the base split, the
// overlay opacities and the coastal sand coming from the neighbouring-tile
// blend. Mountain coasts stay rocky, so they get no beach.
export function vertexTextureWeights(tile, samples) {
const water = isWaterTile(tile) ? 1 : 0;
if (!isTexturedLand(tile)) return [0, 0, 1, 0, 0, 0, water, 0];
const blend = blendTextures(samples);
if (!blend) return [0, 0, 1, 0, 0, 0, water, 0];
const beach = tile.terrainType === "Mountain"
? 0
: Math.min(1, blendBeach(samples) * BEACH_STRENGTH);
return [
blend.grass,
blend.sand,
0,
blend[TEXTURE_FOREST],
blend[TEXTURE_HILLS],
blend[TEXTURE_MOUNTAIN],
water,
beach,
];
}
+1 -18
View File
@@ -3,7 +3,6 @@
// testable.
import { HEX_W, HEX_H } from "../../../shared/hex.js";
import { OVERVIEW_COLOURS, OVERVIEW_SEA } from "./constants.js";
// Order-independent FNV-style hash of an [x, y, ...] entry array, used to skip
// rebuilding a layer when the snapshot did not actually change it.
@@ -117,21 +116,6 @@ export function makeSegment(a, b, colour, width, offset = 0) {
return div;
}
export function overviewColour(tile) {
if (tile.terrainType && OVERVIEW_COLOURS[tile.terrainType]) {
return OVERVIEW_COLOURS[tile.terrainType];
}
return tile.terrainClass === "Sea" ? OVERVIEW_SEA : OVERVIEW_COLOURS.Land;
}
// Mixes two #rrggbb colours, `t` being how much of `b` to use.
export function blendColours(a, b, t) {
const ca = parseColour(a);
const cb = parseColour(b);
const mix = (i) => Math.round(ca[i] + (cb[i] - ca[i]) * t);
return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`;
}
export function parseColour(value) {
if (value.charAt(0) === "#") {
const hex = value.slice(1);
@@ -141,8 +125,7 @@ export function parseColour(value) {
parseInt(hex.slice(4, 6), 16),
];
}
// Tolerates the "rgb(r, g, b)" form blendColours produces, so a blended
// overview colour can be fed straight back in.
// Tolerates an "rgb(r, g, b)" string as well as a #rrggbb one.
const match = value.match(/(\d+)\D+(\d+)\D+(\d+)/);
if (match) return [Number(match[1]), Number(match[2]), Number(match[3])];
return [0, 0, 0];
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
import { TestCase } from "./framework/test_case.js";
import { mapToLocal, MapTopology, COL_STEP } from "../shared/hex.js";
import {
DISTORTION,
EDGE_SEGMENTS,
CORNERS,
periodUnits,
vertexOffset,
distortPoint,
distortedCorners,
distortedBoundary,
edgePoint,
bentPath,
latticeKey,
} from "../client/js/map_view/distortion.js";
export class DistortionTest extends TestCase {
async test_is_deterministic_for_a_seed() {
const first = distortedCorners(1234, { x: 40, y: 40 }, 0);
const second = distortedCorners(1234, { x: 40, y: 40 }, 0);
this.assertEqual(first, second, "the same seed warps the same way");
const other = distortedCorners(5678, { x: 40, y: 40 }, 0);
this.assertNotEqual(first, other, "a different seed gives a different shape");
}
async test_offsets_stay_within_the_amplitude() {
for (let xi = -50; xi <= 50; xi += 7) {
for (let yi = -50; yi <= 50; yi += 5) {
const offset = vertexOffset(99, xi, yi, 0);
this.assertTrue(Math.abs(offset.x) <= DISTORTION.x, `x offset ${offset.x} too large`);
this.assertTrue(Math.abs(offset.y) <= DISTORTION.y, `y offset ${offset.y} too large`);
}
}
}
async test_shared_corners_move_together_so_the_mesh_stays_watertight() {
// Tile (0,0)'s right corner is the same lattice vertex as tile (1,0)'s
// top-left corner: (31.5, 0). If the two tiles displaced it differently the
// map would tear along their shared edge.
const a = distortedCorners(2024, mapToLocal(0, 0), 0);
const b = distortedCorners(2024, mapToLocal(1, 0), 0);
this.assertEqual(a[0], b[2], "the shared corner is displaced identically");
// Same for the lower shared vertex (15.75, 28): (0,0)'s bottom-right and
// (1,0)'s left corner.
this.assertEqual(a[5], b[3], "the second shared corner is displaced identically");
}
async test_cylindrical_wrap_is_seamless() {
const topology = new MapTopology({ x: 40, y: 30 }, true);
const period = periodUnits(topology);
this.assertEqual(period, topology.width * 3, "one period is 3*width lattice columns");
const x = 31.5;
const wrap = COL_STEP * topology.width;
const near = distortPoint(555, x, -28, period);
const wrapped = distortPoint(555, x + wrap, -28, period);
this.assertApprox(wrapped.x - near.x, wrap, 1e-9);
this.assertApprox(wrapped.y, near.y, 1e-9);
this.assertEqual(periodUnits(new MapTopology({ x: 4, y: 4 }, false)), 0, "flat maps do not fold");
}
async test_negative_lattice_coordinates_are_folded_consistently() {
const period = 60;
const wrap = period * (63 / 4);
const x = -10;
const before = distortPoint(12, x, -10, period);
const after = distortPoint(12, x + wrap, -10, period);
this.assertApprox(after.x - before.x, wrap, 1e-9);
this.assertApprox(after.y, before.y, 1e-9);
}
async test_wrapped_edges_are_identical() {
// An edge and its copy one wrap period over are the same border. Both must
// bend the same way, or the map tears along the cylindrical seam.
const topology = new MapTopology({ x: 40, y: 30 }, true);
const period = periodUnits(topology);
const wrap = COL_STEP * topology.width;
const a = { x: 31.5, y: 0 };
const b = { x: 15.75, y: -28 };
for (let j = 0; j <= 8; j++) {
const t = j / 8;
const point = edgePoint(555, a, b, t, period);
const copy = edgePoint(555, { x: a.x + wrap, y: a.y }, { x: b.x + wrap, y: b.y }, t, period);
this.assertApprox(copy.x - point.x, wrap, 1e-9, "seam edge x");
this.assertApprox(copy.y, point.y, 1e-9, "seam edge y");
}
}
async test_edges_are_subdivided_and_meet_their_corners() {
const centre = mapToLocal(3, -2);
const boundary = distortedBoundary(77, centre, 0);
this.assertSize(boundary, CORNERS.length * EDGE_SEGMENTS, "every edge is split");
// Each edge's first point is its starting corner, displaced exactly like
// the corner function, so adjacent edges still join.
for (let i = 0; i < CORNERS.length; i++) {
const point = boundary[i * EDGE_SEGMENTS];
const corner = distortPoint(77, centre.x + CORNERS[i].x, centre.y + CORNERS[i].y, 0);
this.assertApprox(point.x, corner.x, 1e-9, "corner moved");
this.assertApprox(point.y, corner.y, 1e-9, "corner moved");
this.assertApprox(point.idealX, centre.x + CORNERS[i].x, 1e-9, "ideal corner");
this.assertApprox(point.idealY, centre.y + CORNERS[i].y, 1e-9, "ideal corner");
this.assertEqual(point.t, 0);
}
}
async test_wobbled_edge_is_shared_by_both_neighbours() {
// Edge (31.5, 0) -> (15.75, -28) of tile (0,0) is the same border as edge
// (15.75, -28) -> (31.5, 0) of its neighbour (1,-1). Traversed in opposite
// directions, both must produce the identical set of points.
const a0 = { x: 31.5, y: 0 };
const a1 = { x: 15.75, y: -28 };
const fromA = [];
const fromB = [];
for (let j = 0; j <= EDGE_SEGMENTS; j++) {
fromA.push(edgePoint(4242, a0, a1, j / EDGE_SEGMENTS, 0));
fromB.push(edgePoint(4242, a1, a0, j / EDGE_SEGMENTS, 0));
}
const sort = (list) =>
list.slice().sort((p, q) => (p.x !== q.x ? p.x - q.x : p.y - q.y));
const left = sort(fromA);
const right = sort(fromB);
this.assertSize(left, right.length);
for (let i = 0; i < left.length; i++) {
this.assertApprox(left[i].x, right[i].x, 1e-9, "shared border point");
this.assertApprox(left[i].y, right[i].y, 1e-9, "shared border point");
}
}
async test_edge_wobble_vanishes_at_the_corners() {
const a = { x: 31.5, y: 0 };
const b = { x: 15.75, y: -28 };
this.assertEqual(edgePoint(9, a, b, 0, 0), distortPoint(9, a.x, a.y, 0));
this.assertEqual(edgePoint(9, a, b, 1, 0), distortPoint(9, b.x, b.y, 0));
}
async test_shared_corners_share_a_lattice_key() {
// Tile (0,0)'s right corner and tile (1,0)'s top-left corner are the same
// lattice vertex, so border chaining can stitch them with one integer key.
const a = distortedCorners(1, mapToLocal(0, 0), 0);
const b = distortedCorners(1, mapToLocal(1, 0), 0);
this.assertEqual(latticeKey(a[0].x, a[0].y), latticeKey(b[2].x, b[2].y));
this.assertNotEqual(latticeKey(a[0].x, a[0].y), latticeKey(a[1].x, a[1].y));
}
async test_bent_path_still_starts_and_ends_where_asked() {
const from = { x: 0, y: 0 };
const to = { x: 30, y: 12 };
this.assertEqual(bentPath(5, from, to, 1, 0), from);
this.assertEqual(bentPath(5, from, to, 1, 1), to);
const middle = bentPath(5, from, to, 1, 0.5);
this.assertApprox(middle.x, 15, DISTORTION.road, "the bend stays small");
}
}
+89
View File
@@ -0,0 +1,89 @@
import { TestCase } from "./framework/test_case.js";
import { MapTopology, mapToLocal } from "../shared/hex.js";
import { GLMapRenderer } from "../client/js/map_view/webgl.js";
import {
FOG_EXPLORED_OPACITY,
FOG_EXPLORED_RGB,
FOG_UNEXPLORED_OPACITY,
FOG_UNEXPLORED_RGB,
blurFog,
fogLevel,
isFogged,
} from "../client/js/map_view/fog.js";
// Two samples on opposite sides of a shared hex edge, halfway between the two
// tile centres.
const edgeSamples = (a, b) => [
{ level: a, dx: -23.625, dy: -14 },
{ level: b, dx: 23.625, dy: 14 },
];
export class FogTest extends TestCase {
test_levels_describe_visible_explored_and_unexplored_ground() {
this.assertFalse(isFogged(fogLevel(true, true)), "visible ground has no fog");
this.assertEqual(fogLevel(false, true).opacity, FOG_EXPLORED_OPACITY);
this.assertEqual(fogLevel(false, false).opacity, FOG_UNEXPLORED_OPACITY);
this.assertEqual(fogLevel(false, true).rgb, FOG_EXPLORED_RGB);
this.assertEqual(fogLevel(false, false).rgb, FOG_UNEXPLORED_RGB);
}
test_the_frontier_fades_instead_of_stepping() {
const explored = fogLevel(false, true);
const visible = fogLevel(true, true);
const blend = blurFog(edgeSamples(explored, visible));
// Half the kernel is over fogged ground, so the edge is about half as opaque
// as the explored tile rather than jumping straight to clear.
this.assertApprox(blend.opacity, FOG_EXPLORED_OPACITY / 2, 1e-6, "faded opacity");
this.assertEqual(blend.rgb, FOG_EXPLORED_RGB, "the fog keeps its colour");
}
test_a_tile_centre_keeps_its_own_fog() {
const unexplored = fogLevel(false, false);
const clear = fogLevel(true, true);
const blur = blurFog([
{ level: unexplored, dx: 0, dy: 0 },
{ level: clear, dx: 47.25, dy: 28 },
]);
this.assertGreater(blur.opacity, 0.9, "the middle of the fog stays opaque");
}
test_explored_and_unexplored_colours_mix() {
const blend = blurFog(edgeSamples(fogLevel(false, true), fogLevel(false, false)));
this.assertGreater(blend.opacity, FOG_EXPLORED_OPACITY, "between the two fog levels");
this.assertLess(blend.opacity, FOG_UNEXPLORED_OPACITY);
for (let i = 0; i < 3; i++) {
const low = Math.min(FOG_EXPLORED_RGB[i], FOG_UNEXPLORED_RGB[i]);
const high = Math.max(FOG_EXPLORED_RGB[i], FOG_UNEXPLORED_RGB[i]);
this.assertTrue(blend.rgb[i] >= low - 1e-9 && blend.rgb[i] <= high + 1e-9, `channel ${i} mixed`);
}
}
test_the_blur_agrees_from_both_sides_of_an_edge() {
// The renderer builds one ring per tile; both tiles on a shared edge must
// produce the same blurred fog there, or the frontier would seam.
const topology = new MapTopology({ x: 5, y: 5 }, false);
const renderer = Object.create(GLMapRenderer.prototype);
const levelAt = (x, y) => fogLevel(false, !(x === 0 && y === 0));
const centreA = mapToLocal(0, 0);
const centreB = mapToLocal(1, 0);
const ringA = renderer._fogRing(topology, 0, 0, centreA, levelAt);
const ringB = renderer._fogRing(topology, 1, 0, centreB, levelAt);
const px = (centreA.x + centreB.x) / 2;
const py = (centreA.y + centreB.y) / 2;
const samples = (ring) => ring.map((s) => ({ level: s.level, dx: s.x - px, dy: s.y - py }));
const a = blurFog(samples(ringA));
const b = blurFog(samples(ringB));
this.assertApprox(a.opacity, b.opacity, 1e-12, "the shared edge fades identically");
for (let i = 0; i < 3; i++) {
this.assertApprox(a.rgb[i], b.rgb[i], 1e-12, `channel ${i} agrees`);
}
}
test_off_map_neighbours_are_ignored() {
const blend = blurFog([
{ level: fogLevel(false, false), dx: 0, dy: 0 },
{ level: null, dx: 47.25, dy: 28 },
]);
this.assertApprox(blend.opacity, FOG_UNEXPLORED_OPACITY, 1e-6, "a missing tile does not dilute the fog");
}
}
+1 -1
View File
@@ -53,11 +53,11 @@ export function defaultState(civIds = ["france", "britain"], seed = SEED) {
// The six render layers of the world map, as jQuery objects.
export function mapLayers($) {
return {
overview: $("#layer-overview"),
terrain: $("#layer-terrain"),
roads: $("#layer-roads"),
borders: $("#layer-borders"),
fog: $("#layer-fog"),
highlight: $("#layer-highlight"),
paths: $("#layer-paths"),
targets: $("#layer-targets"),
entities: $("#layer-entities"),
+88 -22
View File
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { MapView, inwardOffset } from "../client/js/map_view.js";
import { mapToLocal, COL_STEP } from "../shared/hex.js";
import { mapToLocal, COL_STEP, HEX_W, parseKey } from "../shared/hex.js";
import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
import { defaultState, smallState, mapLayers, adjacentLand, seaTile } from "./framework/helpers.js";
@@ -40,6 +40,54 @@ export class MapViewTest extends TestCase {
}
}
async test_dom_terrain_uses_the_tiling_land_textures() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const local = mapToLocal(2, 1);
const land = env.window.document.createElement("div");
map._applyTerrainTexture(land, { terrainType: "Land", col: 3 }, local);
this.assertTrue(land.style.backgroundImage.includes("textures/grass.png"), "plains use grass");
// Positioned from the tile's world origin, so neighbours continue the pattern.
this.assertTrue(
land.style.backgroundPosition.includes(`${-(local.x - HEX_W / 2)}px`),
`world-positioned tiling: ${land.style.backgroundPosition}`
);
const forest = env.window.document.createElement("div");
map._applyTerrainTexture(forest, { terrainType: "Forest", col: 0 }, local);
this.assertTrue(forest.style.backgroundImage.includes("textures/forest.png"), "forest overlays");
this.assertTrue(
forest.style.backgroundImage.indexOf("forest.png") <
forest.style.backgroundImage.indexOf("grass.png"),
"the forest overlay is the top layer"
);
const desert = env.window.document.createElement("div");
map._applyTerrainTexture(desert, { terrainType: "Desert", col: 4 }, local);
this.assertTrue(desert.style.backgroundImage.includes("textures/sand.png"), "desert uses sand");
this.assertFalse(desert.style.backgroundImage.includes("grass.png"), "desert has no grass");
for (const [terrain, overlay] of [
["Hills", "hills.png"],
["Mountain", "mountain.png"],
]) {
const tile = env.window.document.createElement("div");
map._applyTerrainTexture(tile, { terrainType: terrain, col: 1 }, local);
this.assertTrue(
tile.style.backgroundImage.includes(`textures/${overlay}`),
`${terrain} overlays ${overlay}`
);
this.assertTrue(
tile.style.backgroundImage.includes("textures/grass.png"),
`${terrain} sits on grass`
);
}
} finally {
teardownDom(env);
}
}
async test_renders_the_pre_generated_road_network() {
const state = smallState();
const { env, map, snapshot } = await this.build(state);
@@ -202,33 +250,15 @@ export class MapViewTest extends TestCase {
}
}
async test_zooming_out_switches_to_the_overview() {
async test_far_zoom_keeps_the_detailed_terrain() {
const state = smallState();
const { env, map } = await this.build(state);
try {
this.assertFalse(map.lod, "detailed at close zoom");
this.assertSize(env.$("#layer-overview .overview-block"), 0, "no overview until it is needed");
map.camera.zoom = 0.01;
map._applyCamera();
this.assertTrue(map.lod, "overview at far zoom");
this.assertGreater(
env.$("#layer-overview .overview-block").length,
0,
"overview blocks built"
);
this.assertLess(
env.$("#layer-overview .overview-block").length,
Object.keys(map.tiles).length,
"overview is simpler than the full map"
);
this.assertTrue(env.$("#map-world").hasClass("lod"));
this.assertTrue(env.$("#layer-terrain").hasClass("detail-layer"));
map.camera.zoom = 1;
map._applyCamera();
this.assertFalse(map.lod, "detailed again when zoomed in");
this.assertFalse(env.$("#map-world").hasClass("lod"));
this.assertTrue(env.$("#layer-terrain").hasClass("detail-layer"));
this.assertSize(env.$("#layer-overview"), 0, "there is no simplified overview");
} finally {
teardownDom(env);
}
@@ -543,6 +573,42 @@ export class MapViewTest extends TestCase {
}
}
async test_selected_tile_is_outlined() {
const state = smallState();
const { env, map } = await this.build(state);
try {
map.setSelectedTile({ x: 0, y: 0 });
const hex = env.$("#layer-highlight .tile-selection");
this.assertSize(hex, 1, "the outline is created");
this.assertTrue(hex.hasClass("active"), "the outline is shown");
const points = (hex.find("polygon").attr("points") || "").split(" ");
this.assertSize(points, 6, "a hexagon outline");
const wrapped = map._wrappedPosition(mapToLocal(0, 0));
this.assertApprox(parseFloat(hex[0].style.left), wrapped.x - HEX_W / 2, 1e-6);
map.clearSelectedTile();
this.assertFalse(hex.hasClass("active"), "the outline is hidden again");
} finally {
teardownDom(env);
}
}
async test_selected_tile_reports_its_region() {
const state = smallState();
const { env, map } = await this.build(state);
try {
this.assertGreater(map.regions.size, 0, "regions are mirrored from the snapshot");
const [tileKey, cityId] = map.regions.entries().next().value;
map.setSelectedTile(parseKey(tileKey));
this.assertEqual(map._selectedRegion, cityId, "the tile's region is selected as a whole");
map.clearSelectedTile();
this.assertNull(map._selectedRegion, "clearing drops the region");
} finally {
teardownDom(env);
}
}
async test_stacked_units_show_one_icon_with_a_count_badge() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
+215
View File
@@ -0,0 +1,215 @@
import { TestCase } from "./framework/test_case.js";
import { MapTopology, key, mapToLocal } from "../shared/hex.js";
import { GLMapRenderer } from "../client/js/map_view/webgl.js";
import {
TEXTURE_DESIRED_REPEAT,
blendBeach,
blendTextures,
isTexturedLand,
landTexture,
overlayNormal,
terrainLayers,
textureRepeat,
vertexTextureWeights,
} from "../client/js/map_view/textures.js";
// A flat fixture with the given terrain per coordinate, plus the renderer
// helpers `_textureRing`/`_textureWeights` under test.
function fixture(terrainAt, size = 5) {
const topology = new MapTopology({ x: size, y: size }, false);
const tiles = {};
for (let y = topology.originY; y < topology.originY + size; y++) {
for (let x = topology.originX; x < topology.originX + size; x++) {
tiles[key(x, y)] = { terrainType: terrainAt(x, y), col: 0 };
}
}
const renderer = Object.create(GLMapRenderer.prototype);
return { renderer, topology, tiles, view: { seed: 7, topology, tiles, _period: 0 } };
}
export class TexturesTest extends TestCase {
test_grassy_land_uses_grass_and_desert_uses_sand() {
for (const type of ["Forest", "Hills", "Land", "Mountain"]) {
this.assertEqual(landTexture(type).base, "grass", `${type} base`);
}
this.assertEqual(landTexture("Desert").base, "sand", "desert base");
this.assertNull(landTexture("Sea"));
this.assertNull(landTexture("Ice"));
}
test_only_terrain_with_a_tiling_texture_is_blended() {
this.assertTrue(isTexturedLand({ terrainType: "Land" }));
this.assertTrue(isTexturedLand({ terrainType: "Desert" }));
this.assertTrue(isTexturedLand({ terrainType: "Mountain" }));
this.assertFalse(isTexturedLand({ terrainType: "Sea" }));
this.assertFalse(isTexturedLand(null));
}
test_forest_hills_and_mountains_overlay_the_base() {
this.assertEqual(terrainLayers("Forest"), [
"assets/textures/forest.png",
"assets/textures/grass.png",
]);
this.assertEqual(terrainLayers("Hills"), [
"assets/textures/hills.png",
"assets/textures/grass.png",
]);
this.assertEqual(terrainLayers("Mountain"), [
"assets/textures/mountain.png",
"assets/textures/grass.png",
]);
this.assertEqual(terrainLayers("Desert"), ["assets/textures/sand.png"]);
this.assertEqual(terrainLayers("Land"), ["assets/textures/grass.png"]);
this.assertEmpty(terrainLayers("Ice"), "the atlas terrains keep their art");
}
test_only_hills_and_mountains_have_normal_maps() {
this.assertEqual(overlayNormal("hills"), "hills_normal_map");
this.assertEqual(overlayNormal("mountain"), "mountains_normal_map");
this.assertNull(overlayNormal("forest"));
this.assertNull(overlayNormal("sand"));
}
test_the_repeat_snaps_to_a_whole_number_across_the_wrap() {
const repeat = textureRepeat(1890);
this.assertApprox(Math.round(1890 / repeat), 1890 / repeat, 1e-9, "a whole number of repeats");
this.assertApprox(repeat, TEXTURE_DESIRED_REPEAT, 4, "close to the desired density");
this.assertEqual(textureRepeat(0), TEXTURE_DESIRED_REPEAT, "flat maps use the desired repeat");
}
test_a_shared_edge_blends_to_an_even_mix() {
// (0,0) is land and (1,0) is desert; the point sits on their shared edge,
// halfway between the two centres.
const blend = blendTextures([
{ tile: { terrainType: "Land" }, dx: -23.625, dy: -14 },
{ tile: { terrainType: "Desert" }, dx: 23.625, dy: 14 },
]);
this.assertApprox(blend.grass, 0.5, 1e-9);
this.assertApprox(blend.sand, 0.5, 1e-9);
}
test_a_tile_centre_stays_almost_purely_its_own_texture() {
const blend = blendTextures([
{ tile: { terrainType: "Desert" }, dx: 0, dy: 0 },
{ tile: { terrainType: "Land" }, dx: 47.25, dy: 28 },
{ tile: { terrainType: "Land" }, dx: 47.25, dy: -28 },
{ tile: { terrainType: "Land" }, dx: 0, dy: 56 },
]);
this.assertGreater(blend.sand, 0.9, "the tile centre keeps its sand");
this.assertLess(blend.grass, 0.1, "the neighbours barely bleed in");
}
test_overlays_fade_towards_grass() {
const centre = blendTextures([
{ tile: { terrainType: "Forest" }, dx: 0, dy: 0 },
{ tile: { terrainType: "Land" }, dx: 47.25, dy: 28 },
{ tile: { terrainType: "Land" }, dx: 0, dy: 56 },
]);
this.assertGreater(centre.forest, 0.9, "a forest tile keeps its overlay");
const edge = blendTextures([
{ tile: { terrainType: "Forest" }, dx: -23.625, dy: -14 },
{ tile: { terrainType: "Land" }, dx: 23.625, dy: 14 },
]);
this.assertApprox(edge.forest, 0.5, 1e-9, "the overlay is half strength on the shared edge");
const hills = blendTextures([{ tile: { terrainType: "Hills" }, dx: 0, dy: 0 }]);
this.assertApprox(hills.hills, 1, 1e-9, "a hill keeps its relief overlay");
this.assertApprox(hills.grass, 1, 1e-9, "hills sit on grass");
const mountain = blendTextures([{ tile: { terrainType: "Mountain" }, dx: 0, dy: 0 }]);
this.assertApprox(mountain.mountain, 1, 1e-9, "a mountain keeps its overlay");
this.assertApprox(mountain.grass, 1, 1e-9, "mountains sit on grass");
}
test_the_blend_is_a_shared_field_from_either_tile() {
// Both tiles on a shared edge must compute the same weights, otherwise the
// transition would show a seam where the two fans meet.
const { renderer, tiles, view } = fixture((x) => (x >= 1 ? "Desert" : "Land"));
const centreLand = mapToLocal(0, 0);
const centreDesert = mapToLocal(1, 0);
const ringLand = renderer._textureRing(view, 0, 0, centreLand);
const ringDesert = renderer._textureRing(view, 1, 0, centreDesert);
const px = (centreLand.x + centreDesert.x) / 2;
const py = (centreLand.y + centreDesert.y) / 2;
const fromLand = renderer._textureWeights(tiles[key(0, 0)], ringLand, px, py);
const fromDesert = renderer._textureWeights(tiles[key(1, 0)], ringDesert, px, py);
this.assertEqual(fromLand, fromDesert, "the shared vertex agrees");
this.assertApprox(fromLand[0], 0.5, 1e-9, "grass");
this.assertApprox(fromLand[1], 0.5, 1e-9, "sand");
}
test_terrain_vertices_carry_world_space_texture_coordinates() {
const { renderer, view } = fixture(() => "Land", 3);
renderer.terrain = { name: "terrain" };
renderer.gl = { STATIC_DRAW: 0, DYNAMIC_DRAW: 0 };
let captured = null;
renderer._upload = (pass, arr, usage, floats = 8) => {
captured = arr;
pass.floats = floats;
};
renderer.buildTerrain(view);
this.assertEqual(captured.length % 14, 0, "fourteen floats per terrain vertex");
// The undistorted world position rides along as UVs; tile (0,0)'s right
// corner is the ideal lattice point (31.5, 0), independent of the warp.
let found = false;
for (let i = 0; i < captured.length; i += 14) {
this.assertTrue(Number.isFinite(captured[i + 4]), "world x is finite");
this.assertTrue(Number.isFinite(captured[i + 5]), "world y is finite");
if (Math.abs(captured[i + 4] - 31.5) < 1e-9 && Math.abs(captured[i + 5]) < 1e-9) {
found = true;
}
}
this.assertTrue(found, "the ideal shared corner appears as a world coordinate");
}
test_atlas_terrain_ignores_the_land_blend() {
const atlas = vertexTextureWeights(
{ terrainType: "Ice" },
[{ tile: { terrainType: "Land" }, dx: 0, dy: 0 }]
);
this.assertEqual(atlas, [0, 0, 1, 0, 0, 0, 0, 0], "ice stays on the atlas");
const land = vertexTextureWeights(
{ terrainType: "Land" },
[{ tile: { terrainType: "Land" }, dx: 0, dy: 0 }]
);
this.assertEqual(land, [1, 0, 0, 0, 0, 0, 0, 0], "plains use the grass texture");
const mountain = vertexTextureWeights(
{ terrainType: "Mountain" },
[{ tile: { terrainType: "Mountain" }, dx: 0, dy: 0 }]
);
this.assertEqual(mountain, [1, 0, 0, 0, 0, 1, 0, 0], "mountains sit on grass with their overlay");
}
test_sea_is_flagged_for_the_water_shader() {
const sea = vertexTextureWeights(
{ terrainType: "Sea" },
[{ tile: { terrainType: "Sea" }, dx: 0, dy: 0 }]
);
this.assertEqual(sea, [0, 0, 1, 0, 0, 0, 1, 0], "sea rides on the atlas but is flagged as water");
this.assertFalse(isTexturedLand({ terrainType: "Sea" }), "sea is not a tiling land texture");
}
test_a_coast_blends_sand_into_non_mountain_land() {
const samples = [
{ tile: { terrainType: "Land" }, dx: -23.625, dy: -14 },
{ tile: { terrainType: "Sea" }, dx: 23.625, dy: 14 },
];
const plains = vertexTextureWeights({ terrainType: "Land" }, samples);
this.assertGreater(plains[7], 0, "the shoreline gets some sand");
this.assertLess(plains[7], 1, "the ground still shows through");
const mountain = vertexTextureWeights({ terrainType: "Mountain" }, samples);
this.assertEqual(mountain[7], 0, "mountain coasts stay rocky");
}
test_beach_is_a_shared_field_between_land_and_sea() {
const inland = blendBeach([
{ tile: { terrainType: "Land" }, dx: 0, dy: 0 },
{ tile: { terrainType: "Land" }, dx: 47.25, dy: 28 },
]);
this.assertEqual(inland, 0, "inland ground has no beach");
const shore = blendBeach([
{ tile: { terrainType: "Land" }, dx: -23.625, dy: -14 },
{ tile: { terrainType: "Sea" }, dx: 23.625, dy: 14 },
]);
this.assertApprox(shore, 0.5, 1e-9, "the shoreline is an even mix");
}
}
+162
View File
@@ -0,0 +1,162 @@
import { TestCase } from "./framework/test_case.js";
import { GLMapRenderer } from "../client/js/map_view/webgl.js";
import { CORNERS, latticeKey } from "../client/js/map_view/distortion.js";
import { MapTopology, key, mapToLocal } from "../shared/hex.js";
// Minimal stand-in for a GLMapRenderer: the prototype supplies the pure
// geometry helpers, and only the two GL touch-points (`_upload`, `this.gl`) are
// stubbed so the builders can run without a context.
function makeRenderer() {
const renderer = Object.create(GLMapRenderer.prototype);
renderer.gl = { DYNAMIC_DRAW: 0 };
renderer.captured = {};
renderer.borders = { name: "borders" };
renderer.roads = { name: "roads" };
renderer.regionOverlay = { name: "region" };
renderer._upload = function (pass, arr) {
this.captured[pass.name] = arr;
pass.count = arr.length / 8;
};
return renderer;
}
function view(renderer, { size = 3, territory, regions, selectedRegion = null, roads = new Set() } = {}) {
const topology = new MapTopology({ x: size, y: size }, false);
const tiles = {};
for (let y = topology.originY; y < topology.originY + size; y++) {
for (let x = topology.originX; x < topology.originX + size; x++) {
tiles[key(x, y)] = { col: 0 };
}
}
return {
seed: 4242,
topology,
tiles,
roads,
territory: new Map(territory),
regions: new Map(regions),
civilisations: [{ primaryColour: "#ff0000" }, { primaryColour: "#00ff00" }],
_selectedRegion: selectedRegion,
};
}
export class WebglGeometryTest extends TestCase {
async test_chains_stitch_edges_across_tiles() {
const renderer = makeRenderer();
const edges = [
{ points: [{ x: 0, y: 0 }, { x: 1, y: 0 }], startKey: "0", endKey: "1" },
{ points: [{ x: 1, y: 0 }, { x: 2, y: 0 }], startKey: "1", endKey: "2" },
{ points: [{ x: 9, y: 9 }, { x: 8, y: 9 }], startKey: "8", endKey: "7" },
];
const chains = renderer._chainEdges(edges);
this.assertSize(chains, 2, "two disjoint runs");
const run = chains.find((chain) => chain.points.length === 3);
this.assertNotNull(run, "the joined run has three points");
this.assertFalse(run.closed, "a run with two open ends is not closed");
this.assertEqual(run.points[0], { x: 0, y: 0 }, "the shared corner is only listed once");
}
async test_closed_boundary_is_one_chain() {
const renderer = makeRenderer();
const edges = [
{ points: [{ x: 0, y: 0 }, { x: 1, y: 0 }], startKey: "0", endKey: "1" },
{ points: [{ x: 1, y: 0 }, { x: 1, y: 1 }], startKey: "1", endKey: "2" },
{ points: [{ x: 1, y: 1 }, { x: 0, y: 0 }], startKey: "2", endKey: "0" },
];
const chains = renderer._chainEdges(edges);
this.assertSize(chains, 1, "the loop is a single chain");
this.assertTrue(chains[0].closed, "the loop closes");
this.assertSize(chains[0].points, 3, "the repeated closing point is dropped");
}
async test_border_strokes_are_finite() {
const renderer = makeRenderer();
const territory = [
["-1,-1", 0], ["0,-1", 0], ["-1,0", 0], ["0,0", 0],
["1,0", 1], ["1,1", 1],
];
const state = view(renderer, { territory });
renderer.buildBorders(state);
const arr = renderer.captured.borders;
this.assertGreater(arr.length, 0, "borders are built");
for (const value of arr) {
this.assertTrue(Number.isFinite(value), "every vertex coordinate is finite");
}
}
async test_a_nations_whole_outline_is_one_closed_chain() {
// A 3x3 block owned by a single nation has no open ends, so its border must
// chain into one loop rather than a handful of per-tile strokes.
const renderer = makeRenderer();
const territory = [];
for (let y = -1; y <= 1; y++) {
for (let x = -1; x <= 1; x++) territory.push([key(x, y), 0]);
}
const state = view(renderer, { territory });
const edges = [];
for (const [k, owner] of state.territory) {
const coords = k.split(",").map(Number);
const centre = mapToLocal(coords[0], coords[1]);
const neighbours = renderer._edgeNeighbours(state, coords[0], coords[1]);
for (let e = 0; e < CORNERS.length; e++) {
const neighbour = neighbours[e];
if (!neighbour) continue;
const nk = key(neighbour.x, neighbour.y);
if (state.territory.get(nk) === owner) continue;
const a = { x: centre.x + CORNERS[e].x, y: centre.y + CORNERS[e].y };
const next = CORNERS[(e + 1) % CORNERS.length];
const b = { x: centre.x + next.x, y: centre.y + next.y };
const dx = b.x - a.x;
const dy = b.y - a.y;
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const ownerIsLeft = (centre.x - mx) * -dy + (centre.y - my) * dx >= 0;
edges.push(ownerIsLeft
? { points: [a, b], startKey: latticeKey(a.x, a.y), endKey: latticeKey(b.x, b.y) }
: { points: [b, a], startKey: latticeKey(b.x, b.y), endKey: latticeKey(a.x, a.y) });
}
}
const chains = renderer._chainEdges(edges);
this.assertSize(chains, 1, "the whole coastline is one run");
this.assertTrue(chains[0].closed, "the block's border wraps all the way round");
}
async test_region_border_is_suppressed_on_a_national_border() {
const selected = 0;
const sharedDifferentOwner = [
["0,0", 0], ["1,0", 1],
];
const sharedSameOwner = [
["0,0", 0], ["1,0", 0],
];
const regions = [["0,0", 0], ["1,0", 1]];
const splitRenderer = makeRenderer();
splitRenderer.buildRegionOverlay(
view(splitRenderer, { territory: sharedDifferentOwner, regions, selectedRegion: selected })
);
const split = splitRenderer.captured.region.length;
const joinedRenderer = makeRenderer();
joinedRenderer.buildRegionOverlay(
view(joinedRenderer, { territory: sharedSameOwner, regions, selectedRegion: selected })
);
const joined = joinedRenderer.captured.region.length;
// Only the selected tile (0,0) contributes; its six-edge fill is 24 tris.
const fill = 24 * 3 * 8;
this.assertEqual(split, fill, "no dotted line is drawn over the national border");
this.assertGreater(joined, fill, "a same-owner region border is still dotted");
}
async test_roads_join_at_a_bend_without_a_notch() {
const renderer = makeRenderer();
const state = view(renderer, { roads: new Set(["-1,0", "0,0", "1,0"]) });
renderer.buildRoads(state);
const arr = renderer.captured.roads;
this.assertGreater(arr.length, 0, "roads are built");
for (const value of arr) {
this.assertTrue(Number.isFinite(value), "every road vertex is finite");
}
}
}