Added textured terrain, procedural water and a blurred fog of war
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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%);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||