Added buildable roads and railways drawn as winding textured links
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 229 B |
Binary file not shown.
|
After Width: | Height: | Size: 219 B |
+52
-10
@@ -41,7 +41,9 @@ html, body {
|
||||
overflow: hidden;
|
||||
}
|
||||
.loading-overlay.hidden { display: none; }
|
||||
.loading-overlay.done { opacity: 0.0; transition: opacity 0.6s ease; }
|
||||
/* Once finished the overlay only fades; it must not keep swallowing clicks
|
||||
meant for the HUD underneath it. */
|
||||
.loading-overlay.done { opacity: 0.0; transition: opacity 0.6s ease; pointer-events: none; }
|
||||
.loading-panel {
|
||||
width: min(720px, 100%);
|
||||
max-height: 100%;
|
||||
@@ -282,14 +284,15 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* A single pre-generated road link, running from tile centre to tile centre. */
|
||||
.road-seg {
|
||||
/* A single road or railway link, running from tile centre to tile centre. The
|
||||
texture (and its repeat) is set in JS; only the quad's shape is styled here. */
|
||||
.road-seg,
|
||||
.rail-seg {
|
||||
position: absolute;
|
||||
background: #8a5a2b;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 1px rgba(0, 0, 0, 0.6);
|
||||
transform-origin: 0 50%;
|
||||
background-color: transparent;
|
||||
transform-origin: 50% 0;
|
||||
pointer-events: none;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* A ranged unit's bombardment: a red dashed line from the battery to the
|
||||
@@ -488,9 +491,16 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Roads are hidden on the political map: flat country colours read better
|
||||
without them, while the national borders stay to separate the nations. */
|
||||
#map-world.political #layer-roads { display: none; }
|
||||
/* On the flat political and economic maps the transport network stays visible,
|
||||
but as solid strokes instead of the road/rail textures. */
|
||||
#map-world.political .road-seg, #map-world.economic .road-seg {
|
||||
background-image: none !important;
|
||||
background-color: #3a3a3d;
|
||||
}
|
||||
#map-world.political .rail-seg, #map-world.economic .rail-seg {
|
||||
background-image: none !important;
|
||||
background-color: #8a5a2b;
|
||||
}
|
||||
|
||||
.city-label-layer { pointer-events: none; z-index: 5; }
|
||||
.city-label {
|
||||
@@ -628,6 +638,38 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
max-width: 560px;
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
/* The improvement picker opens directly above the build button. */
|
||||
.build-anchor { position: relative; display: inline-block; }
|
||||
.build-popover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
min-width: 300px;
|
||||
background: var(--panel-bg, rgba(18, 24, 34, 0.96));
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
z-index: 70;
|
||||
}
|
||||
.build-popover-title {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin: 2px 4px 8px;
|
||||
}
|
||||
.build-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
text-align: left;
|
||||
}
|
||||
.build-option .build-name { font-weight: 600; }
|
||||
.build-option .build-meta { font-size: 11px; color: var(--muted); }
|
||||
.build-option.current { outline: 1px solid rgba(255, 255, 255, 0.4); }
|
||||
.info-section.hidden { display: none !important; }
|
||||
.info-title { font-size: 17px; font-weight: 600; margin-bottom: 4px; }
|
||||
.tile-coords { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; margin-bottom: 2px; }
|
||||
|
||||
@@ -154,6 +154,12 @@
|
||||
<div id="tile-ethnicity"></div>
|
||||
<div id="tile-gdp"></div>
|
||||
<div id="tile-improvements"></div>
|
||||
<div class="row">
|
||||
<div class="build-anchor">
|
||||
<div id="tile-build-popover" class="build-popover hidden"></div>
|
||||
<button id="tile-build" type="button" class="aero hidden">Build improvement</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="city-info" class="info-section hidden">
|
||||
<div id="city-name" class="info-title"></div>
|
||||
|
||||
@@ -28,6 +28,8 @@ export class GameScreen {
|
||||
this.selectedCityId = 0;
|
||||
// The tile whose info panel is open, if any (right-click inspection).
|
||||
this._inspectedTile = null;
|
||||
// The tile the improvement picker currently targets.
|
||||
this._buildTarget = null;
|
||||
this._trainingCityId = 0;
|
||||
this._cityBuildings = new Map();
|
||||
this._population = new Map();
|
||||
@@ -82,6 +84,7 @@ export class GameScreen {
|
||||
this.capabilityModal = new CapabilityModal();
|
||||
this.feed = new GameFeed($("#game-feed"));
|
||||
this.$stackMenu = $("#stack-menu");
|
||||
this.$buildPopover = $("#tile-build-popover");
|
||||
this.$statTooltip = $("#stat-tooltip");
|
||||
this._hoveredStat = null;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
effectValue,
|
||||
pillageSteps,
|
||||
ROADS,
|
||||
TRANSPORT_IMPROVEMENTS,
|
||||
AIR_STRIKE,
|
||||
} from "../../../shared/data.js";
|
||||
import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors } from "../../../shared/rules.js";
|
||||
@@ -104,6 +105,33 @@ export const panelMethods = {
|
||||
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
|
||||
.on("mouseleave", () => this._hideStatTooltip());
|
||||
|
||||
// The improvement picker opens above the build button; the choices are
|
||||
// delegated, so rebuilding the list never loses the handler.
|
||||
$("#tile-build").on("click", (event) => {
|
||||
event.stopPropagation();
|
||||
this._toggleBuildPopover();
|
||||
});
|
||||
$("#tile-build-popover").on("click", ".build-choice", (event) => {
|
||||
const $choice = $(event.currentTarget);
|
||||
if ($choice.prop("disabled")) return;
|
||||
// Recompute the target as a fallback, so a re-rendered popover can never
|
||||
// lose the tile the choice applies to.
|
||||
const target = this._buildTarget || this._buildableTile();
|
||||
if (!target) return;
|
||||
this._buildImprovement(
|
||||
target.coords,
|
||||
$choice.attr("data-improvement"),
|
||||
$choice.attr("data-free") === "1"
|
||||
);
|
||||
this._hideBuildPopover();
|
||||
});
|
||||
$(document).on("mousedown.buildpopover", (event) => {
|
||||
if (!$(event.target).closest("#tile-build-popover").length &&
|
||||
!$(event.target).closest("#tile-build").length) {
|
||||
this._hideBuildPopover();
|
||||
}
|
||||
});
|
||||
|
||||
// A click anywhere outside the stack menu dismisses it.
|
||||
$(document).on("mousedown.stackmenu", (event) => {
|
||||
if (!this.$stackMenu.hasClass("hidden") && !$(event.target).closest("#stack-menu").length) {
|
||||
@@ -505,6 +533,10 @@ export const panelMethods = {
|
||||
} else if (this.selectedCityId) {
|
||||
const city = this._city(this.selectedCityId);
|
||||
if (city) this._showCity(city);
|
||||
} else if (this._inspectedTile) {
|
||||
// Keep the inspected tile's improvements and build button current after
|
||||
// the world changes (a road may have just been built on it).
|
||||
this._renderTileInfo(this._inspectedTile);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -648,11 +680,122 @@ export const panelMethods = {
|
||||
$("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`);
|
||||
this._renderTilePeople(k);
|
||||
this._renderTileGdp(owner >= 0 ? this._tileGdpBreakdown(coords, owner, city, tile) : null);
|
||||
const roads = this.map.roads && this.map.roads.has(k) ? [ROADS.improvementName] : [];
|
||||
const improvements = city
|
||||
? Array.from(new Set([...(city.improvements || []), ...roads]))
|
||||
: roads;
|
||||
$("#tile-improvements").text(`Improvements: ${fmtImprovements(improvements)}`);
|
||||
const transport = this.map.railways.has(k)
|
||||
? ["Railway"]
|
||||
: this.map.roads.has(k)
|
||||
? [ROADS.improvementName]
|
||||
: [];
|
||||
const cityImprovements = (city ? city.improvements || [] : []).filter(
|
||||
(name) => name !== ROADS.improvementName && name !== "Railway"
|
||||
);
|
||||
$("#tile-improvements").text(
|
||||
`Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport])))}`
|
||||
);
|
||||
// Only a land tile the player controls can carry a new improvement.
|
||||
const buildable = owner === this.localCiv &&
|
||||
owner >= 0 &&
|
||||
!!tile &&
|
||||
tile.terrainClass === "Land";
|
||||
$("#tile-build").toggleClass("hidden", !buildable);
|
||||
if (!buildable) this._hideBuildPopover();
|
||||
},
|
||||
|
||||
// The tile the improvement picker targets, or null when it cannot be built on.
|
||||
_buildableTile() {
|
||||
let coords = this._inspectedTile;
|
||||
// A selected city still stands on an improvable tile.
|
||||
if (!coords && this.selectedCityId) {
|
||||
const city = this._city(this.selectedCityId);
|
||||
if (city) coords = { x: city.coords[0], y: city.coords[1] };
|
||||
}
|
||||
if (!coords) return null;
|
||||
const k = `${coords.x},${coords.y}`;
|
||||
if ((this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1) !== this.localCiv) {
|
||||
return null;
|
||||
}
|
||||
const tile = this.map.getTile(coords);
|
||||
if (!tile || tile.terrainClass !== "Land" || this.localCiv < 0) return null;
|
||||
return { coords, k, tile };
|
||||
},
|
||||
|
||||
_toggleBuildPopover() {
|
||||
if (!this.$buildPopover || !this.$buildPopover.length) return;
|
||||
if (!this.$buildPopover.hasClass("hidden")) {
|
||||
this._hideBuildPopover();
|
||||
return;
|
||||
}
|
||||
this._renderBuildPopover();
|
||||
},
|
||||
|
||||
// Lists the transport improvements with their price and hourly upkeep, greying
|
||||
// out the one already on the tile and anything the budget cannot afford.
|
||||
_renderBuildPopover() {
|
||||
const target = this._buildableTile();
|
||||
const $pop = this.$buildPopover;
|
||||
if (!target || !$pop || !$pop.length) return;
|
||||
this._buildTarget = target;
|
||||
$pop.empty();
|
||||
$pop.append($("<div class='build-popover-title'></div>").text("Build transport"));
|
||||
const current = this.map.railways.has(target.k)
|
||||
? "railway"
|
||||
: this.map.roads.has(target.k)
|
||||
? "road"
|
||||
: null;
|
||||
const budget = (this.snapshot.viewerStats || {}).budget || 0;
|
||||
const testing = !!(this.snapshot && this.snapshot.testing);
|
||||
for (const improvement of TRANSPORT_IMPROVEMENTS) {
|
||||
const isCurrent = current === improvement.id;
|
||||
const affordable = budget >= improvement.buildCost;
|
||||
const $button = $("<button type='button' class='aero build-option build-choice'></button>");
|
||||
$button.attr("data-improvement", improvement.id);
|
||||
$button.append(
|
||||
$("<div></div>").append(
|
||||
$("<div class='build-name'></div>").text(improvement.name),
|
||||
$("<div class='build-meta'></div>").text(
|
||||
`€${groupDigits(improvement.buildCost)} · €${groupDigits(improvement.maintenance)}/tile·h`
|
||||
)
|
||||
),
|
||||
$("<div class='build-meta'></div>").text(
|
||||
isCurrent ? "Built" : `${Math.round(1 / improvement.movementCostMultiplier)}× grass`
|
||||
)
|
||||
);
|
||||
$button.prop("disabled", isCurrent || !affordable);
|
||||
$button.attr(
|
||||
"title",
|
||||
isCurrent
|
||||
? "Already built on this tile"
|
||||
: affordable
|
||||
? `Build ${improvement.name} here`
|
||||
: `Not enough budget (€${groupDigits(improvement.buildCost)} needed)`
|
||||
);
|
||||
if (isCurrent) $button.addClass("current");
|
||||
$pop.append($button);
|
||||
// A local test game may raise it for free and at once.
|
||||
if (testing && !isCurrent) {
|
||||
const $free = $("<button type='button' class='aero free-button build-choice'></button>");
|
||||
$free.attr("data-improvement", improvement.id);
|
||||
$free.attr("data-free", "1");
|
||||
$free.text(`${improvement.name}: free & instant`);
|
||||
$pop.append($free);
|
||||
}
|
||||
}
|
||||
$pop.removeClass("hidden");
|
||||
},
|
||||
|
||||
_buildImprovement(coords, id, free = false) {
|
||||
const order = {
|
||||
type: "build_improvement",
|
||||
coords: [coords.x, coords.y],
|
||||
improvement: id,
|
||||
};
|
||||
if (free) order.free = true;
|
||||
this.sendOrder(order);
|
||||
},
|
||||
|
||||
_hideBuildPopover() {
|
||||
if (this.$buildPopover && this.$buildPopover.length) {
|
||||
this.$buildPopover.addClass("hidden");
|
||||
}
|
||||
},
|
||||
|
||||
// A short, readable label for the tile: its owner's demonym, the terrain and
|
||||
|
||||
@@ -96,8 +96,10 @@ export class MapView {
|
||||
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".
|
||||
// Pre-generated and player-built road tiles, keyed "x,y".
|
||||
this.roads = new Set();
|
||||
// Player-built railway tiles, keyed "x,y". A tile is a road or a railway.
|
||||
this.railways = new Set();
|
||||
this.civilisations = [];
|
||||
this.localCiv = -1;
|
||||
this.protoUnits = [];
|
||||
@@ -148,7 +150,7 @@ export class MapView {
|
||||
this._regionsSignature = null;
|
||||
this._territoryVersion = undefined;
|
||||
this._regionsVersion = undefined;
|
||||
this._roadsSignature = null;
|
||||
this._improvementsSignature = null;
|
||||
this._exploredCount = -1;
|
||||
this._visibleSignature = null;
|
||||
|
||||
|
||||
@@ -143,6 +143,22 @@ export function distortedCorners(seed, centre, period) {
|
||||
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 };
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -225,18 +241,25 @@ export function distortedBoundary(seed, centre, period, segments = EDGE_SEGMENTS
|
||||
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 };
|
||||
// A cubic Bezier centreline sampled from `p0` to `p3`, with control points
|
||||
// `p1`/`p2`. Roads are built from these so each link is a smooth curve; the
|
||||
// caller shapes the control points from the tiles on either side, which is what
|
||||
// makes consecutive links meet at a shared centre with a shared tangent.
|
||||
export function cubicBezier(p0, p1, p2, p3, segments = 14) {
|
||||
const points = [];
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const t = i / segments;
|
||||
const mt = 1 - t;
|
||||
const b0 = mt * mt * mt;
|
||||
const b1 = 3 * mt * mt * t;
|
||||
const b2 = 3 * mt * t * t;
|
||||
const b3 = t * t * t;
|
||||
points.push({
|
||||
x: b0 * p0.x + b1 * p1.x + b2 * p2.x + b3 * p3.x,
|
||||
y: b0 * p0.y + b1 * p1.y + b2 * p2.y + b3 * p3.y,
|
||||
h: 0,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ export const entityMethods = {
|
||||
applySnapshot(state) {
|
||||
bootLog.step("Snapshot received from the server");
|
||||
this.ensureTerrain(state.seed, state.mapConfig || null);
|
||||
this.setRoads(state.roads || []);
|
||||
bootLog.step("Mirrored the road network");
|
||||
this.setImprovements(state.roads || [], state.railways || [], state.versions && state.versions.improvements);
|
||||
bootLog.step("Mirrored the transport network");
|
||||
this.localCiv = state.viewer;
|
||||
this._applyTerritory(state.territory, state.versions && state.versions.territory);
|
||||
bootLog.step("Mirrored the territory and borders");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { mapToLocal, key } from "../../../shared/hex.js";
|
||||
import { HexPathfinder } from "../../../shared/hex_pathfinder.js";
|
||||
import { ROADS } from "../../../shared/data/roads.js";
|
||||
import { TRANSPORT_BY_ID } from "../../../shared/data/improvements.js";
|
||||
import { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js";
|
||||
import { CAMERA_TILT, RESYNC_HOURS, PREDICTION_GRACE_HOURS } from "./constants.js";
|
||||
import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js";
|
||||
@@ -337,8 +337,15 @@ export const motionMethods = {
|
||||
const costCache = new Map();
|
||||
const heuristicCache = new Map();
|
||||
const bestSpeed = Math.max(speed || proto.speed, 0.0001);
|
||||
const fastestHours =
|
||||
this.terrainStats.minMovementCostMultiplier() / (bestSpeed * CONTROLLED_SPEED_MULTIPLIER);
|
||||
// Mirror GameState._fastestTileTravelHours: a railway (or a road) may
|
||||
// undercut the cheapest terrain, and the heuristic must stay admissible if
|
||||
// A* is to find it.
|
||||
const cheapest = Math.min(
|
||||
this.terrainStats.minMovementCostMultiplier(),
|
||||
TRANSPORT_BY_ID.road.movementCostMultiplier,
|
||||
TRANSPORT_BY_ID.railway.movementCostMultiplier
|
||||
);
|
||||
const fastestHours = cheapest / (bestSpeed * CONTROLLED_SPEED_MULTIPLIER);
|
||||
const iterator = new HexPathfinder().findPathIter(
|
||||
start,
|
||||
goal,
|
||||
@@ -497,9 +504,10 @@ export const motionMethods = {
|
||||
const k = key(coords.x, coords.y);
|
||||
let pace = effective;
|
||||
if (this.territory.get(k) === civ) pace *= CONTROLLED_SPEED_MULTIPLIER;
|
||||
const multiplier = this.roads.has(k)
|
||||
? ROADS.movementCostMultiplier
|
||||
: this.terrainStats.movementCostMultiplier(coords);
|
||||
let multiplier;
|
||||
if (this.railways.has(k)) multiplier = TRANSPORT_BY_ID.railway.movementCostMultiplier;
|
||||
else if (this.roads.has(k)) multiplier = TRANSPORT_BY_ID.road.movementCostMultiplier;
|
||||
else multiplier = this.terrainStats.movementCostMultiplier(coords);
|
||||
return multiplier / pace;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,11 +34,12 @@ export const terrainMethods = {
|
||||
this._terrainSignature = signature;
|
||||
this.seed = seed;
|
||||
this.mapConfig = config;
|
||||
// A new world brings a new road network; drop the old one (and the cache
|
||||
// signature) before the chunks are rebuilt, so setRoads always installs the
|
||||
// new tiles.
|
||||
// A new world brings a new transport network; drop the old one (and the
|
||||
// cache signature) before the chunks are rebuilt, so setImprovements always
|
||||
// installs the new tiles.
|
||||
this.roads = new Set();
|
||||
this._roadsSignature = null;
|
||||
this.railways = new Set();
|
||||
this._improvementsSignature = null;
|
||||
this.topology = new MapTopology(config.mapSize, config.topology === "cylindrical");
|
||||
const generator = new MapGenerator(config, this.topology);
|
||||
bootLog.measure("Generated the terrain, settlements and roads", () => {
|
||||
@@ -451,14 +452,17 @@ export const terrainMethods = {
|
||||
this.glRenderer.buildKnowledge(this);
|
||||
},
|
||||
|
||||
// Installs the road network once. Roads never change once generated, so a
|
||||
// length check is enough to skip rebuilding the on-screen chunks.
|
||||
setRoads(entries) {
|
||||
const list = entries || [];
|
||||
const signature = String(list.length);
|
||||
if (this._roadsSignature === signature) return;
|
||||
this._roadsSignature = signature;
|
||||
this.roads = new Set(list.map((entry) => key(entry[0], entry[1])));
|
||||
// Installs the transport network (roads and railways). Both change as players
|
||||
// build improvements, so a signature over the tile counts and the server's
|
||||
// improvement version decides whether the on-screen chunks need rebuilding.
|
||||
setImprovements(roads, railways, version) {
|
||||
const roadList = roads || [];
|
||||
const railList = railways || [];
|
||||
const signature = `${roadList.length}|${railList.length}|${version === undefined ? "" : version}`;
|
||||
if (this._improvementsSignature === signature) return;
|
||||
this._improvementsSignature = signature;
|
||||
this.roads = new Set(roadList.map((entry) => key(entry[0], entry[1])));
|
||||
this.railways = new Set(railList.map((entry) => key(entry[0], entry[1])));
|
||||
if (this.glRenderer) {
|
||||
this.glRenderer.buildRoads(this);
|
||||
return;
|
||||
@@ -469,34 +473,59 @@ export const terrainMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
// Draws a brown line from the centre of every road tile to the centre of each
|
||||
// of its road neighbours. The work is paid per visible chunk, so panning over
|
||||
// a huge network only ever builds the few hundred tiles on screen.
|
||||
// Draws the transport network as texture quads: a strip runs from the centre
|
||||
// of every transport tile to the centre of each of its transport neighbours.
|
||||
// Roads and railways share one graph, so a mixed link is drawn as two halves
|
||||
// that meet at the border — road texture on the road side, rail on the other —
|
||||
// and upgrading a tile never leaves a gap. The work is paid per visible chunk,
|
||||
// so panning over a huge network only ever builds the tiles on screen.
|
||||
_buildRoads(rec) {
|
||||
rec.roadsDirty = false;
|
||||
this._recycleLayer(rec.roadsEl, this._roadPool);
|
||||
if (!this.roads || this.roads.size === 0) return;
|
||||
const width = 5;
|
||||
const kind = new Map();
|
||||
for (const k of this.roads) kind.set(k, "road");
|
||||
for (const k of this.railways) kind.set(k, "rail");
|
||||
if (kind.size === 0) return;
|
||||
const width = 10;
|
||||
const fragment = document.createDocumentFragment();
|
||||
// One rotated texture quad starting at `from`, running `length` pixels along
|
||||
// the link direction, carrying the given road.png/rail.png.
|
||||
const addSeg = (from, length, kindName, angle) => {
|
||||
const el = this._acquireHex(this._roadPool);
|
||||
el.className = kindName === "road" ? "road-seg" : "rail-seg";
|
||||
el.style.left = `${from.x - width / 2}px`;
|
||||
el.style.top = `${from.y}px`;
|
||||
el.style.width = `${width}px`;
|
||||
el.style.height = `${length}px`;
|
||||
el.style.transformOrigin = "50% 0";
|
||||
el.style.transform = `rotate(${angle - Math.PI / 2}rad)`;
|
||||
el.style.backgroundImage = `url("assets/textures/${kindName}.png")`;
|
||||
el.style.backgroundSize = "16px 16px";
|
||||
el.style.backgroundPosition = "-3px 0";
|
||||
el.style.backgroundRepeat = "repeat-y";
|
||||
fragment.appendChild(el);
|
||||
};
|
||||
for (const k of rec.tileKeys) {
|
||||
if (!this.roads.has(k)) continue;
|
||||
if (!kind.has(k)) continue;
|
||||
const coords = parseKey(k);
|
||||
const center = mapToLocal(coords.x, coords.y);
|
||||
const localCenter = { x: center.x - rec.x, y: center.y - rec.y };
|
||||
for (const neighbour of this.topology.neighbours(coords.x, coords.y)) {
|
||||
if (!this.roads.has(key(neighbour.x, neighbour.y))) continue;
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
if (!kind.has(nk) || k >= nk) continue;
|
||||
const delta = this.topology.pixelDelta(coords, neighbour);
|
||||
const length = Math.hypot(delta.x, delta.y);
|
||||
if (length <= 0) continue;
|
||||
const angle = Math.atan2(delta.y, delta.x);
|
||||
const el = this._acquireHex(this._roadPool);
|
||||
el.className = "road-seg";
|
||||
el.style.left = `${localCenter.x}px`;
|
||||
el.style.top = `${localCenter.y - width / 2}px`;
|
||||
el.style.width = `${length}px`;
|
||||
el.style.height = `${width}px`;
|
||||
el.style.transform = `rotate(${angle}rad)`;
|
||||
fragment.appendChild(el);
|
||||
const here = kind.get(k);
|
||||
const there = kind.get(nk);
|
||||
if (here === there) {
|
||||
addSeg(localCenter, length, here, angle);
|
||||
} else {
|
||||
const mid = { x: localCenter.x + delta.x / 2, y: localCenter.y + delta.y / 2 };
|
||||
addSeg(localCenter, length / 2, here, angle);
|
||||
addSeg(mid, length / 2, there, angle);
|
||||
}
|
||||
}
|
||||
}
|
||||
rec.roadsEl.appendChild(fragment);
|
||||
|
||||
@@ -29,6 +29,9 @@ export const TEXTURE_FOREST = "forest";
|
||||
export const TEXTURE_MOUNTAIN = "mountain";
|
||||
// Tangent-space normal map that gives the mountains their relief.
|
||||
export const TEXTURE_MOUNTAIN_NORMAL = "mountains_normal_map";
|
||||
// The player-buildable transport improvements, tiled along a road/rail link.
|
||||
export const TEXTURE_ROAD = "road";
|
||||
export const TEXTURE_RAIL = "rail";
|
||||
|
||||
// The overlay layers, in the order the shader composites them. Hills have no
|
||||
// overlay of their own: higher grass is desaturated in the shader instead.
|
||||
|
||||
+306
-62
@@ -19,7 +19,7 @@ import { nearestCorners, parseColour } from "./utils.js";
|
||||
import {
|
||||
CORNERS,
|
||||
EDGE_SEGMENTS,
|
||||
bentPath,
|
||||
cubicBezier,
|
||||
distortedBoundary,
|
||||
edgePoint,
|
||||
latticeKey,
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
TEXTURE_GRASS,
|
||||
TEXTURE_MOUNTAIN,
|
||||
TEXTURE_MOUNTAIN_NORMAL,
|
||||
TEXTURE_RAIL,
|
||||
TEXTURE_ROAD,
|
||||
TEXTURE_SAND,
|
||||
isTexturedLand,
|
||||
isWaterTile,
|
||||
@@ -83,6 +85,26 @@ const ATTR_SLOPE = 6;
|
||||
const TERRAIN_FLOATS = 17;
|
||||
const COLOUR_FLOATS = 8;
|
||||
const FOG_FLOATS = 7;
|
||||
// Transport quads: x, y, u, v, height, then a tint/alpha colour. The road and
|
||||
// railway textures run along the link, so they need the UVs the flat passes do
|
||||
// without, and a colour to tint them.
|
||||
const ROAD_FLOATS = 9;
|
||||
// World pixels of road/rail texture per repeat, and the width of a link quad.
|
||||
// The textures are 16px wide with the opaque art spanning x 3..13, so the quad
|
||||
// samples exactly that band to keep its edges clean.
|
||||
const TRANSPORT_TEXTURE_LENGTH = 16;
|
||||
const TRANSPORT_WIDTH = 10;
|
||||
const TRANSPORT_U0 = 3 / 16;
|
||||
const TRANSPORT_U1 = 13 / 16;
|
||||
// How finely a road link's cubic Bezier is tessellated. Enough points that the
|
||||
// mitered band reads as a smooth curve rather than a chain of faceted quads.
|
||||
const ROAD_CURVE_SEGMENTS = 14;
|
||||
// On the flat political/economic maps the transport network is drawn as plain
|
||||
// straight lines instead of textured curve quads: dark grey for roads, brown for
|
||||
// railways.
|
||||
const FLAT_TRANSPORT_WIDTH = 4;
|
||||
const FLAT_ROAD_COLOUR = [0x3a / 255, 0x3a / 255, 0x3d / 255, 1];
|
||||
const FLAT_RAIL_COLOUR = [0x8a / 255, 0x5a / 255, 0x2b / 255, 1];
|
||||
|
||||
// A flat sample, used when a view has no relief field (tests, stub views).
|
||||
const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 };
|
||||
@@ -101,7 +123,6 @@ export const CONTEXT_ATTRIBUTES = {
|
||||
failIfMajorPerformanceCaveat: false,
|
||||
};
|
||||
|
||||
const ROAD = [138 / 255, 90 / 255, 43 / 255, 1];
|
||||
// Unclaimed land on the political map, a flat muted olive. Painting it here
|
||||
// lets the political/economic modes skip the textured terrain mesh entirely.
|
||||
const UNCLAIMED_LAND = [0x5b / 255, 0x5f / 255, 0x49 / 255, 1];
|
||||
@@ -282,8 +303,16 @@ void main() {
|
||||
// transparent corners are not painted black.
|
||||
float alpha = mix(1.0, atlas.a, vColour.b);
|
||||
gl_FragColor = vec4(base, alpha);
|
||||
} else {
|
||||
} else if (uMode < 1.5) {
|
||||
gl_FragColor = vColour;
|
||||
} else {
|
||||
// Transport quads: the road/rail texture (bound to the atlas sampler in
|
||||
// this mode) tinted and alpha-blended by the vertex colour, so the
|
||||
// transparent margins of the strip show the ground. Reusing the atlas
|
||||
// sampler keeps the program inside the eight texture units WebGL 1.0
|
||||
// guarantees.
|
||||
vec4 tex = texture2D(uAtlas, vUV);
|
||||
gl_FragColor = vec4(tex.rgb * vColour.rgb, tex.a * vColour.a);
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -393,6 +422,9 @@ export class GLMapRenderer {
|
||||
// so open water hexagons stay regular.
|
||||
this._warp = null;
|
||||
this.roads = { buffer: null, count: 0 };
|
||||
this.railways = { buffer: null, count: 0 };
|
||||
// The flat political/economic transport lines: straight solid strokes.
|
||||
this.flatTransport = { buffer: null, count: 0 };
|
||||
this.borders = { buffer: null, count: 0 };
|
||||
this.fog = { buffer: null, count: 0 };
|
||||
// The selected/inspected tile's region: a soft fill plus dotted borders.
|
||||
@@ -460,7 +492,7 @@ export class GLMapRenderer {
|
||||
const gl = this.gl;
|
||||
if (!gl) return;
|
||||
for (const pass of [
|
||||
this.terrain, this.ocean, this.roads, this.borders, this.fog, this.regionOverlay,
|
||||
this.terrain, this.ocean, this.roads, this.railways, this.flatTransport, this.borders, this.fog, this.regionOverlay,
|
||||
this.politicalFill, this.economicFill, ...this.terrainLevels,
|
||||
]) {
|
||||
if (!pass || !pass.buffer) continue;
|
||||
@@ -727,6 +759,9 @@ export class GLMapRenderer {
|
||||
load(TEXTURE_FOREST, `${TEXTURE_DIR}${TEXTURE_FOREST}.png`, true);
|
||||
load(TEXTURE_MOUNTAIN, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN}.png`, true);
|
||||
load(TEXTURE_MOUNTAIN_NORMAL, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN_NORMAL}.png`, true);
|
||||
// The transport strips tile along a link, so they repeat.
|
||||
load(TEXTURE_ROAD, `${TEXTURE_DIR}${TEXTURE_ROAD}.png`, true);
|
||||
load(TEXTURE_RAIL, `${TEXTURE_DIR}${TEXTURE_RAIL}.png`, true);
|
||||
// The water normal map is baked rather than loaded, so it is ready at once.
|
||||
const bump = generateWaterBump(256, WATER_BUMP_CELLS, 1337);
|
||||
const water = this._createTexture(true);
|
||||
@@ -1254,79 +1289,220 @@ export class GLMapRenderer {
|
||||
this._upload(this.ocean, arr, undefined, TERRAIN_FLOATS);
|
||||
}
|
||||
|
||||
// Draws the road network as mitered strokes. Each pair of adjacent road tiles
|
||||
// is drawn once as a single polyline from centre to centre, crossing the
|
||||
// shared wavy border at its midpoint; the bend there and the gentle in-tile
|
||||
// drifts are joined with the same miter as the borders, so no piece overlaps
|
||||
// and no notch is left at a bend. A small cap at every tile centre covers the
|
||||
// junction where several roads meet.
|
||||
// Draws the whole transport network as texture quads. Roads and railways
|
||||
// share one graph: each link is a single cubic Bezier from centre to centre,
|
||||
// and when its two ends are different kinds the curve is split at the border
|
||||
// so the road half and the rail half meet there. Upgrading a tile therefore
|
||||
// never leaves a gap where the two networks meet. A small quad at every tile
|
||||
// centre covers a junction, or a stub for a lone tile.
|
||||
buildRoads(view) {
|
||||
const arr = [];
|
||||
const roads = view.roads;
|
||||
if (!roads || roads.size === 0 || !view.topology) {
|
||||
this._upload(this.roads, arr);
|
||||
const roadArr = [];
|
||||
const railArr = [];
|
||||
this.roads.texture = TEXTURE_ROAD;
|
||||
this.railways.texture = TEXTURE_RAIL;
|
||||
this.buildFlatTransport(view);
|
||||
const roads = view.roads || new Set();
|
||||
const railways = view.railways || new Set();
|
||||
if ((roads.size === 0 && railways.size === 0) || !view.topology) {
|
||||
this._upload(this.roads, roadArr, undefined, ROAD_FLOATS);
|
||||
this._upload(this.railways, railArr, undefined, ROAD_FLOATS);
|
||||
return;
|
||||
}
|
||||
const seed = view.seed | 0;
|
||||
// 0 = road, 1 = railway. A tile carries one kind.
|
||||
const kind = new Map();
|
||||
for (const k of roads) kind.set(k, 0);
|
||||
for (const k of railways) kind.set(k, 1);
|
||||
const topo = view.topology;
|
||||
const period = periodUnits(topo);
|
||||
const field = this._heightField(view);
|
||||
const lift = (point) => {
|
||||
if (field) point.h = field.at(point.x, point.y);
|
||||
return point;
|
||||
};
|
||||
const steps = 3;
|
||||
const drawn = new Set();
|
||||
for (const k of roads) {
|
||||
// The centre of every transport tile, its transport neighbours (of either
|
||||
// kind), and its coords, so a link can be curved through the tiles on either
|
||||
// side of it and a wrapped seam link stays one step.
|
||||
const coordsOf = new Map();
|
||||
const centres = new Map();
|
||||
for (const k of kind.keys()) {
|
||||
const coords = parseKey(k);
|
||||
const centre = lift(mapToLocal(coords.x, coords.y));
|
||||
coordsOf.set(k, coords);
|
||||
centres.set(k, lift(mapToLocal(coords.x, coords.y)));
|
||||
}
|
||||
const neighboursOf = new Map();
|
||||
for (const k of kind.keys()) {
|
||||
const coords = coordsOf.get(k);
|
||||
const list = [];
|
||||
for (const neighbour of topo.neighbours(coords.x, coords.y)) {
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
if (!roads.has(nk)) continue;
|
||||
if (kind.has(nk)) list.push(nk);
|
||||
}
|
||||
neighboursOf.set(k, list);
|
||||
}
|
||||
const arrFor = (k) => (kind.get(k) === 0 ? roadArr : railArr);
|
||||
const drawn = new Set();
|
||||
const links = new Map();
|
||||
const bump = (k) => links.set(k, (links.get(k) || 0) + 1);
|
||||
const at = (origin, originKey, otherKey) => {
|
||||
const delta = topo.pixelDelta(coordsOf.get(originKey), coordsOf.get(otherKey));
|
||||
return { x: origin.x + delta.x, y: origin.y + delta.y };
|
||||
};
|
||||
for (const k of kind.keys()) {
|
||||
for (const nk of neighboursOf.get(k)) {
|
||||
const pair = k < nk ? `${k}|${nk}` : `${nk}|${k}`;
|
||||
if (drawn.has(pair)) continue;
|
||||
drawn.add(pair);
|
||||
bump(k);
|
||||
bump(nk);
|
||||
const from = centres.get(k);
|
||||
const to = lift(at(from, k, nk));
|
||||
// A Catmull-Rom tangent at a shared centre: continue through the tile
|
||||
// on the other side, so every link meeting there leaves in the same
|
||||
// direction (C1 continuity). Because the neighbour list mixes roads and
|
||||
// railways, the tangent is right across an upgrade too.
|
||||
const beforeKey = this._continuationKey(neighboursOf.get(k), k, nk, coordsOf, topo);
|
||||
const afterKey = this._continuationKey(neighboursOf.get(nk), nk, k, coordsOf, topo);
|
||||
const before = beforeKey ? at(from, k, beforeKey) : from;
|
||||
const after = afterKey ? at(to, nk, afterKey) : to;
|
||||
const c1 = { x: from.x + (to.x - before.x) / 6, y: from.y + (to.y - before.y) / 6 };
|
||||
const c2 = { x: to.x - (after.x - from.x) / 6, y: to.y - (after.y - from.y) / 6 };
|
||||
const points = cubicBezier(from, c1, c2, to, ROAD_CURVE_SEGMENTS).map(lift);
|
||||
const same = kind.get(k) === kind.get(nk);
|
||||
this._transportBand(points, arrFor(k), same ? null : arrFor(nk));
|
||||
}
|
||||
}
|
||||
// A transport tile joined to nothing has no link to draw, so give it a stub.
|
||||
// A plain run or bend needs no cap: the bands overshoot the centre and
|
||||
// overlap, so the path stays continuous. A real junction (three or more
|
||||
// links) gets a small uniform patch to fill the middle.
|
||||
for (const k of kind.keys()) {
|
||||
const count = links.get(k) || 0;
|
||||
if (count === 1 || count === 2) continue;
|
||||
const coords = coordsOf.get(k);
|
||||
const point = lift(mapToLocal(coords.x, coords.y));
|
||||
const arr = arrFor(k);
|
||||
if (count === 0) this._transportCappedJoint(arr, point);
|
||||
else this._transportJunction(arr, point);
|
||||
}
|
||||
this._upload(this.roads, roadArr, undefined, ROAD_FLOATS);
|
||||
this._upload(this.railways, railArr, undefined, ROAD_FLOATS);
|
||||
}
|
||||
|
||||
// The flat political/economic transport layer: straight solid strokes from
|
||||
// tile centre to tile centre, dark grey for roads and brown for railways, with
|
||||
// no curves and no textures. A mixed link changes colour at the border. A
|
||||
// small square at every centre hides the notches where strokes meet.
|
||||
buildFlatTransport(view) {
|
||||
const arr = [];
|
||||
const roads = view.roads || new Set();
|
||||
const railways = view.railways || new Set();
|
||||
if ((roads.size === 0 && railways.size === 0) || !view.topology) {
|
||||
this._upload(this.flatTransport, arr);
|
||||
return;
|
||||
}
|
||||
const kind = new Map();
|
||||
for (const k of roads) kind.set(k, FLAT_ROAD_COLOUR);
|
||||
for (const k of railways) kind.set(k, FLAT_RAIL_COLOUR);
|
||||
const topo = view.topology;
|
||||
const coordsOf = new Map();
|
||||
const centres = new Map();
|
||||
for (const k of kind.keys()) {
|
||||
const coords = parseKey(k);
|
||||
coordsOf.set(k, coords);
|
||||
centres.set(k, mapToLocal(coords.x, coords.y));
|
||||
}
|
||||
const drawn = new Set();
|
||||
for (const k of kind.keys()) {
|
||||
const from = centres.get(k);
|
||||
const coords = coordsOf.get(k);
|
||||
for (const neighbour of topo.neighbours(coords.x, coords.y)) {
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
if (!kind.has(nk)) continue;
|
||||
const pair = k < nk ? `${k}|${nk}` : `${nk}|${k}`;
|
||||
if (drawn.has(pair)) continue;
|
||||
drawn.add(pair);
|
||||
const delta = topo.pixelDelta(coords, neighbour);
|
||||
const target = lift({ x: centre.x + delta.x, y: centre.y + delta.y });
|
||||
// Cross the border exactly where it is now: the midpoint of the shared
|
||||
// wavy edge, not the ideal straight boundary.
|
||||
const [idealA, idealB] = nearestCorners(centre, target);
|
||||
const mid = lift(edgePoint(seed, idealA, idealB, 0.5, period));
|
||||
// Half the road drifts in from each tile centre towards that midpoint.
|
||||
const points = [centre];
|
||||
for (let j = 1; j <= steps; j++) {
|
||||
points.push(lift(bentPath(seed, centre, mid, 0x5a17, j / steps)));
|
||||
const mid = { x: from.x + delta.x / 2, y: from.y + delta.y / 2 };
|
||||
const target = { x: from.x + delta.x, y: from.y + delta.y };
|
||||
const here = kind.get(k);
|
||||
const there = kind.get(nk);
|
||||
if (here === there) {
|
||||
this._segment(arr, from, target, FLAT_TRANSPORT_WIDTH, here);
|
||||
} else {
|
||||
this._segment(arr, from, mid, FLAT_TRANSPORT_WIDTH, here);
|
||||
this._segment(arr, mid, target, FLAT_TRANSPORT_WIDTH, there);
|
||||
}
|
||||
for (let j = steps - 1; j >= 1; j--) {
|
||||
points.push(lift(bentPath(seed, target, mid, 0x5a17, j / steps)));
|
||||
}
|
||||
points.push(target);
|
||||
this._roadBand(arr, points, 5, ROAD);
|
||||
}
|
||||
}
|
||||
for (const k of roads) {
|
||||
const coords = parseKey(k);
|
||||
this._roadJoint(arr, lift(mapToLocal(coords.x, coords.y)));
|
||||
// Cap every centre so the strokes join cleanly, using the tile's own colour.
|
||||
for (const k of kind.keys()) {
|
||||
const point = centres.get(k);
|
||||
const r = FLAT_TRANSPORT_WIDTH / 2;
|
||||
this._quad(arr, [
|
||||
{ x: point.x - r, y: point.y - r },
|
||||
{ x: point.x + r, y: point.y - r },
|
||||
{ x: point.x + r, y: point.y + r },
|
||||
{ x: point.x - r, y: point.y + r },
|
||||
], kind.get(k));
|
||||
}
|
||||
this._upload(this.roads, arr);
|
||||
this._upload(this.flatTransport, arr);
|
||||
}
|
||||
|
||||
// A centered band along a polyline. Both edges are mitered (and consecutive
|
||||
// quads share them), so a bend is filled rather than left as a wedge.
|
||||
_roadBand(arr, points, width, colour) {
|
||||
// The transport neighbour of `originKey` that best continues the road arriving
|
||||
// from `forwardKey`: the one most nearly opposite it, so the Catmull-Rom
|
||||
// tangent runs before -> origin -> forward. Null at a dead end, which gives a
|
||||
// straight tangent out of the stub. Deltas are wrapped, so the choice is right
|
||||
// even across the cylindrical seam.
|
||||
_continuationKey(list, originKey, forwardKey, coordsOf, topo) {
|
||||
const originCoords = coordsOf.get(originKey);
|
||||
const forward = topo.pixelDelta(originCoords, coordsOf.get(forwardKey));
|
||||
const forwardLength = Math.hypot(forward.x, forward.y) || 1;
|
||||
let best = null;
|
||||
let bestAlign = Infinity;
|
||||
for (const nk of list) {
|
||||
if (nk === forwardKey) continue;
|
||||
const delta = topo.pixelDelta(originCoords, coordsOf.get(nk));
|
||||
const length = Math.hypot(delta.x, delta.y) || 1;
|
||||
const align = (delta.x * forward.x + delta.y * forward.y) / (length * forwardLength);
|
||||
if (align < bestAlign) {
|
||||
bestAlign = align;
|
||||
best = nk;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// A centered, mitered band along a polyline. v runs along the polyline (one
|
||||
// texture repeat per TRANSPORT_TEXTURE_LENGTH world pixels) and u spans the
|
||||
// quad's width, clipped to the texture's opaque band. Consecutive quads share
|
||||
// their edges and their v, so the pattern runs continuously. Both ends are
|
||||
// extended half a road width past the tile centre, so neighbouring links
|
||||
// overlap instead of leaving a wedge.
|
||||
//
|
||||
// `secondArr` (optional) receives the second half of the band, split at the
|
||||
// border. That is how a road link meets a railway link: the geometry stays one
|
||||
// continuous curve, but the near half is textured as a road and the far half
|
||||
// as a railway.
|
||||
_transportBand(points, firstArr, secondArr = null) {
|
||||
if (points.length < 2) return;
|
||||
const half = TRANSPORT_WIDTH / 2;
|
||||
const extended = [this._extend(points[1], points[0], half)];
|
||||
for (const point of points) extended.push(point);
|
||||
extended.push(this._extend(points[points.length - 2], points[points.length - 1], half));
|
||||
points = extended;
|
||||
const n = points.length;
|
||||
if (n < 2) return;
|
||||
const half = width / 2;
|
||||
const normals = [];
|
||||
const lengths = [];
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
lengths.push(length);
|
||||
normals.push({ x: -dy / length, y: dx / length });
|
||||
}
|
||||
const offsets = [];
|
||||
const distance = [0];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const before = normals[i === 0 ? 0 : i - 1];
|
||||
const after = normals[i === n - 1 ? n - 2 : i];
|
||||
@@ -1334,31 +1510,77 @@ export class GLMapRenderer {
|
||||
if (denom < 0.2) denom = 0.2;
|
||||
const scale = half / denom;
|
||||
offsets.push({ x: (before.x + after.x) * scale, y: (before.y + after.y) * scale });
|
||||
if (i > 0) distance.push(distance[i - 1] + lengths[i - 1]);
|
||||
}
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const quads = n - 1;
|
||||
const split = secondArr ? Math.ceil(quads / 2) : quads;
|
||||
for (let i = 0; i < quads; i++) {
|
||||
const arr = i < split ? firstArr : secondArr;
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
const oa = offsets[i];
|
||||
const ob = offsets[i + 1];
|
||||
this._quad(arr, [
|
||||
{ x: a.x + oa.x, y: a.y + oa.y, h: a.h },
|
||||
{ x: b.x + ob.x, y: b.y + ob.y, h: b.h },
|
||||
{ x: b.x - ob.x, y: b.y - ob.y, h: b.h },
|
||||
{ x: a.x - oa.x, y: a.y - oa.y, h: a.h },
|
||||
], colour);
|
||||
const va = distance[i] / TRANSPORT_TEXTURE_LENGTH;
|
||||
const vb = distance[i + 1] / TRANSPORT_TEXTURE_LENGTH;
|
||||
this._transportQuad(arr, [
|
||||
{ x: a.x + oa.x, y: a.y + oa.y, h: a.h, u: TRANSPORT_U0, v: va },
|
||||
{ x: b.x + ob.x, y: b.y + ob.y, h: b.h, u: TRANSPORT_U0, v: vb },
|
||||
{ x: b.x - ob.x, y: b.y - ob.y, h: b.h, u: TRANSPORT_U1, v: vb },
|
||||
{ x: a.x - oa.x, y: a.y - oa.y, h: a.h, u: TRANSPORT_U1, v: va },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// A small square that hides the notch where several roads meet at a centre.
|
||||
_roadJoint(arr, point) {
|
||||
const r = 2.5;
|
||||
// A point `distance` beyond `to` along the direction `to - from`, at the same
|
||||
// height. Used to run a band a little past a tile centre so it melts into the
|
||||
// neighbouring link's band.
|
||||
_extend(from, to, distance) {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
return { x: to.x + (dx / length) * distance, y: to.y + (dy / length) * distance, h: to.h };
|
||||
}
|
||||
|
||||
// A stub for a transport tile with no neighbours, so a lone road or railway
|
||||
// still shows on the map.
|
||||
_transportCappedJoint(arr, point) {
|
||||
const r = TRANSPORT_WIDTH / 2;
|
||||
const h = point.h;
|
||||
this._quad(arr, [
|
||||
{ x: point.x - r, y: point.y - r, h },
|
||||
{ x: point.x - r, y: point.y + r, h },
|
||||
{ x: point.x + r, y: point.y + r, h },
|
||||
{ x: point.x + r, y: point.y - r, h },
|
||||
], ROAD);
|
||||
this._transportQuad(arr, [
|
||||
{ x: point.x - r, y: point.y - r, h, u: TRANSPORT_U0, v: 0 },
|
||||
{ x: point.x + r, y: point.y - r, h, u: TRANSPORT_U1, v: 0 },
|
||||
{ x: point.x + r, y: point.y + r, h, u: TRANSPORT_U1, v: 1 },
|
||||
{ x: point.x - r, y: point.y + r, h, u: TRANSPORT_U0, v: 1 },
|
||||
]);
|
||||
}
|
||||
|
||||
// A small round patch at a three-or-more-way junction. Every vertex samples
|
||||
// the same spot of the strip (its asphalt/grey body), so the patch is a flat
|
||||
// colour with no direction of its own and never reads as a straight road tile.
|
||||
_transportJunction(arr, point) {
|
||||
const r = TRANSPORT_WIDTH / 2;
|
||||
const sides = 10;
|
||||
const push = (x, y) => arr.push(x, y, 0.3, 0.5, point.h, 1, 1, 1, 1);
|
||||
for (let i = 0; i < sides; i++) {
|
||||
const a = (i / sides) * Math.PI * 2;
|
||||
const b = ((i + 1) / sides) * Math.PI * 2;
|
||||
push(point.x, point.y);
|
||||
push(point.x + Math.cos(a) * r, point.y + Math.sin(a) * r);
|
||||
push(point.x + Math.cos(b) * r, point.y + Math.sin(b) * r);
|
||||
}
|
||||
}
|
||||
|
||||
// Two triangles from a quad of { x, y, h, u, v } corners, tinted white so the
|
||||
// transport texture shows as authored.
|
||||
_transportQuad(arr, corners) {
|
||||
const push = (corner) =>
|
||||
arr.push(corner.x, corner.y, corner.u, corner.v, corner.h || 0, 1, 1, 1, 1);
|
||||
push(corners[0]);
|
||||
push(corners[1]);
|
||||
push(corners[2]);
|
||||
push(corners[0]);
|
||||
push(corners[2]);
|
||||
push(corners[3]);
|
||||
}
|
||||
|
||||
// Draws each nation's coastline/border as one continuous stroked outline.
|
||||
@@ -1950,6 +2172,22 @@ export class GLMapRenderer {
|
||||
gl.vertexAttribPointer(this.aHeight, 1, gl.FLOAT, false, stride, 8);
|
||||
gl.enableVertexAttribArray(this.aColour);
|
||||
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 12);
|
||||
} else if (floats === ROAD_FLOATS) {
|
||||
// Transport quads carry a UV and a tint on top of position and height.
|
||||
// The strip is bound to the atlas sampler, which the uMode 2 shader branch
|
||||
// reads instead of the terrain atlas.
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.textures[pass.texture]);
|
||||
gl.uniform1i(this.uAtlas, UNIT_ATLAS);
|
||||
gl.enableVertexAttribArray(this.aUV);
|
||||
gl.vertexAttribPointer(this.aUV, 2, gl.FLOAT, false, stride, 8);
|
||||
gl.disableVertexAttribArray(this.aWorld);
|
||||
gl.disableVertexAttribArray(this.aOverlay);
|
||||
gl.disableVertexAttribArray(this.aSlope);
|
||||
gl.enableVertexAttribArray(this.aHeight);
|
||||
gl.vertexAttribPointer(this.aHeight, 1, gl.FLOAT, false, stride, 16);
|
||||
gl.enableVertexAttribArray(this.aColour);
|
||||
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 20);
|
||||
} else {
|
||||
gl.enableVertexAttribArray(this.aUV);
|
||||
gl.vertexAttribPointer(this.aUV, 2, gl.FLOAT, false, stride, 8);
|
||||
@@ -2047,8 +2285,14 @@ export class GLMapRenderer {
|
||||
}
|
||||
if (political) this._drawPass(this.politicalFill, 1, shifts, period, rows);
|
||||
else if (economic) this._drawPass(this.economicFill, 1, shifts, period, rows);
|
||||
// Roads would only clutter the flat colours; borders stay.
|
||||
if (!political && !economic) this._drawPass(this.roads, 1, shifts, period);
|
||||
// Roads and railways: textured curve quads on the terrain map, plain solid
|
||||
// strokes (dark grey / brown) on the flat political and economic maps.
|
||||
if (!political && !economic) {
|
||||
this._drawPass(this.roads, 2, shifts, period);
|
||||
this._drawPass(this.railways, 2, shifts, period);
|
||||
} else {
|
||||
this._drawPass(this.flatTransport, 1, shifts, period);
|
||||
}
|
||||
this._drawPass(this.borders, 1, shifts, period);
|
||||
this._drawFog(bufferWidth, bufferHeight, shifts, period, rows);
|
||||
this._drawPass(this.regionOverlay, 1, shifts, period);
|
||||
|
||||
@@ -19,6 +19,8 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
|
||||
const unitSources = units.sources || [];
|
||||
const campaigns = breakdown.campaignUpkeep || { total: 0, sources: [] };
|
||||
const campaignSources = campaigns.sources || [];
|
||||
const transport = breakdown.transportUpkeep || { total: 0, sources: [] };
|
||||
const transportSources = transport.sources || [];
|
||||
const net = breakdown.net || 0;
|
||||
|
||||
$list.empty();
|
||||
@@ -86,6 +88,11 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
|
||||
detailRow(`${source.label} ×${source.count}`, -source.amount, "expense-detail")
|
||||
);
|
||||
}
|
||||
for (const source of transportSources) {
|
||||
$body.append(
|
||||
detailRow(`${source.label} ×${source.count}`, -source.amount, "expense-detail")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const $table = $("<table class='budget-table'></table>");
|
||||
|
||||
@@ -119,6 +119,8 @@ export class GameServer extends EventEmitter {
|
||||
return this._handleCancelTrain(civ, order);
|
||||
case "build":
|
||||
return this._handleBuild(civ, order);
|
||||
case "build_improvement":
|
||||
return this._handleBuildImprovement(civ, order);
|
||||
case "demolish":
|
||||
return this._handleDemolish(civ, order);
|
||||
case "government":
|
||||
@@ -227,6 +229,18 @@ export class GameServer extends EventEmitter {
|
||||
return this.state.requestDemolish(city.id, Number(order.building));
|
||||
}
|
||||
|
||||
// Lays a road or railway on a tile the player controls. GameState enforces
|
||||
// ownership, terrain and cost; the testing shortcut is only honoured locally.
|
||||
_handleBuildImprovement(civ, order) {
|
||||
const free = !!order.free && this.testing;
|
||||
return this.state.requestBuildImprovement(
|
||||
civ,
|
||||
coordsOf(order.coords),
|
||||
String(order.improvement || ""),
|
||||
free
|
||||
);
|
||||
}
|
||||
|
||||
_handleGovernment(civ, order) {
|
||||
return this.state.requestSetGovernment(civ, Number(order.government));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Player-buildable tile improvements. A tile carries at most one, laid down on
|
||||
// land the civilisation controls: a road or a railway, the latter twice as fast
|
||||
// again as a road. Both are bought outright and then cost an hourly maintenance
|
||||
// for every tile of the network a nation holds.
|
||||
|
||||
export const TRANSPORT_IMPROVEMENTS = [
|
||||
{
|
||||
id: "road",
|
||||
name: "Road",
|
||||
// The texture the client draws the tile with.
|
||||
texture: "road",
|
||||
// Travel multiplier once a unit is on the tile. Unimproved grass is 1.0, so
|
||||
// a road moves twice as fast and a railway twice as fast again (50% faster
|
||||
// than a road).
|
||||
movementCostMultiplier: 0.5,
|
||||
buildCost: 50_000_000,
|
||||
maintenance: 100_000,
|
||||
},
|
||||
{
|
||||
id: "railway",
|
||||
name: "Railway",
|
||||
texture: "rail",
|
||||
movementCostMultiplier: 0.25,
|
||||
buildCost: 150_000_000,
|
||||
maintenance: 300_000,
|
||||
},
|
||||
];
|
||||
|
||||
export const TRANSPORT_BY_ID = Object.fromEntries(
|
||||
TRANSPORT_IMPROVEMENTS.map((improvement) => [improvement.id, improvement])
|
||||
);
|
||||
|
||||
export function transportImprovement(id) {
|
||||
return TRANSPORT_BY_ID[id] || null;
|
||||
}
|
||||
@@ -16,3 +16,4 @@ export * from "./politics.js";
|
||||
export * from "./map_config.js";
|
||||
export * from "./terrain.js";
|
||||
export * from "./roads.js";
|
||||
export * from "./improvements.js";
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
// generation: they connect the cities with a sparse, near-shortest network and
|
||||
// make movement cost the same on every terrain they cross.
|
||||
|
||||
import { TRANSPORT_BY_ID } from "./improvements.js";
|
||||
|
||||
export const ROADS = {
|
||||
// Travel multiplier while a unit stands on a road tile. It is flat, unlike
|
||||
// the terrain multipliers, so a road levels out hills and forests.
|
||||
movementCostMultiplier: 1.0,
|
||||
// Travel multiplier while a unit stands on a road tile, taken from the road
|
||||
// improvement so the two stay in step. Like the railway it is flat, unlike the
|
||||
// terrain multipliers, so a road levels out hills and forests.
|
||||
movementCostMultiplier: TRANSPORT_BY_ID.road.movementCostMultiplier,
|
||||
// A road tile's generation cost is costBase raised to the terrain's own
|
||||
// movement multiplier, so hills and mountains are exponentially dearer to
|
||||
// run a road through than plains.
|
||||
|
||||
@@ -99,8 +99,14 @@ export class GameState {
|
||||
// attribute population, GDP and tax to the city that develops the land.
|
||||
this.tileRegion = new Map();
|
||||
this._regionByCity = new Map();
|
||||
// Tiles carrying a road (pre-generated city network).
|
||||
// Tiles carrying a road (pre-generated city network, plus anything the
|
||||
// players build).
|
||||
this.roads = new Set();
|
||||
// Tiles carrying a railway. A tile holds a road or a railway, never both.
|
||||
this.railways = new Set();
|
||||
// Bumped whenever the transport network changes, so the serialised road and
|
||||
// railway lists are rebuilt only when something actually moved.
|
||||
this._improvementVersion = 0;
|
||||
this.tilePopulation = new Map();
|
||||
// Per-tile GDP-per-capita multiplier, lowered by pillaging (default 1).
|
||||
this.tileGdpPenalty = new Map();
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
prerequisitesMet,
|
||||
technologyEffects,
|
||||
} from "../rules.js";
|
||||
import { key } from "../hex.js";
|
||||
import { key, parseKey } from "../hex.js";
|
||||
import { TRANSPORT_BY_ID } from "../data/improvements.js";
|
||||
import { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js";
|
||||
import { effectAt, mergeModifiers } from "./helpers.js";
|
||||
|
||||
@@ -102,9 +103,57 @@ export const economyMethods = {
|
||||
}
|
||||
// Running propaganda campaigns are upkeep too, paid hour by hour.
|
||||
total += this.getCampaignUpkeep(civ);
|
||||
// The transport network costs an hourly maintenance per tile held.
|
||||
total += this.getTransportUpkeep(civ);
|
||||
return total;
|
||||
},
|
||||
|
||||
// How many road and railway tiles a nation controls.
|
||||
_transportTiles(civ) {
|
||||
const counts = { road: 0, railway: 0 };
|
||||
const sets = [
|
||||
[this.roads, "road"],
|
||||
[this.railways, "railway"],
|
||||
];
|
||||
for (const [tiles, id] of sets) {
|
||||
for (const k of tiles) {
|
||||
if (this.civAt(parseKey(k)) === civ) counts[id] += 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
},
|
||||
|
||||
// The hourly maintenance of a nation's transport network: every road tile it
|
||||
// holds costs a flat fee, every railway tile three times as much.
|
||||
getTransportUpkeep(civ) {
|
||||
const counts = this._transportTiles(civ);
|
||||
return counts.road * TRANSPORT_BY_ID.road.maintenance +
|
||||
counts.railway * TRANSPORT_BY_ID.railway.maintenance;
|
||||
},
|
||||
|
||||
// The road and railway lines of the budget breakdown, dropping any empty kind.
|
||||
_transportUpkeepSources(civ) {
|
||||
const counts = this._transportTiles(civ);
|
||||
const sources = [];
|
||||
if (counts.road > 0) {
|
||||
sources.push({
|
||||
label: TRANSPORT_BY_ID.road.name,
|
||||
kind: "transport",
|
||||
count: counts.road,
|
||||
amount: counts.road * TRANSPORT_BY_ID.road.maintenance,
|
||||
});
|
||||
}
|
||||
if (counts.railway > 0) {
|
||||
sources.push({
|
||||
label: TRANSPORT_BY_ID.railway.name,
|
||||
kind: "transport",
|
||||
count: counts.railway,
|
||||
amount: counts.railway * TRANSPORT_BY_ID.railway.maintenance,
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
},
|
||||
|
||||
getBuildingUpkeep(civ) {
|
||||
let total = 0;
|
||||
for (const city of this.cities) {
|
||||
@@ -200,12 +249,15 @@ export const economyMethods = {
|
||||
const unitUpkeep = unitSources.reduce((sum, source) => sum + source.amount, 0);
|
||||
const campaignSources = this._campaignUpkeepSources(civ);
|
||||
const campaignUpkeep = campaignSources.reduce((sum, source) => sum + source.amount, 0);
|
||||
const transportSources = this._transportUpkeepSources(civ);
|
||||
const transportUpkeep = transportSources.reduce((sum, source) => sum + source.amount, 0);
|
||||
return {
|
||||
income: { rate, base, modifier, total: income, sources: incomeSources, regions },
|
||||
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
|
||||
unitUpkeep: { total: unitUpkeep, sources: unitSources, groups: unitSources },
|
||||
campaignUpkeep: { total: campaignUpkeep, sources: campaignSources, groups: campaignSources },
|
||||
net: income - buildingUpkeep - unitUpkeep - campaignUpkeep,
|
||||
transportUpkeep: { total: transportUpkeep, sources: transportSources, groups: transportSources },
|
||||
net: income - buildingUpkeep - unitUpkeep - campaignUpkeep - transportUpkeep,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// tick, and the travel-cost model the browser mirrors for prediction.
|
||||
|
||||
import { key } from "../hex.js";
|
||||
import { ROADS } from "../data/roads.js";
|
||||
import { TRANSPORT_BY_ID } from "../data/improvements.js";
|
||||
import {
|
||||
MIN_STEP_COST,
|
||||
DEFAULT_UNIT_SPEED,
|
||||
@@ -310,15 +310,31 @@ export const movementMethods = {
|
||||
if (this._isAirUnit(unit)) {
|
||||
return 1 / (unit.moveSpeed || this._unitSpeed(unit));
|
||||
}
|
||||
// A road levels the terrain: every road tile costs the same to cross.
|
||||
const multiplier = this.roads.has(key(coords.x, coords.y))
|
||||
? ROADS.movementCostMultiplier
|
||||
: this.terrainStats.movementCostMultiplier(coords);
|
||||
return multiplier / this._effectiveSpeedAt(unit, coords);
|
||||
// A road or railway levels the terrain: every such tile costs the same to
|
||||
// cross, a railway half again as cheap as a road.
|
||||
return this._tileMovementMultiplier(coords) / this._effectiveSpeedAt(unit, coords);
|
||||
},
|
||||
|
||||
// The cheapest transport multiplier a tile offers: its railway, else its road,
|
||||
// else the terrain's own. Flat across the whole tile, so a road levels hills
|
||||
// and forests alike.
|
||||
_tileMovementMultiplier(coords) {
|
||||
const k = key(coords.x, coords.y);
|
||||
if (this.railways.has(k)) return TRANSPORT_BY_ID.railway.movementCostMultiplier;
|
||||
if (this.roads.has(k)) return TRANSPORT_BY_ID.road.movementCostMultiplier;
|
||||
return this.terrainStats.movementCostMultiplier(coords);
|
||||
},
|
||||
|
||||
_fastestTileTravelHours(unit) {
|
||||
return this._minMovementCost / (this._unitSpeed(unit) * CONTROLLED_SPEED_MULTIPLIER);
|
||||
// A railway (or, failing that, a road) is the cheapest possible tile, so the
|
||||
// heuristic must use it when it beats the most generous terrain or A* could
|
||||
// overestimate and miss the cheapest route.
|
||||
const fastest = Math.min(
|
||||
this._minMovementCost,
|
||||
TRANSPORT_BY_ID.road.movementCostMultiplier,
|
||||
TRANSPORT_BY_ID.railway.movementCostMultiplier
|
||||
);
|
||||
return fastest / (this._unitSpeed(unit) * CONTROLLED_SPEED_MULTIPLIER);
|
||||
},
|
||||
|
||||
_heuristic(unit, from, to) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { key } from "../hex.js";
|
||||
import { ECONOMY } from "../data/economy.js";
|
||||
import { GOVERNMENTS } from "../data/governments.js";
|
||||
import { PILLAGE, pillageSteps } from "../data/combat.js";
|
||||
import { transportImprovement } from "../data/improvements.js";
|
||||
import { buildingBuildCost, buildingRefund } from "../rules.js";
|
||||
import { TRAINING_QUEUE_LIMIT } from "./constants.js";
|
||||
|
||||
@@ -116,6 +117,32 @@ export const orderMethods = {
|
||||
return true;
|
||||
},
|
||||
|
||||
// Lays a road or railway on a land tile the civilisation controls. A tile
|
||||
// holds at most one transport improvement, so building one replaces whatever
|
||||
// is already there; the full price is still paid. The cost is charged up front
|
||||
// and the tile changes at once, and afterwards the nation pays an hourly
|
||||
// maintenance for every road and railway tile it holds.
|
||||
requestBuildImprovement(civ, coords, id, free = false) {
|
||||
if (civ < 0 || civ >= this.civilisations.length) return false;
|
||||
const improvement = transportImprovement(id);
|
||||
if (!improvement || !coords) return false;
|
||||
if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false;
|
||||
const k = key(coords.x, coords.y);
|
||||
const tile = this.tiles[k];
|
||||
if (!tile || tile.terrainClass !== "Land") return false;
|
||||
if (this.civAt(coords) !== civ) return false;
|
||||
const current = this.railways.has(k) ? "railway" : this.roads.has(k) ? "road" : null;
|
||||
if (current === improvement.id) return false;
|
||||
if (!free && !this._spendBudget(civ, improvement.buildCost)) return false;
|
||||
this.roads.delete(k);
|
||||
this.railways.delete(k);
|
||||
if (improvement.id === "railway") this.railways.add(k);
|
||||
else this.roads.add(k);
|
||||
this._improvementVersion += 1;
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
requestDemolish(cityId, protoIndex) {
|
||||
const city = this.findCity(cityId);
|
||||
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
|
||||
|
||||
@@ -28,8 +28,12 @@ export const serializationMethods = {
|
||||
cities: this._serializeCities(),
|
||||
units: this._serializeUnits(),
|
||||
territory: this._serializedTerritory,
|
||||
// The pre-generated road tiles, as [x, y].
|
||||
roads: this._serializeRoads(),
|
||||
// The pre-generated road tiles, as [x, y], and the player-built railway
|
||||
// tiles, so the browser can draw the transport network. Roads change when
|
||||
// a player builds or replaces one, so both are memoised against the
|
||||
// improvement version.
|
||||
roads: this._serializeTransport(this.roads, "_serializedRoadsCache"),
|
||||
railways: this._serializeTransport(this.railways, "_serializedRailwaysCache"),
|
||||
// Each land tile's region: [x, y, cityId], so the browser attributes a
|
||||
// tile's GDP modifiers to the city that develops it.
|
||||
regions: this._serializeRegions(),
|
||||
@@ -80,6 +84,7 @@ export const serializationMethods = {
|
||||
regions: this._regionVersion,
|
||||
population: this._populationVersion,
|
||||
ethnicity: this._ethnicityVersion,
|
||||
improvements: this._improvementVersion,
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -176,16 +181,19 @@ export const serializationMethods = {
|
||||
});
|
||||
},
|
||||
|
||||
// A sparse [x, y] list of every road tile. Roads never change once generated,
|
||||
// so the list is serialized once.
|
||||
_serializeRoads() {
|
||||
if (this._serializedRoadsCache) return this._serializedRoadsCache;
|
||||
// A sparse [x, y] list of every tile in `tiles`. The list is memoised against
|
||||
// the improvement version: the pre-generated road network is laid once, and
|
||||
// player-built roads and railways change it in step, so an unchanged network
|
||||
// is not re-walked on every broadcast.
|
||||
_serializeTransport(tiles, cacheField) {
|
||||
const cached = this[cacheField];
|
||||
if (cached && cached.version === this._improvementVersion) return cached.value;
|
||||
const result = [];
|
||||
for (const k of this.roads) {
|
||||
for (const k of tiles) {
|
||||
const coords = parseKey(k);
|
||||
result.push([coords.x, coords.y]);
|
||||
}
|
||||
this._serializedRoadsCache = result;
|
||||
this[cacheField] = { version: this._improvementVersion, value: result };
|
||||
return result;
|
||||
},
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
distortedBoundary,
|
||||
edgePoint,
|
||||
bentPath,
|
||||
cubicBezier,
|
||||
latticeKey,
|
||||
vertexKey,
|
||||
} from "../client/js/map_view/distortion.js";
|
||||
@@ -152,6 +153,24 @@ export class DistortionTest extends TestCase {
|
||||
this.assertApprox(middle.x, 15, DISTORTION.road, "the bend stays small");
|
||||
}
|
||||
|
||||
async test_cubic_bezier_hits_the_endpoints_and_the_midpoint_formula() {
|
||||
const p0 = { x: 0, y: 0 };
|
||||
const p1 = { x: 10, y: 20 };
|
||||
const p2 = { x: 30, y: 20 };
|
||||
const p3 = { x: 40, y: 0 };
|
||||
const points = cubicBezier(p0, p1, p2, p3, 16);
|
||||
this.assertEqual(points.length, 17, "the curve is tessellated end to end");
|
||||
this.assertApprox(points[0].x, p0.x, 1e-9);
|
||||
this.assertApprox(points[0].y, p0.y, 1e-9);
|
||||
this.assertApprox(points[16].x, p3.x, 1e-9);
|
||||
this.assertApprox(points[16].y, p3.y, 1e-9);
|
||||
// The cubic midpoint is (P0 + 3P1 + 3P2 + P3) / 8.
|
||||
this.assertApprox(points[8].x, (p0.x + 3 * p1.x + 3 * p2.x + p3.x) / 8, 1e-9);
|
||||
this.assertApprox(points[8].y, (p0.y + 3 * p1.y + 3 * p2.y + p3.y) / 8, 1e-9);
|
||||
// It bows away from the straight chord.
|
||||
this.assertApprox(points[8].y, 15, 1e-9, "the curve winds");
|
||||
}
|
||||
|
||||
async test_a_straight_edge_is_the_line_between_its_warped_corners() {
|
||||
// `wavy` false drops the saw-tooth but keeps the shared corners, so open
|
||||
// water reads as a regular hexagon while still tiling with its neighbours.
|
||||
|
||||
@@ -1277,6 +1277,51 @@ export class GameScreenTest extends TestCase {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_build_button_opens_the_improvement_picker_and_sends_an_order() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 10_000_000_000);
|
||||
screen.onState(state.snapshot(0));
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
return state.civAt(cell) === 0 && !state.cityAt(cell) &&
|
||||
!state.roads.has(k) && !state.railways.has(k);
|
||||
});
|
||||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||||
screen._showTile(coords);
|
||||
this.assertFalse(env.$("#tile-build").hasClass("hidden"), "the build button is offered");
|
||||
env.$("#tile-build").click();
|
||||
const options = env.$("#tile-build-popover .build-option");
|
||||
this.assertEqual(options.length, 2, "road and railway are listed");
|
||||
this.assertFalse(options.eq(0).prop("disabled"), "the road option is clickable");
|
||||
this.assertFalse(options.eq(1).prop("disabled"), "the railway option is clickable");
|
||||
// Clicking the road choice (through the delegated handler) sends the order.
|
||||
options.eq(0).click();
|
||||
const order = network.orders[network.orders.length - 1];
|
||||
this.assertEqual(order.type, "build_improvement");
|
||||
this.assertApprox(order.coords[0], coords.x);
|
||||
this.assertApprox(order.coords[1], coords.y);
|
||||
this.assertEqual(order.improvement, "road");
|
||||
|
||||
// A tile that already carries the improvement offers only the upgrade.
|
||||
const road = Array.from(state.roads).map((k) => k.split(",").map(Number))[0];
|
||||
screen._showTile({ x: road[0], y: road[1] });
|
||||
env.$("#tile-build").click();
|
||||
const again = env.$("#tile-build-popover .build-option");
|
||||
this.assertTrue(again.eq(0).prop("disabled"), "the built road is greyed out");
|
||||
this.assertFalse(again.eq(1).prop("disabled"), "the railway upgrade stays available");
|
||||
again.eq(1).click();
|
||||
this.assertEqual(network.orders[network.orders.length - 1].improvement, "railway");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findEnemyTile(state) {
|
||||
|
||||
@@ -417,6 +417,46 @@ export class GameServerLoginTest extends TestCase {
|
||||
for (let i = 0; i < 30; i++) server.advanceMovement(1 / 60);
|
||||
this.assertGreater(unit.progressHours, 0, "the server advanced with nobody connected");
|
||||
}
|
||||
|
||||
test_build_improvement_order_lays_a_road_on_controlled_land() {
|
||||
const { server } = makeServer();
|
||||
server.peerCiv.set(1, 0);
|
||||
server.state.budgets.set(0, 10_000_000_000);
|
||||
// A land tile France owns but where the pre-generated network left no road.
|
||||
const coords = server.state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
return server.state.civAt(cell) === 0 && !server.state.roads.has(k);
|
||||
});
|
||||
this.assertNotNull(coords, "the map has a bare owned tile");
|
||||
this.assertTrue(
|
||||
server.handleOrder(1, {
|
||||
type: "build_improvement",
|
||||
coords: [coords.x, coords.y],
|
||||
improvement: "road",
|
||||
})
|
||||
);
|
||||
const k = `${coords.x},${coords.y}`;
|
||||
this.assertTrue(server.state.roads.has(k), "the road is laid");
|
||||
// The owner may replace it with a railway.
|
||||
this.assertTrue(
|
||||
server.handleOrder(1, {
|
||||
type: "build_improvement",
|
||||
coords: [coords.x, coords.y],
|
||||
improvement: "railway",
|
||||
})
|
||||
);
|
||||
this.assertTrue(server.state.railways.has(k));
|
||||
this.assertFalse(server.state.roads.has(k), "the road was replaced");
|
||||
// A player who owns nothing cannot build there.
|
||||
server.peerCiv.set(2, 1);
|
||||
this.assertFalse(
|
||||
server.handleOrder(2, {
|
||||
type: "build_improvement",
|
||||
coords: [coords.x, coords.y],
|
||||
improvement: "road",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameServerLobbyTest extends TestCase {
|
||||
|
||||
@@ -147,10 +147,14 @@ export class GameStateBuildingsTest extends TestCase {
|
||||
this.assertHas(breakdown, "income");
|
||||
this.assertHas(breakdown, "buildingUpkeep");
|
||||
this.assertHas(breakdown, "unitUpkeep");
|
||||
this.assertHas(breakdown, "transportUpkeep");
|
||||
this.assertApprox(breakdown.buildingUpkeep.total, 100);
|
||||
this.assertApprox(
|
||||
breakdown.unitUpkeep.total,
|
||||
state.getPlayerUpkeep(0) - breakdown.buildingUpkeep.total
|
||||
state.getPlayerUpkeep(0) -
|
||||
breakdown.buildingUpkeep.total -
|
||||
breakdown.campaignUpkeep.total -
|
||||
breakdown.transportUpkeep.total
|
||||
);
|
||||
const expected = state.getPlayerGdp(0) * ECONOMY.budgetGdpRate * (1 + breakdown.income.modifier);
|
||||
this.assertApprox(breakdown.income.total, expected);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import { TRANSPORT_BY_ID } from "../shared/data/improvements.js";
|
||||
|
||||
const BIG_BUDGET = 10_000_000_000;
|
||||
|
||||
// A land tile owned by civ 0 that carries no transport improvement yet.
|
||||
function bareOwnedLand(state, civ = 0) {
|
||||
return state.landCells.find((coords) => {
|
||||
const k = key(coords.x, coords.y);
|
||||
return state.civAt(coords) === civ && !state.roads.has(k) && !state.railways.has(k);
|
||||
});
|
||||
}
|
||||
|
||||
export class ImprovementsTest extends TestCase {
|
||||
test_building_a_road_costs_money_and_lays_the_tile() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertNotNull(coords, "the map has a bare owned land tile");
|
||||
const k = key(coords.x, coords.y);
|
||||
const before = state.getBudget(0);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
this.assertTrue(state.roads.has(k), "the road is on the tile");
|
||||
this.assertFalse(state.railways.has(k), "it is not a railway");
|
||||
this.assertApprox(before - state.getBudget(0), TRANSPORT_BY_ID.road.buildCost);
|
||||
}
|
||||
|
||||
test_a_railway_replaces_a_road_and_moves_twice_as_fast() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertNotNull(coords);
|
||||
const k = key(coords.x, coords.y);
|
||||
const unit = state._spawnUnit(coords, 0, state.protoUnits[0]);
|
||||
|
||||
state.roads.add(k);
|
||||
const roadHours = state._tileTravelHours(unit, coords);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
||||
this.assertTrue(state.railways.has(k), "the railway is on the tile");
|
||||
this.assertFalse(state.roads.has(k), "the road was replaced");
|
||||
const railHours = state._tileTravelHours(unit, coords);
|
||||
this.assertApprox(railHours * 2, roadHours, 1e-9, "a railway is 50% faster than a road");
|
||||
}
|
||||
|
||||
test_a_railway_moves_four_times_as_fast_as_open_grass() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const coords = state.landCells.find(
|
||||
(c) => state.tiles[key(c.x, c.y)].terrainType === "Land" && state.civAt(c) === 0
|
||||
);
|
||||
this.assertNotNull(coords, "the map has owned grass");
|
||||
const unit = state._spawnUnit(coords, 0, state.protoUnits[0]);
|
||||
state.roads.delete(key(coords.x, coords.y));
|
||||
state.railways.delete(key(coords.x, coords.y));
|
||||
const open = state._tileTravelHours(unit, coords);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
||||
const rail = state._tileTravelHours(unit, coords);
|
||||
this.assertApprox(rail * 4, open, 1e-9, "a railway is four times grass speed");
|
||||
}
|
||||
|
||||
test_only_a_controlled_land_tile_may_be_improved() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const sea = Object.keys(state.tiles)
|
||||
.map((k) => state.tiles[k])
|
||||
.find((tile) => tile.terrainClass === "Sea");
|
||||
this.assertNotNull(sea, "the map has a sea tile");
|
||||
// The exact coords of the sea tile.
|
||||
const seaEntry = Object.entries(state.tiles).find(([, tile]) => tile === sea);
|
||||
const [sx, sy] = seaEntry[0].split(",").map(Number);
|
||||
this.assertFalse(state.requestBuildImprovement(0, { x: sx, y: sy }, "road"), "no road at sea");
|
||||
|
||||
// A tile owned by another civilisation is off limits.
|
||||
const foreign = state.landCells.find((c) => state.civAt(c) === 1);
|
||||
if (foreign) {
|
||||
this.assertFalse(
|
||||
state.requestBuildImprovement(0, foreign, "road"),
|
||||
"cannot build on a foreign tile"
|
||||
);
|
||||
}
|
||||
// An unknown improvement id is refused.
|
||||
const owned = bareOwnedLand(state);
|
||||
this.assertFalse(state.requestBuildImprovement(0, owned, "monorail"));
|
||||
}
|
||||
|
||||
test_the_same_improvement_cannot_be_built_twice() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
const after = state.getBudget(0);
|
||||
this.assertFalse(state.requestBuildImprovement(0, coords, "road"), "already a road");
|
||||
this.assertApprox(state.getBudget(0), after, 1e-9, "nothing was charged");
|
||||
}
|
||||
|
||||
test_poor_nations_cannot_build() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 0);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertFalse(state.requestBuildImprovement(0, coords, "road"));
|
||||
this.assertFalse(state.roads.has(key(coords.x, coords.y)));
|
||||
}
|
||||
|
||||
test_maintenance_counts_roads_and_railways_the_nation_holds() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const coords = bareOwnedLand(state);
|
||||
const k = key(coords.x, coords.y);
|
||||
const before = state.getTransportUpkeep(0);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
this.assertApprox(
|
||||
state.getTransportUpkeep(0) - before,
|
||||
TRANSPORT_BY_ID.road.maintenance,
|
||||
1e-9,
|
||||
"a road adds its hourly maintenance"
|
||||
);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
||||
this.assertApprox(
|
||||
state.getTransportUpkeep(0) - before,
|
||||
TRANSPORT_BY_ID.railway.maintenance,
|
||||
1e-9,
|
||||
"the railway replaces the road's maintenance"
|
||||
);
|
||||
}
|
||||
|
||||
test_the_snapshot_ships_railways() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
||||
const snapshot = state.snapshot(0);
|
||||
this.assertTrue(
|
||||
snapshot.railways.some(([x, y]) => x === coords.x && y === coords.y),
|
||||
"the built railway is in the snapshot"
|
||||
);
|
||||
this.assertEqual(
|
||||
snapshot.versions.improvements,
|
||||
state._improvementVersion,
|
||||
"the improvement version is shipped"
|
||||
);
|
||||
}
|
||||
|
||||
test_a_city_starts_with_a_road_the_player_could_upgrade() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const city = cityOf(state, 0);
|
||||
const coords = { x: city.coords.x, y: city.coords.y };
|
||||
const k = key(coords.x, coords.y);
|
||||
this.assertTrue(state.roads.has(k), "a city carries a road");
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
||||
this.assertTrue(state.railways.has(k), "the city road becomes a railway");
|
||||
this.assertFalse(state.roads.has(k));
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,26 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_renders_railways_as_texture_quads() {
|
||||
const state = smallState();
|
||||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||||
const capitalKey = `${capital.coords.x},${capital.coords.y}`;
|
||||
const neighbour = state.topology.neighbours(capital.coords.x, capital.coords.y)[0];
|
||||
state.railways.add(capitalKey);
|
||||
state.railways.add(`${neighbour.x},${neighbour.y}`);
|
||||
state._improvementVersion += 1;
|
||||
const { env, map, snapshot } = await this.build(state);
|
||||
try {
|
||||
this.assertSize(map.railways, snapshot.railways.length, "the snapshot's railways are mirrored");
|
||||
this.assertTrue(map.railways.has(capitalKey), "the capital carries a railway");
|
||||
const segs = env.$("#layer-roads .rail-seg");
|
||||
this.assertGreater(segs.length, 0, "railways are drawn as quads");
|
||||
this.assertTrue((segs.first().attr("style") || "").includes("rail.png"), "the rail texture is used");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_unit_views_show_their_owners_flag() {
|
||||
const state = smallState();
|
||||
const { env } = await this.build(state);
|
||||
|
||||
+15
-1
@@ -58,7 +58,7 @@ export class RoadsTest extends TestCase {
|
||||
const before = state._tileTravelHours(unit, hill);
|
||||
state.roads.add(key(hill.x, hill.y));
|
||||
const after = state._tileTravelHours(unit, hill);
|
||||
this.assertLess(after, before, "a road makes the hill as cheap as plains");
|
||||
this.assertLess(after, before, "a road levels the hill below open ground");
|
||||
this.assertApprox(
|
||||
after,
|
||||
ROADS.movementCostMultiplier / state._effectiveSpeedAt(unit, hill),
|
||||
@@ -66,6 +66,20 @@ export class RoadsTest extends TestCase {
|
||||
);
|
||||
}
|
||||
|
||||
test_roads_move_twice_as_fast_as_open_grass() {
|
||||
const state = smallState();
|
||||
const grass = state.landCells.find(
|
||||
(coords) => state.tiles[key(coords.x, coords.y)].terrainType === "Land"
|
||||
);
|
||||
this.assertNotNull(grass, "the map has open grass");
|
||||
const unit = state._spawnUnit(grass, 0, state.protoUnits[0]);
|
||||
const open = state._tileTravelHours(unit, grass);
|
||||
state.roads.add(key(grass.x, grass.y));
|
||||
const road = state._tileTravelHours(unit, grass);
|
||||
this.assertApprox(road * 2, open, 1e-9, "a road halves the travel time");
|
||||
this.assertApprox(ROADS.movementCostMultiplier, 0.5, 1e-9);
|
||||
}
|
||||
|
||||
test_greedy_spanner_drops_links_the_others_already_cover() {
|
||||
// Four collinear points. The direct long links are covered by the chain of
|
||||
// unit links, so only the three short ones survive.
|
||||
|
||||
@@ -195,6 +195,41 @@ export class ServerProtocolTest extends TestCase {
|
||||
});
|
||||
}
|
||||
|
||||
async test_build_improvement_order_is_applied_and_broadcast() {
|
||||
await withServer(async (port, server) => {
|
||||
const client = makeClient(`ws://127.0.0.1:${port}/ws`);
|
||||
await client.open;
|
||||
client.send({ t: "login", name: "Host", password: "pw" });
|
||||
await client.next("assigned");
|
||||
client.send({
|
||||
t: "configure_game",
|
||||
setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] },
|
||||
});
|
||||
await client.next("game_state");
|
||||
|
||||
const state = server.gameServer.state;
|
||||
state.budgets.set(0, 1_000_000_000_000);
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
return state.civAt(cell) === 0 && !state.roads.has(k) && !state.railways.has(k);
|
||||
});
|
||||
this.assertNotNull(coords, "the map has a bare owned land tile");
|
||||
client.send({
|
||||
t: "order",
|
||||
order: { type: "build_improvement", coords: [coords.x, coords.y], improvement: "road" },
|
||||
});
|
||||
|
||||
const snapshot = await client.next("game_state");
|
||||
const k = `${coords.x},${coords.y}`;
|
||||
this.assertTrue(
|
||||
snapshot.state.roads.some(([x, y]) => x === coords.x && y === coords.y),
|
||||
"the built road is in the snapshot"
|
||||
);
|
||||
this.assertTrue(state.roads.has(k), "the server state carries the road");
|
||||
client.close();
|
||||
});
|
||||
}
|
||||
|
||||
async test_wrong_password_is_rejected() {
|
||||
await withServer(async (port) => {
|
||||
const host = makeClient(`ws://127.0.0.1:${port}/ws`);
|
||||
|
||||
@@ -14,17 +14,20 @@ function makeRenderer() {
|
||||
renderer.captured = {};
|
||||
renderer.borders = { name: "borders" };
|
||||
renderer.roads = { name: "roads" };
|
||||
renderer.railways = { name: "railways" };
|
||||
renderer.flatTransport = { name: "flatTransport" };
|
||||
renderer.regionOverlay = { name: "region" };
|
||||
renderer.politicalFill = { name: "political" };
|
||||
renderer.economicFill = { name: "economic" };
|
||||
renderer._upload = function (pass, arr) {
|
||||
renderer._upload = function (pass, arr, usage, floats) {
|
||||
this.captured[pass.name] = arr;
|
||||
pass.count = arr.length / 8;
|
||||
pass.floats = floats;
|
||||
pass.count = arr.length / (floats || 8);
|
||||
};
|
||||
return renderer;
|
||||
}
|
||||
|
||||
function view(renderer, { size = 3, territory, regions, selectedRegion = null, roads = new Set(), terrainAt = null } = {}) {
|
||||
function view(renderer, { size = 3, territory, regions, selectedRegion = null, roads = new Set(), railways = new Set(), terrainAt = null } = {}) {
|
||||
const topology = new MapTopology({ x: size, y: size }, false);
|
||||
const tiles = {};
|
||||
for (let y = topology.originY; y < topology.originY + size; y++) {
|
||||
@@ -37,6 +40,7 @@ function view(renderer, { size = 3, territory, regions, selectedRegion = null, r
|
||||
topology,
|
||||
tiles,
|
||||
roads,
|
||||
railways,
|
||||
territory: new Map(territory),
|
||||
regions: new Map(regions),
|
||||
civilisations: [{ primaryColour: "#ff0000" }, { primaryColour: "#00ff00" }],
|
||||
@@ -209,14 +213,182 @@ export class WebglGeometryTest extends TestCase {
|
||||
this.assertEqual(verts, 9 * trianglesPerTile * 3, "each fan triangle is split in four");
|
||||
}
|
||||
|
||||
async test_linked_road_tiles_are_bands_not_straight_caps() {
|
||||
// A lone road tile is a single textured cap; adjacent tiles are drawn as a
|
||||
// continuous band with no straight tile stamped at the junction.
|
||||
const renderer = makeRenderer();
|
||||
renderer.buildRoads(view(renderer, { roads: new Set(["0,0"]) }));
|
||||
const lone = renderer.captured.roads.length;
|
||||
this.assertEqual(lone % 9, 0, "nine floats per transport vertex");
|
||||
this.assertEqual(lone, 6 * 9, "a lone tile is one quad");
|
||||
|
||||
const paired = makeRenderer();
|
||||
paired.buildRoads(view(paired, { roads: new Set(["0,0", "1,0"]) }));
|
||||
const link = paired.captured.roads.length;
|
||||
this.assertEqual(link % 9, 0, "nine floats per transport vertex");
|
||||
this.assertGreater(link, lone, "the link is a band, not caps");
|
||||
}
|
||||
|
||||
async test_road_and_railway_halves_meet_at_a_mixed_link() {
|
||||
const renderer = makeRenderer();
|
||||
renderer.buildRoads(view(renderer, {
|
||||
roads: new Set(["0,0"]),
|
||||
railways: new Set(["1,0"]),
|
||||
}));
|
||||
const road = renderer.captured.roads;
|
||||
const rail = renderer.captured.railways;
|
||||
this.assertGreater(road.length, 0, "the road half is drawn");
|
||||
this.assertGreater(rail.length, 0, "the rail half is drawn");
|
||||
const pointKeys = (arr) => {
|
||||
const set = new Set();
|
||||
for (let i = 0; i < arr.length; i += 9) {
|
||||
set.add(`${arr[i].toFixed(4)},${arr[i + 1].toFixed(4)}`);
|
||||
}
|
||||
return set;
|
||||
};
|
||||
const roadPoints = pointKeys(road);
|
||||
let shared = 0;
|
||||
for (const k of pointKeys(rail)) if (roadPoints.has(k)) shared += 1;
|
||||
this.assertGreater(shared, 0, "both halves share the border vertices");
|
||||
}
|
||||
|
||||
async test_flat_transport_is_solid_straight_strokes() {
|
||||
const renderer = makeRenderer();
|
||||
renderer.buildRoads(view(renderer, {
|
||||
roads: new Set(["0,0", "1,0"]),
|
||||
railways: new Set(["-1,0"]),
|
||||
}));
|
||||
const flat = renderer.captured.flatTransport;
|
||||
this.assertGreater(flat.length, 0, "the flat transport is built");
|
||||
this.assertEqual(flat.length % 8, 0, "eight floats per flat vertex");
|
||||
const colours = new Set();
|
||||
for (let i = 0; i < flat.length; i += 8) {
|
||||
colours.add(flat[i + 4].toFixed(4));
|
||||
this.assertEqual(flat[i + 2], 0, "the flat map ignores the relief");
|
||||
}
|
||||
this.assertTrue(colours.has((0x3a / 255).toFixed(4)), "roads are dark grey");
|
||||
this.assertTrue(colours.has((0x8a / 255).toFixed(4)), "railways are brown");
|
||||
}
|
||||
|
||||
async test_a_bent_road_is_a_curve_not_straight_stubs() {
|
||||
const renderer = makeRenderer();
|
||||
const topo = new MapTopology({ x: 5, y: 5 }, false);
|
||||
const a = { x: 0, y: 0 };
|
||||
const b = { x: 1, y: 0 };
|
||||
const A = mapToLocal(a.x, a.y);
|
||||
const B = mapToLocal(b.x, b.y);
|
||||
// The neighbour of b that bends the most away from the a->b line.
|
||||
let bend = null;
|
||||
let bestDeviation = -1;
|
||||
for (const n of topo.neighbours(b.x, b.y)) {
|
||||
if (n.x === a.x && n.y === a.y) continue;
|
||||
const C = mapToLocal(n.x, n.y);
|
||||
const dx = B.x - A.x;
|
||||
const dy = B.y - A.y;
|
||||
const deviation = Math.abs((C.x - A.x) * -dy + (C.y - A.y) * dx) / Math.hypot(dx, dy);
|
||||
if (deviation > bestDeviation) {
|
||||
bestDeviation = deviation;
|
||||
bend = n;
|
||||
}
|
||||
}
|
||||
this.assertGreater(bestDeviation, 5, "the map has a real bend");
|
||||
const roads = new Set([key(a.x, a.y), key(b.x, b.y), key(bend.x, bend.y)]);
|
||||
renderer.buildRoads(view(renderer, { size: 5, roads }));
|
||||
const arr = renderer.captured.roads;
|
||||
this.assertGreater(arr.length, 0, "the bend is drawn");
|
||||
|
||||
// Recover the band's centreline from each quad: corner0/corner3 average to
|
||||
// the link's start centre and corner1/corner2 to its end centre.
|
||||
const distanceToSegment = (p, s, e) => {
|
||||
const dx = e.x - s.x;
|
||||
const dy = e.y - s.y;
|
||||
const len2 = dx * dx + dy * dy || 1;
|
||||
let t = ((p.x - s.x) * dx + (p.y - s.y) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return Math.hypot(p.x - (s.x + dx * t), p.y - (s.y + dy * t));
|
||||
};
|
||||
const C = mapToLocal(bend.x, bend.y);
|
||||
let worst = 0;
|
||||
for (let i = 0; i + 45 <= arr.length; i += 54) {
|
||||
const p = { x: (arr[i] + arr[i + 45]) / 2, y: (arr[i + 1] + arr[i + 46]) / 2 };
|
||||
const q = { x: (arr[i + 9] + arr[i + 18]) / 2, y: (arr[i + 10] + arr[i + 19]) / 2 };
|
||||
for (const point of [p, q]) {
|
||||
const off = Math.min(
|
||||
distanceToSegment(point, A, B),
|
||||
distanceToSegment(point, B, C)
|
||||
);
|
||||
worst = Math.max(worst, off);
|
||||
}
|
||||
}
|
||||
this.assertGreater(worst, 1, "the centreline bows off the straight corners");
|
||||
}
|
||||
|
||||
async test_road_continuation_uses_the_opposite_neighbour() {
|
||||
const renderer = makeRenderer();
|
||||
const coordsOf = new Map([
|
||||
["a", { x: 0, y: 0 }],
|
||||
["b", { x: 1, y: 0 }],
|
||||
["c", { x: 2, y: 0 }],
|
||||
]);
|
||||
const topo = { pixelDelta: (from, to) => ({ x: to.x - from.x, y: to.y - from.y }) };
|
||||
this.assertEqual(
|
||||
renderer._continuationKey(["a", "b", "c"], "b", "c", coordsOf, topo),
|
||||
"a",
|
||||
"continues through the tile behind"
|
||||
);
|
||||
this.assertNull(
|
||||
renderer._continuationKey(["c"], "b", "c", coordsOf, topo),
|
||||
"a dead end has no continuation"
|
||||
);
|
||||
}
|
||||
|
||||
async test_seam_road_links_stay_local_and_do_not_span_the_world() {
|
||||
const renderer = makeRenderer();
|
||||
const topology = new MapTopology({ x: 20, y: 6 }, true);
|
||||
const a = { x: topology.originX, y: 0 };
|
||||
const A = mapToLocal(a.x, a.y);
|
||||
// The neighbour that lies on the far side of the wrap: raw mapToLocal puts
|
||||
// it a whole world away, but pixelDelta knows it is one step.
|
||||
let wrap = null;
|
||||
let rawDistance = 0;
|
||||
for (const n of topology.neighbours(a.x, a.y)) {
|
||||
const d = Math.hypot(mapToLocal(n.x, n.y).x - A.x, mapToLocal(n.x, n.y).y - A.y);
|
||||
if (d > rawDistance) {
|
||||
rawDistance = d;
|
||||
wrap = n;
|
||||
}
|
||||
}
|
||||
this.assertGreater(rawDistance, 400, "a neighbour wraps the seam");
|
||||
const tiles = {};
|
||||
for (let y = topology.originY; y < topology.originY + 6; y++) {
|
||||
for (let x = topology.originX; x < topology.originX + 20; x++) {
|
||||
tiles[key(x, y)] = { col: 0, terrainType: "Land" };
|
||||
}
|
||||
}
|
||||
renderer.buildRoads({
|
||||
seed: 1,
|
||||
topology,
|
||||
tiles,
|
||||
roads: new Set([key(a.x, a.y), key(wrap.x, wrap.y)]),
|
||||
territory: new Map(),
|
||||
regions: new Map(),
|
||||
civilisations: [{ primaryColour: "#ff0000" }],
|
||||
});
|
||||
const arr = renderer.captured.roads;
|
||||
this.assertGreater(arr.length, 0, "the seam link is drawn");
|
||||
const xs = [];
|
||||
for (let i = 0; i < arr.length; i += 9) xs.push(arr[i]);
|
||||
this.assertLess(Math.max(...xs) - Math.min(...xs), 200, "the link stays at the seam");
|
||||
}
|
||||
|
||||
async test_flat_layers_carry_the_relief_too() {
|
||||
const renderer = makeRenderer();
|
||||
const state = view(renderer, { roads: new Set(["0,0"]) });
|
||||
renderer.buildRoads(state);
|
||||
const arr = renderer.captured.roads;
|
||||
let lifted = false;
|
||||
for (let i = 0; i < arr.length; i += 8) {
|
||||
if (arr[i + 2] > 0) lifted = true;
|
||||
for (let i = 0; i < arr.length; i += 9) {
|
||||
if (arr[i + 4] > 0) lifted = true;
|
||||
}
|
||||
this.assertTrue(lifted, "road vertices ride the heightmap");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user