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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.loading-overlay.hidden { display: none; }
|
.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 {
|
.loading-panel {
|
||||||
width: min(720px, 100%);
|
width: min(720px, 100%);
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
@@ -282,14 +284,15 @@ input:focus, select:focus { border-color: var(--accent); }
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A single pre-generated road link, running from tile centre to tile centre. */
|
/* A single road or railway link, running from tile centre to tile centre. The
|
||||||
.road-seg {
|
texture (and its repeat) is set in JS; only the quad's shape is styled here. */
|
||||||
|
.road-seg,
|
||||||
|
.rail-seg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: #8a5a2b;
|
background-color: transparent;
|
||||||
border-radius: 2px;
|
transform-origin: 50% 0;
|
||||||
box-shadow: 0 0 1px rgba(0, 0, 0, 0.6);
|
|
||||||
transform-origin: 0 50%;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A ranged unit's bombardment: a red dashed line from the battery to the
|
/* 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;
|
font-weight: 700;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
/* Roads are hidden on the political map: flat country colours read better
|
/* On the flat political and economic maps the transport network stays visible,
|
||||||
without them, while the national borders stay to separate the nations. */
|
but as solid strokes instead of the road/rail textures. */
|
||||||
#map-world.political #layer-roads { display: none; }
|
#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-layer { pointer-events: none; z-index: 5; }
|
||||||
.city-label {
|
.city-label {
|
||||||
@@ -628,6 +638,38 @@ input:focus, select:focus { border-color: var(--accent); }
|
|||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
padding: 12px 18px;
|
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-section.hidden { display: none !important; }
|
||||||
.info-title { font-size: 17px; font-weight: 600; margin-bottom: 4px; }
|
.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; }
|
.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-ethnicity"></div>
|
||||||
<div id="tile-gdp"></div>
|
<div id="tile-gdp"></div>
|
||||||
<div id="tile-improvements"></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>
|
||||||
<div id="city-info" class="info-section hidden">
|
<div id="city-info" class="info-section hidden">
|
||||||
<div id="city-name" class="info-title"></div>
|
<div id="city-name" class="info-title"></div>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export class GameScreen {
|
|||||||
this.selectedCityId = 0;
|
this.selectedCityId = 0;
|
||||||
// The tile whose info panel is open, if any (right-click inspection).
|
// The tile whose info panel is open, if any (right-click inspection).
|
||||||
this._inspectedTile = null;
|
this._inspectedTile = null;
|
||||||
|
// The tile the improvement picker currently targets.
|
||||||
|
this._buildTarget = null;
|
||||||
this._trainingCityId = 0;
|
this._trainingCityId = 0;
|
||||||
this._cityBuildings = new Map();
|
this._cityBuildings = new Map();
|
||||||
this._population = new Map();
|
this._population = new Map();
|
||||||
@@ -82,6 +84,7 @@ export class GameScreen {
|
|||||||
this.capabilityModal = new CapabilityModal();
|
this.capabilityModal = new CapabilityModal();
|
||||||
this.feed = new GameFeed($("#game-feed"));
|
this.feed = new GameFeed($("#game-feed"));
|
||||||
this.$stackMenu = $("#stack-menu");
|
this.$stackMenu = $("#stack-menu");
|
||||||
|
this.$buildPopover = $("#tile-build-popover");
|
||||||
this.$statTooltip = $("#stat-tooltip");
|
this.$statTooltip = $("#stat-tooltip");
|
||||||
this._hoveredStat = null;
|
this._hoveredStat = null;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
effectValue,
|
effectValue,
|
||||||
pillageSteps,
|
pillageSteps,
|
||||||
ROADS,
|
ROADS,
|
||||||
|
TRANSPORT_IMPROVEMENTS,
|
||||||
AIR_STRIKE,
|
AIR_STRIKE,
|
||||||
} from "../../../shared/data.js";
|
} from "../../../shared/data.js";
|
||||||
import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors } from "../../../shared/rules.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("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
|
||||||
.on("mouseleave", () => this._hideStatTooltip());
|
.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.
|
// A click anywhere outside the stack menu dismisses it.
|
||||||
$(document).on("mousedown.stackmenu", (event) => {
|
$(document).on("mousedown.stackmenu", (event) => {
|
||||||
if (!this.$stackMenu.hasClass("hidden") && !$(event.target).closest("#stack-menu").length) {
|
if (!this.$stackMenu.hasClass("hidden") && !$(event.target).closest("#stack-menu").length) {
|
||||||
@@ -505,6 +533,10 @@ export const panelMethods = {
|
|||||||
} else if (this.selectedCityId) {
|
} else if (this.selectedCityId) {
|
||||||
const city = this._city(this.selectedCityId);
|
const city = this._city(this.selectedCityId);
|
||||||
if (city) this._showCity(city);
|
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})`);
|
$("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`);
|
||||||
this._renderTilePeople(k);
|
this._renderTilePeople(k);
|
||||||
this._renderTileGdp(owner >= 0 ? this._tileGdpBreakdown(coords, owner, city, tile) : null);
|
this._renderTileGdp(owner >= 0 ? this._tileGdpBreakdown(coords, owner, city, tile) : null);
|
||||||
const roads = this.map.roads && this.map.roads.has(k) ? [ROADS.improvementName] : [];
|
const transport = this.map.railways.has(k)
|
||||||
const improvements = city
|
? ["Railway"]
|
||||||
? Array.from(new Set([...(city.improvements || []), ...roads]))
|
: this.map.roads.has(k)
|
||||||
: roads;
|
? [ROADS.improvementName]
|
||||||
$("#tile-improvements").text(`Improvements: ${fmtImprovements(improvements)}`);
|
: [];
|
||||||
|
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
|
// 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();
|
this.territory = new Map();
|
||||||
// Each land tile's region, keyed "x,y" and mapped to the region's city id.
|
// Each land tile's region, keyed "x,y" and mapped to the region's city id.
|
||||||
this.regions = new Map();
|
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();
|
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.civilisations = [];
|
||||||
this.localCiv = -1;
|
this.localCiv = -1;
|
||||||
this.protoUnits = [];
|
this.protoUnits = [];
|
||||||
@@ -148,7 +150,7 @@ export class MapView {
|
|||||||
this._regionsSignature = null;
|
this._regionsSignature = null;
|
||||||
this._territoryVersion = undefined;
|
this._territoryVersion = undefined;
|
||||||
this._regionsVersion = undefined;
|
this._regionsVersion = undefined;
|
||||||
this._roadsSignature = null;
|
this._improvementsSignature = null;
|
||||||
this._exploredCount = -1;
|
this._exploredCount = -1;
|
||||||
this._visibleSignature = null;
|
this._visibleSignature = null;
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,22 @@ export function distortedCorners(seed, centre, period) {
|
|||||||
return points;
|
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
|
// 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
|
// 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
|
// 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;
|
return points;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A gently bent interior path from `from` to `to` at parameter t. The wobble is
|
// A cubic Bezier centreline sampled from `p0` to `p3`, with control points
|
||||||
// zero at both ends, so a road leaves a tile centre and reaches the border
|
// `p1`/`p2`. Roads are built from these so each link is a smooth curve; the
|
||||||
// exactly where its neighbouring tile's half does.
|
// caller shapes the control points from the tiles on either side, which is what
|
||||||
export function bentPath(seed, from, to, salt, t) {
|
// makes consecutive links meet at a shared centre with a shared tangent.
|
||||||
const x = from.x + (to.x - from.x) * t;
|
export function cubicBezier(p0, p1, p2, p3, segments = 14) {
|
||||||
const y = from.y + (to.y - from.y) * t;
|
const points = [];
|
||||||
const dx = to.x - from.x;
|
for (let i = 0; i <= segments; i++) {
|
||||||
const dy = to.y - from.y;
|
const t = i / segments;
|
||||||
const length = Math.hypot(dx, dy) || 1;
|
const mt = 1 - t;
|
||||||
const amount =
|
const b0 = mt * mt * mt;
|
||||||
signedEdgeHash(seed, latticeOf(from.x, from.y), latticeOf(to.x, to.y), salt) *
|
const b1 = 3 * mt * mt * t;
|
||||||
DISTORTION.road *
|
const b2 = 3 * mt * t * t;
|
||||||
envelope(t);
|
const b3 = t * t * t;
|
||||||
return { x: x - (dy / length) * amount, y: y + (dx / length) * amount };
|
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) {
|
applySnapshot(state) {
|
||||||
bootLog.step("Snapshot received from the server");
|
bootLog.step("Snapshot received from the server");
|
||||||
this.ensureTerrain(state.seed, state.mapConfig || null);
|
this.ensureTerrain(state.seed, state.mapConfig || null);
|
||||||
this.setRoads(state.roads || []);
|
this.setImprovements(state.roads || [], state.railways || [], state.versions && state.versions.improvements);
|
||||||
bootLog.step("Mirrored the road network");
|
bootLog.step("Mirrored the transport network");
|
||||||
this.localCiv = state.viewer;
|
this.localCiv = state.viewer;
|
||||||
this._applyTerritory(state.territory, state.versions && state.versions.territory);
|
this._applyTerritory(state.territory, state.versions && state.versions.territory);
|
||||||
bootLog.step("Mirrored the territory and borders");
|
bootLog.step("Mirrored the territory and borders");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { mapToLocal, key } from "../../../shared/hex.js";
|
import { mapToLocal, key } from "../../../shared/hex.js";
|
||||||
import { HexPathfinder } from "../../../shared/hex_pathfinder.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 { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js";
|
||||||
import { CAMERA_TILT, RESYNC_HOURS, PREDICTION_GRACE_HOURS } from "./constants.js";
|
import { CAMERA_TILT, RESYNC_HOURS, PREDICTION_GRACE_HOURS } from "./constants.js";
|
||||||
import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js";
|
import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js";
|
||||||
@@ -337,8 +337,15 @@ export const motionMethods = {
|
|||||||
const costCache = new Map();
|
const costCache = new Map();
|
||||||
const heuristicCache = new Map();
|
const heuristicCache = new Map();
|
||||||
const bestSpeed = Math.max(speed || proto.speed, 0.0001);
|
const bestSpeed = Math.max(speed || proto.speed, 0.0001);
|
||||||
const fastestHours =
|
// Mirror GameState._fastestTileTravelHours: a railway (or a road) may
|
||||||
this.terrainStats.minMovementCostMultiplier() / (bestSpeed * CONTROLLED_SPEED_MULTIPLIER);
|
// 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(
|
const iterator = new HexPathfinder().findPathIter(
|
||||||
start,
|
start,
|
||||||
goal,
|
goal,
|
||||||
@@ -497,9 +504,10 @@ export const motionMethods = {
|
|||||||
const k = key(coords.x, coords.y);
|
const k = key(coords.x, coords.y);
|
||||||
let pace = effective;
|
let pace = effective;
|
||||||
if (this.territory.get(k) === civ) pace *= CONTROLLED_SPEED_MULTIPLIER;
|
if (this.territory.get(k) === civ) pace *= CONTROLLED_SPEED_MULTIPLIER;
|
||||||
const multiplier = this.roads.has(k)
|
let multiplier;
|
||||||
? ROADS.movementCostMultiplier
|
if (this.railways.has(k)) multiplier = TRANSPORT_BY_ID.railway.movementCostMultiplier;
|
||||||
: this.terrainStats.movementCostMultiplier(coords);
|
else if (this.roads.has(k)) multiplier = TRANSPORT_BY_ID.road.movementCostMultiplier;
|
||||||
|
else multiplier = this.terrainStats.movementCostMultiplier(coords);
|
||||||
return multiplier / pace;
|
return multiplier / pace;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,11 +34,12 @@ export const terrainMethods = {
|
|||||||
this._terrainSignature = signature;
|
this._terrainSignature = signature;
|
||||||
this.seed = seed;
|
this.seed = seed;
|
||||||
this.mapConfig = config;
|
this.mapConfig = config;
|
||||||
// A new world brings a new road network; drop the old one (and the cache
|
// A new world brings a new transport network; drop the old one (and the
|
||||||
// signature) before the chunks are rebuilt, so setRoads always installs the
|
// cache signature) before the chunks are rebuilt, so setImprovements always
|
||||||
// new tiles.
|
// installs the new tiles.
|
||||||
this.roads = new Set();
|
this.roads = new Set();
|
||||||
this._roadsSignature = null;
|
this.railways = new Set();
|
||||||
|
this._improvementsSignature = null;
|
||||||
this.topology = new MapTopology(config.mapSize, config.topology === "cylindrical");
|
this.topology = new MapTopology(config.mapSize, config.topology === "cylindrical");
|
||||||
const generator = new MapGenerator(config, this.topology);
|
const generator = new MapGenerator(config, this.topology);
|
||||||
bootLog.measure("Generated the terrain, settlements and roads", () => {
|
bootLog.measure("Generated the terrain, settlements and roads", () => {
|
||||||
@@ -451,14 +452,17 @@ export const terrainMethods = {
|
|||||||
this.glRenderer.buildKnowledge(this);
|
this.glRenderer.buildKnowledge(this);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Installs the road network once. Roads never change once generated, so a
|
// Installs the transport network (roads and railways). Both change as players
|
||||||
// length check is enough to skip rebuilding the on-screen chunks.
|
// build improvements, so a signature over the tile counts and the server's
|
||||||
setRoads(entries) {
|
// improvement version decides whether the on-screen chunks need rebuilding.
|
||||||
const list = entries || [];
|
setImprovements(roads, railways, version) {
|
||||||
const signature = String(list.length);
|
const roadList = roads || [];
|
||||||
if (this._roadsSignature === signature) return;
|
const railList = railways || [];
|
||||||
this._roadsSignature = signature;
|
const signature = `${roadList.length}|${railList.length}|${version === undefined ? "" : version}`;
|
||||||
this.roads = new Set(list.map((entry) => key(entry[0], entry[1])));
|
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) {
|
if (this.glRenderer) {
|
||||||
this.glRenderer.buildRoads(this);
|
this.glRenderer.buildRoads(this);
|
||||||
return;
|
return;
|
||||||
@@ -469,34 +473,59 @@ export const terrainMethods = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Draws a brown line from the centre of every road tile to the centre of each
|
// Draws the transport network as texture quads: a strip runs from the centre
|
||||||
// of its road neighbours. The work is paid per visible chunk, so panning over
|
// of every transport tile to the centre of each of its transport neighbours.
|
||||||
// a huge network only ever builds the few hundred tiles on screen.
|
// 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) {
|
_buildRoads(rec) {
|
||||||
rec.roadsDirty = false;
|
rec.roadsDirty = false;
|
||||||
this._recycleLayer(rec.roadsEl, this._roadPool);
|
this._recycleLayer(rec.roadsEl, this._roadPool);
|
||||||
if (!this.roads || this.roads.size === 0) return;
|
const kind = new Map();
|
||||||
const width = 5;
|
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();
|
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) {
|
for (const k of rec.tileKeys) {
|
||||||
if (!this.roads.has(k)) continue;
|
if (!kind.has(k)) continue;
|
||||||
const coords = parseKey(k);
|
const coords = parseKey(k);
|
||||||
const center = mapToLocal(coords.x, coords.y);
|
const center = mapToLocal(coords.x, coords.y);
|
||||||
const localCenter = { x: center.x - rec.x, y: center.y - rec.y };
|
const localCenter = { x: center.x - rec.x, y: center.y - rec.y };
|
||||||
for (const neighbour of this.topology.neighbours(coords.x, coords.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 delta = this.topology.pixelDelta(coords, neighbour);
|
||||||
const length = Math.hypot(delta.x, delta.y);
|
const length = Math.hypot(delta.x, delta.y);
|
||||||
if (length <= 0) continue;
|
if (length <= 0) continue;
|
||||||
const angle = Math.atan2(delta.y, delta.x);
|
const angle = Math.atan2(delta.y, delta.x);
|
||||||
const el = this._acquireHex(this._roadPool);
|
const here = kind.get(k);
|
||||||
el.className = "road-seg";
|
const there = kind.get(nk);
|
||||||
el.style.left = `${localCenter.x}px`;
|
if (here === there) {
|
||||||
el.style.top = `${localCenter.y - width / 2}px`;
|
addSeg(localCenter, length, here, angle);
|
||||||
el.style.width = `${length}px`;
|
} else {
|
||||||
el.style.height = `${width}px`;
|
const mid = { x: localCenter.x + delta.x / 2, y: localCenter.y + delta.y / 2 };
|
||||||
el.style.transform = `rotate(${angle}rad)`;
|
addSeg(localCenter, length / 2, here, angle);
|
||||||
fragment.appendChild(el);
|
addSeg(mid, length / 2, there, angle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rec.roadsEl.appendChild(fragment);
|
rec.roadsEl.appendChild(fragment);
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export const TEXTURE_FOREST = "forest";
|
|||||||
export const TEXTURE_MOUNTAIN = "mountain";
|
export const TEXTURE_MOUNTAIN = "mountain";
|
||||||
// Tangent-space normal map that gives the mountains their relief.
|
// Tangent-space normal map that gives the mountains their relief.
|
||||||
export const TEXTURE_MOUNTAIN_NORMAL = "mountains_normal_map";
|
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
|
// 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.
|
// 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 {
|
import {
|
||||||
CORNERS,
|
CORNERS,
|
||||||
EDGE_SEGMENTS,
|
EDGE_SEGMENTS,
|
||||||
bentPath,
|
cubicBezier,
|
||||||
distortedBoundary,
|
distortedBoundary,
|
||||||
edgePoint,
|
edgePoint,
|
||||||
latticeKey,
|
latticeKey,
|
||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
TEXTURE_GRASS,
|
TEXTURE_GRASS,
|
||||||
TEXTURE_MOUNTAIN,
|
TEXTURE_MOUNTAIN,
|
||||||
TEXTURE_MOUNTAIN_NORMAL,
|
TEXTURE_MOUNTAIN_NORMAL,
|
||||||
|
TEXTURE_RAIL,
|
||||||
|
TEXTURE_ROAD,
|
||||||
TEXTURE_SAND,
|
TEXTURE_SAND,
|
||||||
isTexturedLand,
|
isTexturedLand,
|
||||||
isWaterTile,
|
isWaterTile,
|
||||||
@@ -83,6 +85,26 @@ const ATTR_SLOPE = 6;
|
|||||||
const TERRAIN_FLOATS = 17;
|
const TERRAIN_FLOATS = 17;
|
||||||
const COLOUR_FLOATS = 8;
|
const COLOUR_FLOATS = 8;
|
||||||
const FOG_FLOATS = 7;
|
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).
|
// A flat sample, used when a view has no relief field (tests, stub views).
|
||||||
const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 };
|
const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 };
|
||||||
@@ -101,7 +123,6 @@ export const CONTEXT_ATTRIBUTES = {
|
|||||||
failIfMajorPerformanceCaveat: false,
|
failIfMajorPerformanceCaveat: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROAD = [138 / 255, 90 / 255, 43 / 255, 1];
|
|
||||||
// Unclaimed land on the political map, a flat muted olive. Painting it here
|
// Unclaimed land on the political map, a flat muted olive. Painting it here
|
||||||
// lets the political/economic modes skip the textured terrain mesh entirely.
|
// lets the political/economic modes skip the textured terrain mesh entirely.
|
||||||
const UNCLAIMED_LAND = [0x5b / 255, 0x5f / 255, 0x49 / 255, 1];
|
const UNCLAIMED_LAND = [0x5b / 255, 0x5f / 255, 0x49 / 255, 1];
|
||||||
@@ -282,8 +303,16 @@ void main() {
|
|||||||
// transparent corners are not painted black.
|
// transparent corners are not painted black.
|
||||||
float alpha = mix(1.0, atlas.a, vColour.b);
|
float alpha = mix(1.0, atlas.a, vColour.b);
|
||||||
gl_FragColor = vec4(base, alpha);
|
gl_FragColor = vec4(base, alpha);
|
||||||
} else {
|
} else if (uMode < 1.5) {
|
||||||
gl_FragColor = vColour;
|
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.
|
// so open water hexagons stay regular.
|
||||||
this._warp = null;
|
this._warp = null;
|
||||||
this.roads = { buffer: null, count: 0 };
|
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.borders = { buffer: null, count: 0 };
|
||||||
this.fog = { buffer: null, count: 0 };
|
this.fog = { buffer: null, count: 0 };
|
||||||
// The selected/inspected tile's region: a soft fill plus dotted borders.
|
// The selected/inspected tile's region: a soft fill plus dotted borders.
|
||||||
@@ -460,7 +492,7 @@ export class GLMapRenderer {
|
|||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
if (!gl) return;
|
if (!gl) return;
|
||||||
for (const pass of [
|
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,
|
this.politicalFill, this.economicFill, ...this.terrainLevels,
|
||||||
]) {
|
]) {
|
||||||
if (!pass || !pass.buffer) continue;
|
if (!pass || !pass.buffer) continue;
|
||||||
@@ -727,6 +759,9 @@ export class GLMapRenderer {
|
|||||||
load(TEXTURE_FOREST, `${TEXTURE_DIR}${TEXTURE_FOREST}.png`, true);
|
load(TEXTURE_FOREST, `${TEXTURE_DIR}${TEXTURE_FOREST}.png`, true);
|
||||||
load(TEXTURE_MOUNTAIN, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN}.png`, true);
|
load(TEXTURE_MOUNTAIN, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN}.png`, true);
|
||||||
load(TEXTURE_MOUNTAIN_NORMAL, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN_NORMAL}.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.
|
// The water normal map is baked rather than loaded, so it is ready at once.
|
||||||
const bump = generateWaterBump(256, WATER_BUMP_CELLS, 1337);
|
const bump = generateWaterBump(256, WATER_BUMP_CELLS, 1337);
|
||||||
const water = this._createTexture(true);
|
const water = this._createTexture(true);
|
||||||
@@ -1254,79 +1289,220 @@ export class GLMapRenderer {
|
|||||||
this._upload(this.ocean, arr, undefined, TERRAIN_FLOATS);
|
this._upload(this.ocean, arr, undefined, TERRAIN_FLOATS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draws the road network as mitered strokes. Each pair of adjacent road tiles
|
// Draws the whole transport network as texture quads. Roads and railways
|
||||||
// is drawn once as a single polyline from centre to centre, crossing the
|
// share one graph: each link is a single cubic Bezier from centre to centre,
|
||||||
// shared wavy border at its midpoint; the bend there and the gentle in-tile
|
// and when its two ends are different kinds the curve is split at the border
|
||||||
// drifts are joined with the same miter as the borders, so no piece overlaps
|
// so the road half and the rail half meet there. Upgrading a tile therefore
|
||||||
// and no notch is left at a bend. A small cap at every tile centre covers the
|
// never leaves a gap where the two networks meet. A small quad at every tile
|
||||||
// junction where several roads meet.
|
// centre covers a junction, or a stub for a lone tile.
|
||||||
buildRoads(view) {
|
buildRoads(view) {
|
||||||
const arr = [];
|
const roadArr = [];
|
||||||
const roads = view.roads;
|
const railArr = [];
|
||||||
if (!roads || roads.size === 0 || !view.topology) {
|
this.roads.texture = TEXTURE_ROAD;
|
||||||
this._upload(this.roads, arr);
|
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;
|
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 topo = view.topology;
|
||||||
const period = periodUnits(topo);
|
|
||||||
const field = this._heightField(view);
|
const field = this._heightField(view);
|
||||||
const lift = (point) => {
|
const lift = (point) => {
|
||||||
if (field) point.h = field.at(point.x, point.y);
|
if (field) point.h = field.at(point.x, point.y);
|
||||||
return point;
|
return point;
|
||||||
};
|
};
|
||||||
const steps = 3;
|
// The centre of every transport tile, its transport neighbours (of either
|
||||||
const drawn = new Set();
|
// kind), and its coords, so a link can be curved through the tiles on either
|
||||||
for (const k of roads) {
|
// 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 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)) {
|
for (const neighbour of topo.neighbours(coords.x, coords.y)) {
|
||||||
const nk = key(neighbour.x, neighbour.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}`;
|
const pair = k < nk ? `${k}|${nk}` : `${nk}|${k}`;
|
||||||
if (drawn.has(pair)) continue;
|
if (drawn.has(pair)) continue;
|
||||||
drawn.add(pair);
|
drawn.add(pair);
|
||||||
const delta = topo.pixelDelta(coords, neighbour);
|
const delta = topo.pixelDelta(coords, neighbour);
|
||||||
const target = lift({ x: centre.x + delta.x, y: centre.y + delta.y });
|
const mid = { x: from.x + delta.x / 2, y: from.y + delta.y / 2 };
|
||||||
// Cross the border exactly where it is now: the midpoint of the shared
|
const target = { x: from.x + delta.x, y: from.y + delta.y };
|
||||||
// wavy edge, not the ideal straight boundary.
|
const here = kind.get(k);
|
||||||
const [idealA, idealB] = nearestCorners(centre, target);
|
const there = kind.get(nk);
|
||||||
const mid = lift(edgePoint(seed, idealA, idealB, 0.5, period));
|
if (here === there) {
|
||||||
// Half the road drifts in from each tile centre towards that midpoint.
|
this._segment(arr, from, target, FLAT_TRANSPORT_WIDTH, here);
|
||||||
const points = [centre];
|
} else {
|
||||||
for (let j = 1; j <= steps; j++) {
|
this._segment(arr, from, mid, FLAT_TRANSPORT_WIDTH, here);
|
||||||
points.push(lift(bentPath(seed, centre, mid, 0x5a17, j / steps)));
|
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) {
|
// Cap every centre so the strokes join cleanly, using the tile's own colour.
|
||||||
const coords = parseKey(k);
|
for (const k of kind.keys()) {
|
||||||
this._roadJoint(arr, lift(mapToLocal(coords.x, coords.y)));
|
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
|
// The transport neighbour of `originKey` that best continues the road arriving
|
||||||
// quads share them), so a bend is filled rather than left as a wedge.
|
// from `forwardKey`: the one most nearly opposite it, so the Catmull-Rom
|
||||||
_roadBand(arr, points, width, colour) {
|
// 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;
|
const n = points.length;
|
||||||
if (n < 2) return;
|
|
||||||
const half = width / 2;
|
|
||||||
const normals = [];
|
const normals = [];
|
||||||
|
const lengths = [];
|
||||||
for (let i = 0; i < n - 1; i++) {
|
for (let i = 0; i < n - 1; i++) {
|
||||||
const a = points[i];
|
const a = points[i];
|
||||||
const b = points[i + 1];
|
const b = points[i + 1];
|
||||||
const dx = b.x - a.x;
|
const dx = b.x - a.x;
|
||||||
const dy = b.y - a.y;
|
const dy = b.y - a.y;
|
||||||
const length = Math.hypot(dx, dy) || 1;
|
const length = Math.hypot(dx, dy) || 1;
|
||||||
|
lengths.push(length);
|
||||||
normals.push({ x: -dy / length, y: dx / length });
|
normals.push({ x: -dy / length, y: dx / length });
|
||||||
}
|
}
|
||||||
const offsets = [];
|
const offsets = [];
|
||||||
|
const distance = [0];
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const before = normals[i === 0 ? 0 : i - 1];
|
const before = normals[i === 0 ? 0 : i - 1];
|
||||||
const after = normals[i === n - 1 ? n - 2 : i];
|
const after = normals[i === n - 1 ? n - 2 : i];
|
||||||
@@ -1334,31 +1510,77 @@ export class GLMapRenderer {
|
|||||||
if (denom < 0.2) denom = 0.2;
|
if (denom < 0.2) denom = 0.2;
|
||||||
const scale = half / denom;
|
const scale = half / denom;
|
||||||
offsets.push({ x: (before.x + after.x) * scale, y: (before.y + after.y) * scale });
|
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 a = points[i];
|
||||||
const b = points[i + 1];
|
const b = points[i + 1];
|
||||||
const oa = offsets[i];
|
const oa = offsets[i];
|
||||||
const ob = offsets[i + 1];
|
const ob = offsets[i + 1];
|
||||||
this._quad(arr, [
|
const va = distance[i] / TRANSPORT_TEXTURE_LENGTH;
|
||||||
{ x: a.x + oa.x, y: a.y + oa.y, h: a.h },
|
const vb = distance[i + 1] / TRANSPORT_TEXTURE_LENGTH;
|
||||||
{ x: b.x + ob.x, y: b.y + ob.y, h: b.h },
|
this._transportQuad(arr, [
|
||||||
{ 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, u: TRANSPORT_U0, v: va },
|
||||||
{ 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, u: TRANSPORT_U0, v: vb },
|
||||||
], colour);
|
{ 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.
|
// A point `distance` beyond `to` along the direction `to - from`, at the same
|
||||||
_roadJoint(arr, point) {
|
// height. Used to run a band a little past a tile centre so it melts into the
|
||||||
const r = 2.5;
|
// 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;
|
const h = point.h;
|
||||||
this._quad(arr, [
|
this._transportQuad(arr, [
|
||||||
{ x: point.x - r, y: point.y - r, h },
|
{ x: point.x - r, y: point.y - r, h, u: TRANSPORT_U0, v: 0 },
|
||||||
{ x: point.x - r, y: point.y + r, h },
|
{ x: point.x + r, y: point.y - r, h, u: TRANSPORT_U1, v: 0 },
|
||||||
{ x: point.x + r, y: point.y + r, h },
|
{ x: point.x + r, y: point.y + r, h, u: TRANSPORT_U1, v: 1 },
|
||||||
{ x: point.x + r, y: point.y - r, h },
|
{ x: point.x - r, y: point.y + r, h, u: TRANSPORT_U0, v: 1 },
|
||||||
], ROAD);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
// 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.vertexAttribPointer(this.aHeight, 1, gl.FLOAT, false, stride, 8);
|
||||||
gl.enableVertexAttribArray(this.aColour);
|
gl.enableVertexAttribArray(this.aColour);
|
||||||
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 12);
|
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 {
|
} else {
|
||||||
gl.enableVertexAttribArray(this.aUV);
|
gl.enableVertexAttribArray(this.aUV);
|
||||||
gl.vertexAttribPointer(this.aUV, 2, gl.FLOAT, false, stride, 8);
|
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);
|
if (political) this._drawPass(this.politicalFill, 1, shifts, period, rows);
|
||||||
else if (economic) this._drawPass(this.economicFill, 1, shifts, period, rows);
|
else if (economic) this._drawPass(this.economicFill, 1, shifts, period, rows);
|
||||||
// Roads would only clutter the flat colours; borders stay.
|
// Roads and railways: textured curve quads on the terrain map, plain solid
|
||||||
if (!political && !economic) this._drawPass(this.roads, 1, shifts, period);
|
// 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._drawPass(this.borders, 1, shifts, period);
|
||||||
this._drawFog(bufferWidth, bufferHeight, shifts, period, rows);
|
this._drawFog(bufferWidth, bufferHeight, shifts, period, rows);
|
||||||
this._drawPass(this.regionOverlay, 1, shifts, period);
|
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 unitSources = units.sources || [];
|
||||||
const campaigns = breakdown.campaignUpkeep || { total: 0, sources: [] };
|
const campaigns = breakdown.campaignUpkeep || { total: 0, sources: [] };
|
||||||
const campaignSources = campaigns.sources || [];
|
const campaignSources = campaigns.sources || [];
|
||||||
|
const transport = breakdown.transportUpkeep || { total: 0, sources: [] };
|
||||||
|
const transportSources = transport.sources || [];
|
||||||
const net = breakdown.net || 0;
|
const net = breakdown.net || 0;
|
||||||
|
|
||||||
$list.empty();
|
$list.empty();
|
||||||
@@ -86,6 +88,11 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
|
|||||||
detailRow(`${source.label} ×${source.count}`, -source.amount, "expense-detail")
|
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>");
|
const $table = $("<table class='budget-table'></table>");
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export class GameServer extends EventEmitter {
|
|||||||
return this._handleCancelTrain(civ, order);
|
return this._handleCancelTrain(civ, order);
|
||||||
case "build":
|
case "build":
|
||||||
return this._handleBuild(civ, order);
|
return this._handleBuild(civ, order);
|
||||||
|
case "build_improvement":
|
||||||
|
return this._handleBuildImprovement(civ, order);
|
||||||
case "demolish":
|
case "demolish":
|
||||||
return this._handleDemolish(civ, order);
|
return this._handleDemolish(civ, order);
|
||||||
case "government":
|
case "government":
|
||||||
@@ -227,6 +229,18 @@ export class GameServer extends EventEmitter {
|
|||||||
return this.state.requestDemolish(city.id, Number(order.building));
|
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) {
|
_handleGovernment(civ, order) {
|
||||||
return this.state.requestSetGovernment(civ, Number(order.government));
|
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 "./map_config.js";
|
||||||
export * from "./terrain.js";
|
export * from "./terrain.js";
|
||||||
export * from "./roads.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
|
// generation: they connect the cities with a sparse, near-shortest network and
|
||||||
// make movement cost the same on every terrain they cross.
|
// make movement cost the same on every terrain they cross.
|
||||||
|
|
||||||
|
import { TRANSPORT_BY_ID } from "./improvements.js";
|
||||||
|
|
||||||
export const ROADS = {
|
export const ROADS = {
|
||||||
// Travel multiplier while a unit stands on a road tile. It is flat, unlike
|
// Travel multiplier while a unit stands on a road tile, taken from the road
|
||||||
// the terrain multipliers, so a road levels out hills and forests.
|
// improvement so the two stay in step. Like the railway it is flat, unlike the
|
||||||
movementCostMultiplier: 1.0,
|
// 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
|
// A road tile's generation cost is costBase raised to the terrain's own
|
||||||
// movement multiplier, so hills and mountains are exponentially dearer to
|
// movement multiplier, so hills and mountains are exponentially dearer to
|
||||||
// run a road through than plains.
|
// 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.
|
// attribute population, GDP and tax to the city that develops the land.
|
||||||
this.tileRegion = new Map();
|
this.tileRegion = new Map();
|
||||||
this._regionByCity = 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();
|
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();
|
this.tilePopulation = new Map();
|
||||||
// Per-tile GDP-per-capita multiplier, lowered by pillaging (default 1).
|
// Per-tile GDP-per-capita multiplier, lowered by pillaging (default 1).
|
||||||
this.tileGdpPenalty = new Map();
|
this.tileGdpPenalty = new Map();
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
prerequisitesMet,
|
prerequisitesMet,
|
||||||
technologyEffects,
|
technologyEffects,
|
||||||
} from "../rules.js";
|
} 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 { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js";
|
||||||
import { effectAt, mergeModifiers } from "./helpers.js";
|
import { effectAt, mergeModifiers } from "./helpers.js";
|
||||||
|
|
||||||
@@ -102,9 +103,57 @@ export const economyMethods = {
|
|||||||
}
|
}
|
||||||
// Running propaganda campaigns are upkeep too, paid hour by hour.
|
// Running propaganda campaigns are upkeep too, paid hour by hour.
|
||||||
total += this.getCampaignUpkeep(civ);
|
total += this.getCampaignUpkeep(civ);
|
||||||
|
// The transport network costs an hourly maintenance per tile held.
|
||||||
|
total += this.getTransportUpkeep(civ);
|
||||||
return total;
|
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) {
|
getBuildingUpkeep(civ) {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const city of this.cities) {
|
for (const city of this.cities) {
|
||||||
@@ -200,12 +249,15 @@ export const economyMethods = {
|
|||||||
const unitUpkeep = unitSources.reduce((sum, source) => sum + source.amount, 0);
|
const unitUpkeep = unitSources.reduce((sum, source) => sum + source.amount, 0);
|
||||||
const campaignSources = this._campaignUpkeepSources(civ);
|
const campaignSources = this._campaignUpkeepSources(civ);
|
||||||
const campaignUpkeep = campaignSources.reduce((sum, source) => sum + source.amount, 0);
|
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 {
|
return {
|
||||||
income: { rate, base, modifier, total: income, sources: incomeSources, regions },
|
income: { rate, base, modifier, total: income, sources: incomeSources, regions },
|
||||||
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
|
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
|
||||||
unitUpkeep: { total: unitUpkeep, sources: unitSources, groups: unitSources },
|
unitUpkeep: { total: unitUpkeep, sources: unitSources, groups: unitSources },
|
||||||
campaignUpkeep: { total: campaignUpkeep, sources: campaignSources, groups: campaignSources },
|
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.
|
// tick, and the travel-cost model the browser mirrors for prediction.
|
||||||
|
|
||||||
import { key } from "../hex.js";
|
import { key } from "../hex.js";
|
||||||
import { ROADS } from "../data/roads.js";
|
import { TRANSPORT_BY_ID } from "../data/improvements.js";
|
||||||
import {
|
import {
|
||||||
MIN_STEP_COST,
|
MIN_STEP_COST,
|
||||||
DEFAULT_UNIT_SPEED,
|
DEFAULT_UNIT_SPEED,
|
||||||
@@ -310,15 +310,31 @@ export const movementMethods = {
|
|||||||
if (this._isAirUnit(unit)) {
|
if (this._isAirUnit(unit)) {
|
||||||
return 1 / (unit.moveSpeed || this._unitSpeed(unit));
|
return 1 / (unit.moveSpeed || this._unitSpeed(unit));
|
||||||
}
|
}
|
||||||
// A road levels the terrain: every road tile costs the same to cross.
|
// A road or railway levels the terrain: every such tile costs the same to
|
||||||
const multiplier = this.roads.has(key(coords.x, coords.y))
|
// cross, a railway half again as cheap as a road.
|
||||||
? ROADS.movementCostMultiplier
|
return this._tileMovementMultiplier(coords) / this._effectiveSpeedAt(unit, coords);
|
||||||
: this.terrainStats.movementCostMultiplier(coords);
|
},
|
||||||
return multiplier / 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) {
|
_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) {
|
_heuristic(unit, from, to) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { key } from "../hex.js";
|
|||||||
import { ECONOMY } from "../data/economy.js";
|
import { ECONOMY } from "../data/economy.js";
|
||||||
import { GOVERNMENTS } from "../data/governments.js";
|
import { GOVERNMENTS } from "../data/governments.js";
|
||||||
import { PILLAGE, pillageSteps } from "../data/combat.js";
|
import { PILLAGE, pillageSteps } from "../data/combat.js";
|
||||||
|
import { transportImprovement } from "../data/improvements.js";
|
||||||
import { buildingBuildCost, buildingRefund } from "../rules.js";
|
import { buildingBuildCost, buildingRefund } from "../rules.js";
|
||||||
import { TRAINING_QUEUE_LIMIT } from "./constants.js";
|
import { TRAINING_QUEUE_LIMIT } from "./constants.js";
|
||||||
|
|
||||||
@@ -116,6 +117,32 @@ export const orderMethods = {
|
|||||||
return true;
|
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) {
|
requestDemolish(cityId, protoIndex) {
|
||||||
const city = this.findCity(cityId);
|
const city = this.findCity(cityId);
|
||||||
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
|
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ export const serializationMethods = {
|
|||||||
cities: this._serializeCities(),
|
cities: this._serializeCities(),
|
||||||
units: this._serializeUnits(),
|
units: this._serializeUnits(),
|
||||||
territory: this._serializedTerritory,
|
territory: this._serializedTerritory,
|
||||||
// The pre-generated road tiles, as [x, y].
|
// The pre-generated road tiles, as [x, y], and the player-built railway
|
||||||
roads: this._serializeRoads(),
|
// 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
|
// Each land tile's region: [x, y, cityId], so the browser attributes a
|
||||||
// tile's GDP modifiers to the city that develops it.
|
// tile's GDP modifiers to the city that develops it.
|
||||||
regions: this._serializeRegions(),
|
regions: this._serializeRegions(),
|
||||||
@@ -80,6 +84,7 @@ export const serializationMethods = {
|
|||||||
regions: this._regionVersion,
|
regions: this._regionVersion,
|
||||||
population: this._populationVersion,
|
population: this._populationVersion,
|
||||||
ethnicity: this._ethnicityVersion,
|
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,
|
// A sparse [x, y] list of every tile in `tiles`. The list is memoised against
|
||||||
// so the list is serialized once.
|
// the improvement version: the pre-generated road network is laid once, and
|
||||||
_serializeRoads() {
|
// player-built roads and railways change it in step, so an unchanged network
|
||||||
if (this._serializedRoadsCache) return this._serializedRoadsCache;
|
// 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 = [];
|
const result = [];
|
||||||
for (const k of this.roads) {
|
for (const k of tiles) {
|
||||||
const coords = parseKey(k);
|
const coords = parseKey(k);
|
||||||
result.push([coords.x, coords.y]);
|
result.push([coords.x, coords.y]);
|
||||||
}
|
}
|
||||||
this._serializedRoadsCache = result;
|
this[cacheField] = { version: this._improvementVersion, value: result };
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
distortedBoundary,
|
distortedBoundary,
|
||||||
edgePoint,
|
edgePoint,
|
||||||
bentPath,
|
bentPath,
|
||||||
|
cubicBezier,
|
||||||
latticeKey,
|
latticeKey,
|
||||||
vertexKey,
|
vertexKey,
|
||||||
} from "../client/js/map_view/distortion.js";
|
} 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");
|
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() {
|
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
|
// `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.
|
// water reads as a regular hexagon while still tiling with its neighbours.
|
||||||
|
|||||||
@@ -1277,6 +1277,51 @@ export class GameScreenTest extends TestCase {
|
|||||||
teardownDom(env);
|
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) {
|
function findEnemyTile(state) {
|
||||||
|
|||||||
@@ -417,6 +417,46 @@ export class GameServerLoginTest extends TestCase {
|
|||||||
for (let i = 0; i < 30; i++) server.advanceMovement(1 / 60);
|
for (let i = 0; i < 30; i++) server.advanceMovement(1 / 60);
|
||||||
this.assertGreater(unit.progressHours, 0, "the server advanced with nobody connected");
|
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 {
|
export class GameServerLobbyTest extends TestCase {
|
||||||
|
|||||||
@@ -147,10 +147,14 @@ export class GameStateBuildingsTest extends TestCase {
|
|||||||
this.assertHas(breakdown, "income");
|
this.assertHas(breakdown, "income");
|
||||||
this.assertHas(breakdown, "buildingUpkeep");
|
this.assertHas(breakdown, "buildingUpkeep");
|
||||||
this.assertHas(breakdown, "unitUpkeep");
|
this.assertHas(breakdown, "unitUpkeep");
|
||||||
|
this.assertHas(breakdown, "transportUpkeep");
|
||||||
this.assertApprox(breakdown.buildingUpkeep.total, 100);
|
this.assertApprox(breakdown.buildingUpkeep.total, 100);
|
||||||
this.assertApprox(
|
this.assertApprox(
|
||||||
breakdown.unitUpkeep.total,
|
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);
|
const expected = state.getPlayerGdp(0) * ECONOMY.budgetGdpRate * (1 + breakdown.income.modifier);
|
||||||
this.assertApprox(breakdown.income.total, expected);
|
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() {
|
async test_unit_views_show_their_owners_flag() {
|
||||||
const state = smallState();
|
const state = smallState();
|
||||||
const { env } = await this.build(state);
|
const { env } = await this.build(state);
|
||||||
|
|||||||
+15
-1
@@ -58,7 +58,7 @@ export class RoadsTest extends TestCase {
|
|||||||
const before = state._tileTravelHours(unit, hill);
|
const before = state._tileTravelHours(unit, hill);
|
||||||
state.roads.add(key(hill.x, hill.y));
|
state.roads.add(key(hill.x, hill.y));
|
||||||
const after = state._tileTravelHours(unit, hill);
|
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(
|
this.assertApprox(
|
||||||
after,
|
after,
|
||||||
ROADS.movementCostMultiplier / state._effectiveSpeedAt(unit, hill),
|
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() {
|
test_greedy_spanner_drops_links_the_others_already_cover() {
|
||||||
// Four collinear points. The direct long links are covered by the chain of
|
// Four collinear points. The direct long links are covered by the chain of
|
||||||
// unit links, so only the three short ones survive.
|
// 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() {
|
async test_wrong_password_is_rejected() {
|
||||||
await withServer(async (port) => {
|
await withServer(async (port) => {
|
||||||
const host = makeClient(`ws://127.0.0.1:${port}/ws`);
|
const host = makeClient(`ws://127.0.0.1:${port}/ws`);
|
||||||
|
|||||||
@@ -14,17 +14,20 @@ function makeRenderer() {
|
|||||||
renderer.captured = {};
|
renderer.captured = {};
|
||||||
renderer.borders = { name: "borders" };
|
renderer.borders = { name: "borders" };
|
||||||
renderer.roads = { name: "roads" };
|
renderer.roads = { name: "roads" };
|
||||||
|
renderer.railways = { name: "railways" };
|
||||||
|
renderer.flatTransport = { name: "flatTransport" };
|
||||||
renderer.regionOverlay = { name: "region" };
|
renderer.regionOverlay = { name: "region" };
|
||||||
renderer.politicalFill = { name: "political" };
|
renderer.politicalFill = { name: "political" };
|
||||||
renderer.economicFill = { name: "economic" };
|
renderer.economicFill = { name: "economic" };
|
||||||
renderer._upload = function (pass, arr) {
|
renderer._upload = function (pass, arr, usage, floats) {
|
||||||
this.captured[pass.name] = arr;
|
this.captured[pass.name] = arr;
|
||||||
pass.count = arr.length / 8;
|
pass.floats = floats;
|
||||||
|
pass.count = arr.length / (floats || 8);
|
||||||
};
|
};
|
||||||
return renderer;
|
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 topology = new MapTopology({ x: size, y: size }, false);
|
||||||
const tiles = {};
|
const tiles = {};
|
||||||
for (let y = topology.originY; y < topology.originY + size; y++) {
|
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,
|
topology,
|
||||||
tiles,
|
tiles,
|
||||||
roads,
|
roads,
|
||||||
|
railways,
|
||||||
territory: new Map(territory),
|
territory: new Map(territory),
|
||||||
regions: new Map(regions),
|
regions: new Map(regions),
|
||||||
civilisations: [{ primaryColour: "#ff0000" }, { primaryColour: "#00ff00" }],
|
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");
|
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() {
|
async test_flat_layers_carry_the_relief_too() {
|
||||||
const renderer = makeRenderer();
|
const renderer = makeRenderer();
|
||||||
const state = view(renderer, { roads: new Set(["0,0"]) });
|
const state = view(renderer, { roads: new Set(["0,0"]) });
|
||||||
renderer.buildRoads(state);
|
renderer.buildRoads(state);
|
||||||
const arr = renderer.captured.roads;
|
const arr = renderer.captured.roads;
|
||||||
let lifted = false;
|
let lifted = false;
|
||||||
for (let i = 0; i < arr.length; i += 8) {
|
for (let i = 0; i < arr.length; i += 9) {
|
||||||
if (arr[i + 2] > 0) lifted = true;
|
if (arr[i + 4] > 0) lifted = true;
|
||||||
}
|
}
|
||||||
this.assertTrue(lifted, "road vertices ride the heightmap");
|
this.assertTrue(lifted, "road vertices ride the heightmap");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user