Flew aircraft and missiles straight and let strikes target fogged cities

This commit is contained in:
2026-09-24 20:25:08 +02:00
parent 36ac6698a8
commit a219cf650f
21 changed files with 829 additions and 127 deletions
+16 -2
View File
@@ -132,8 +132,8 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
`strikeReadyHour` cooldown. The strike is charged up front per aircraft `strikeReadyHour` cooldown. The strike is charged up front per aircraft
(`AIR_STRIKE`). Aircraft may only be rebased to one of their own airports and (`AIR_STRIKE`). Aircraft may only be rebased to one of their own airports and
the ferry is bounded by fuel, not the combat `missionRange`; they never join the ferry is bounded by fuel, not the combat `missionRange`; they never join
melee. A strike may target a garrison inside a hostile city — the plane enters melee. A strike may target a garrison inside a hostile city — the plane reaches
that tile as a goal only to bomb and turns for home, and captures skip that tile only to bomb and turns for home, and captures skip
aircraft, so it never occupies or takes the city. A strike is only accepted aircraft, so it never occupies or takes the city. A strike is only accepted
when the whole round trip fits the remaining when the whole round trip fits the remaining
fuel (out to the target, then home to a friendly airport), and the browser fuel (out to the target, then home to a friendly airport), and the browser
@@ -142,6 +142,20 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
snapshot and snapshot and
`map_view/motion.js` draws the red dashed line and target ring on `map_view/motion.js` draws the red dashed line and target ring on
`#layer-targets` while either endpoint is selected. `#layer-targets` while either endpoint is selected.
- **Aircraft and launched missiles fly straight lines, not hexes**
(`GameState._isFlier`). Their `path` is a list of world-pixel points (the
two-point `flightLine` from `shared/hex.js`, using `nearestCopyPoint` for the
wrapped seam), and `_advanceFlier`/`_completeFlightLeg` in `movement.js` fly
them in `advanceMovement`; `_segmentHours` is the single source for flight
time and fuel, so a plane can never run dry on a route its own check accepted.
`findPath` returns a straight line for a flier and never runs A*. The browser
mirrors all of this in `map_view/motion.js` (`_planMove`, `_flightHours`,
`_motionTile`, `motion.flight`): a mismatch makes the icon fight the server.
A missile (`requestMissileStrike`) now flies to its target and detonates on
arrival in `_detonateMissile`, so air defense can shoot it down first.
Air defense is a **corridor**, not a tile-entry volley: `_airDefenseVolley`
fires once per in-game hour from every hostile battery within
`AIR_DEFENSE.rangeTiles` of the flier, charged per firing battery.
- Terrain generation is deterministic from `(seed, mapConfig)`, so - Terrain generation is deterministic from `(seed, mapConfig)`, so
`GameState._buildTerrain` and the settlement/road step cache the world shape `GameState._buildTerrain` and the settlement/road step cache the world shape
(`shared/game_state/world.js`) rather than regenerating it for every game with (`shared/game_state/world.js`) rather than regenerating it for every game with
+1 -1
View File
@@ -322,7 +322,7 @@ simplification worth revisiting. Anything unticked is still open.
(any unit caught in the blast is destroyed too) (any unit caught in the blast is destroyed too)
- [x] Stacks must behave as single units when it comes to pathfinding - [x] Stacks must behave as single units when it comes to pathfinding
- [x] Cannot go through neutral third-party territory, allied and at-war territory can be walked through - [x] Cannot go through neutral third-party territory, allied and at-war territory can be walked through
- [ ] Airplanes move in straight lines as they are not bound by tiles. - [x] Airplanes move in straight lines as they are not bound by tiles.
* [x] Non-melee units such as artillery, airplanes, have an 80 % chance to be destroyed when an enemy attempts to capture them * [x] Non-melee units such as artillery, airplanes, have an 80 % chance to be destroyed when an enemy attempts to capture them
### Intelligence ### Intelligence
+8
View File
@@ -387,6 +387,14 @@ input:focus, select:focus { border-color: var(--accent); }
/* Picking a target takes over the cursor. */ /* Picking a target takes over the cursor. */
#map-viewport.targeting { cursor: crosshair; } #map-viewport.targeting { cursor: crosshair; }
#map-viewport.targeting.dragging { cursor: grabbing; } #map-viewport.targeting.dragging { cursor: grabbing; }
/* While picking a target every click belongs to the map. The unit and city
icons must not swallow it: a click on an enemy unit icon would otherwise run
the normal selection path (clear the selection) instead of choosing the
target, so the Attack action appeared to do nothing. */
#map-viewport.targeting #layer-entities,
#map-viewport.targeting #layer-labels {
pointer-events: none;
}
/* The delivery graph, shown only in the economic map modes: a white line per /* The delivery graph, shown only in the economic map modes: a white line per
delivery leg, an animated merchandise icon riding it and a dot on every delivery leg, an animated merchandise icon riding it and a dot on every
+6 -6
View File
@@ -1,6 +1,12 @@
// Generated by scripts/generate-devlog.js from `git log`; do not edit. // Generated by scripts/generate-devlog.js from `git log`; do not edit.
// The pre-commit hook refreshes it so the main menu shows the latest commits. // The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [ export const DEVLOG = [
{
"hash": "36ac669",
"date": "2026-09-24",
"subject": "Let stacks split by shift-click and cross allied or at-war land",
"body": ""
},
{ {
"hash": "cc5c57c", "hash": "cc5c57c",
"date": "2026-09-24", "date": "2026-09-24",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-23", "date": "2026-09-23",
"subject": "Drew the cylindrical map as a repeating world", "subject": "Drew the cylindrical map as a repeating world",
"body": "" "body": ""
},
{
"hash": "4600b16",
"date": "2026-09-23",
"subject": "Stockpiled a month of every material and cached the trade walks",
"body": "Food, steel, luxury and high-tech now share one flat month-long reserve. A region keeps bidding for the shortfall while its store is thin, so a war that empties a pantry refills it from the grid instead of starving; the harvest is served before the city load, unmet food bids the price up, the army's rations count as demand, and a converter that cannot cover its power bill idles instead of making a loss.\n\nThe delivery graph walks sea lanes any distance, so its walks are cached across days behind a road/railway/work/territory stamp and only dropped by a wartime unit move. Units are indexed per tile, market access and node populations are memoised for the day, and the server can settle a fresh world before serving (--warmup, 150 days by default).\n\nRoadmap records the remaining follow-ups -- the politics/migration hot path and the luxury/high-tech price plateau -- and that freight's heavy energy draw is intended."
} }
]; ];
+19 -2
View File
@@ -11,6 +11,7 @@ import { MapView } from "./map_view.js";
import { CityModal, NationModal, TileModal, ConfirmModal, NewsModal, MapModesModal, CapabilityModal } from "./modals.js"; import { CityModal, NationModal, TileModal, ConfirmModal, NewsModal, MapModesModal, CapabilityModal } from "./modals.js";
import { GameFeed } from "./feed.js"; import { GameFeed } from "./feed.js";
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js"; import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
import { flightDistanceTiles } from "../../shared/hex.js";
import { bootLog } from "./loading.js"; import { bootLog } from "./loading.js";
import { formatCapabilityReport } from "./capabilities.js"; import { formatCapabilityReport } from "./capabilities.js";
import { panelMethods } from "./game_screen/panels.js"; import { panelMethods } from "./game_screen/panels.js";
@@ -456,6 +457,19 @@ export class GameScreen {
} }
} }
const { targets, speed, routes } = await this._groupMovePlan(units, coords); const { targets, speed, routes } = await this._groupMovePlan(units, coords);
// An attack on a neutral nation's unit or city declares war exactly as
// marching into its land does, so the browser asks first. The territory
// scan in `_groupMovePlan` only names owners a ground route crosses; a
// strike on an aircraft's straight line, or on a defenceless city, must be
// named here.
if (type === "attack") {
const defender = this._attackTargetCiv(coords);
if (defender >= 0 && !targets.includes(defender) &&
!this._isAtWar(this.localCiv, defender) &&
!this._isAllied(this.localCiv, defender)) {
targets.push(defender);
}
}
// A strike is refused before it is ever sent when the target lies beyond // A strike is refused before it is ever sent when the target lies beyond
// the unit's reach, so the browser and the server never disagree. An air // the unit's reach, so the browser and the server never disagree. An air
// strike must fit the whole round trip, not just the flight out. // strike must fit the whole round trip, not just the flight out.
@@ -478,9 +492,12 @@ export class GameScreen {
if (!route) return false; if (!route) return false;
if (!proto.air) return false; if (!proto.air) return false;
// A ferry flight is bounded by fuel, not the combat mission radius: // A ferry flight is bounded by fuel, not the combat mission radius:
// endurance hours at the aircraft's speed. // endurance hours at the aircraft's speed. The flight is a straight line,
// so distance is measured along it rather than by counting path tiles.
const ferryTiles = (proto.enduranceHours || 0) * (proto.speed || 1); const ferryTiles = (proto.enduranceHours || 0) * (proto.speed || 1);
return route.length - 1 > ferryTiles; const from = { x: unit.coords[0], y: unit.coords[1] };
return flightDistanceTiles(from, coords, this.map.topology, this.map._maxStepLength) >
ferryTiles;
}); });
if (outOfRange) { if (outOfRange) {
this.confirmModal.show("Out of range", "That target lies beyond this unit's reach.", null); this.confirmModal.show("Out of range", "That target lies beyond this unit's reach.", null);
+47 -13
View File
@@ -47,6 +47,7 @@ import { defenseFactors, unitDefense, productionFactors, productionMultiplier, p
import { formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js"; import { formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
import { dateString, hoursInNextMonth } from "../../../shared/game_clock.js"; import { dateString, hoursInNextMonth } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js"; import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
import { flightDistanceTiles } from "../../../shared/hex.js";
import { ethnicityChart } from "../ui/ethnicity.js"; import { ethnicityChart } from "../ui/ethnicity.js";
import { isEconomicMode } from "../map_view/economic.js"; import { isEconomicMode } from "../map_view/economic.js";
import { isWaterTile } from "../map_view/textures.js"; import { isWaterTile } from "../map_view/textures.js";
@@ -312,6 +313,9 @@ export const panelMethods = {
if (!route || route.length < 2) continue; if (!route || route.length < 2) continue;
routes.set(unit.id, route); routes.set(unit.id, route);
if (!this._isMilitaryUnit(unit)) continue; if (!this._isMilitaryUnit(unit)) continue;
// A flight path is world pixels, not tiles, and an aircraft crosses
// foreign land without declaring war; only ground marches name targets.
if (this._isAirUnit(unit)) continue;
for (const point of route) { for (const point of route) {
const owner = this.map.territory.get(`${point.x},${point.y}`); const owner = this.map.territory.get(`${point.x},${point.y}`);
if (owner === undefined || owner < 0 || owner === unit.civ) continue; if (owner === undefined || owner < 0 || owner === unit.civ) continue;
@@ -616,8 +620,9 @@ export const panelMethods = {
}, },
// A left-click in target-picking mode. A ranged attack fires when the tile // A left-click in target-picking mode. A ranged attack fires when the tile
// holds a hostile military unit; a spy operation files its report or opens its // holds a hostile military unit, or -- for an aircraft -- a hostile city; a
// bombardment when the tile holds the city it wants. // spy operation files its report or opens its bombardment when the tile holds
// the city it wants.
_chooseTarget(coords) { _chooseTarget(coords) {
const targeting = this._targeting; const targeting = this._targeting;
if (!targeting) return; if (!targeting) return;
@@ -644,7 +649,7 @@ export const panelMethods = {
} }
return; return;
} }
if (!this._isEnemyMilitaryAt(coords)) return; if (!this._isEnemyMilitaryAt(coords) && !this._airStrikeOrder(units, coords)) return;
this._orderMove(units, coords, "attack"); this._orderMove(units, coords, "attack");
}, },
@@ -671,8 +676,8 @@ export const panelMethods = {
return !!(proto && proto.air); return !!(proto && proto.air);
}, },
// Whether `coords` holds a hostile military unit, the only thing a strike may // Whether `coords` holds a hostile military unit, the target of a ranged
// target. // strike.
_isEnemyMilitaryAt(coords) { _isEnemyMilitaryAt(coords) {
return this.map.unitIdsAt(coords).some((id) => { return this.map.unitIdsAt(coords).some((id) => {
const data = this._unit(id); const data = this._unit(id);
@@ -680,6 +685,30 @@ export const panelMethods = {
}); });
}, },
// A city that is not ours, at `coords`. A city known to the player but hidden
// under the fog is still a legitimate target, so this reads the snapshot
// rather than the visible unit layer.
_isEnemyCityAt(coords) {
return this._cityCivAt(coords) >= 0;
},
// The hostiles a strike on `coords` would fall on: the enemy military unit
// there, else the non-friendly city. -1 when there is nothing to attack.
_attackTargetCiv(coords) {
const hostile = this.map.unitIdsAt(coords)
.map((id) => this._unit(id))
.find((data) => data && data.civ !== this.localCiv && this._isMilitaryUnit(data));
if (hostile) return hostile.civ;
return this._cityCivAt(coords);
},
_cityCivAt(coords) {
const city = ((this.snapshot && this.snapshot.cities) || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
return city && city.civ !== this.localCiv ? city.civ : -1;
},
// A ground/naval battery attack: resolved where the battery stands, with a // A ground/naval battery attack: resolved where the battery stands, with a
// standing hourly target rather than a march. // standing hourly target rather than a march.
_rangedAttackOrder(units, coords) { _rangedAttackOrder(units, coords) {
@@ -689,12 +718,13 @@ export const panelMethods = {
return this._isEnemyMilitaryAt(coords); return this._isEnemyMilitaryAt(coords);
}, },
// An aircraft attack: the plane flies to the hostile unit and bombs it on // An aircraft attack: the plane flies to the hostile unit or city and bombs
// arrival, then returns home to re-arm. // it on arrival, then returns home to re-arm. A city is a valid target even
// when it is under the fog and even when its garrison is not visible.
_airStrikeOrder(units, coords) { _airStrikeOrder(units, coords) {
if (units.length === 0) return false; if (units.length === 0) return false;
if (!units.every((unit) => this._isAirUnit(unit))) return false; if (!units.every((unit) => this._isAirUnit(unit))) return false;
return this._isEnemyMilitaryAt(coords); return this._isEnemyMilitaryAt(coords) || this._isEnemyCityAt(coords);
}, },
// Why an air strike cannot be issued right now, or null when it can. Blocks a // Why an air strike cannot be issued right now, or null when it can. Blocks a
@@ -765,14 +795,18 @@ export const panelMethods = {
const toTarget = this.map.topology.tileDistance(from, coords); const toTarget = this.map.topology.tileDistance(from, coords);
const airport = this._airReturnAirport(unit, coords); const airport = this._airReturnAirport(unit, coords);
if (!airport) return "This aircraft has no friendly airport to return to."; if (!airport) return "This aircraft has no friendly airport to return to.";
const toHome = this.map.topology.tileDistance(coords, { const home = { x: airport.coords[0], y: airport.coords[1] };
x: airport.coords[0],
y: airport.coords[1],
});
const speed = proto.speed || 1; const speed = proto.speed || 1;
const fuel = unit.airHours === undefined || unit.airHours === null const fuel = unit.airHours === undefined || unit.airHours === null
? proto.enduranceHours ? proto.enduranceHours
: unit.airHours; : unit.airHours;
// Fuel is spent in straight-line hours: out to the target, then on to the
// nearest friendly airport. Mirrors GameState._canStrikeAndReturn, so the
// browser refuses exactly what the server would.
const maxStep = this.map._maxStepLength || 1;
const hours =
flightDistanceTiles(from, coords, this.map.topology, maxStep) / speed +
flightDistanceTiles(coords, home, this.map.topology, maxStep) / speed;
// The furthest it can strike: half the fuel buys the flight out, the other // The furthest it can strike: half the fuel buys the flight out, the other
// half the flight home, capped by the mission radius. // half the flight home, capped by the mission radius.
const effectiveRange = Math.floor( const effectiveRange = Math.floor(
@@ -780,7 +814,7 @@ export const panelMethods = {
); );
const beyondMission = const beyondMission =
toTarget > (proto.missionRange || Infinity); toTarget > (proto.missionRange || Infinity);
const beyondFuel = (toTarget + toHome) / speed > fuel + 1e-9; const beyondFuel = hours > fuel + 1e-9;
if (beyondMission || beyondFuel) { if (beyondMission || beyondFuel) {
return `That target is ${toTarget} tiles away, but this aircraft's effective range is ${effectiveRange} tiles.`; return `That target is ${toTarget} tiles away, but this aircraft's effective range is ${effectiveRange} tiles.`;
} }
+10 -3
View File
@@ -643,10 +643,11 @@ export const entityMethods = {
_currentTile(motion) { _currentTile(motion) {
if (motion.path.length >= 2) { if (motion.path.length >= 2) {
// A flight stores world pixels, so its tile comes from where it is on the
// line; a ground path stores tiles and is grouped with the one it heads
// to, so a stack splits the moment one of its members marches away.
if (motion.flight) return this._motionTile(motion);
const index = pathProgress(motion).index; const index = pathProgress(motion).index;
// A unit that has been ordered off a tile no longer belongs to that
// tile's stack: it is grouped with the tile it is heading to, so the
// stack splits the moment one of its members marches away.
return motion.path[Math.min(index + 1, motion.path.length - 1)]; return motion.path[Math.min(index + 1, motion.path.length - 1)];
} }
return motion.coords; return motion.coords;
@@ -687,11 +688,17 @@ export const entityMethods = {
// selected stack can be ordered onto this tile (move, stack or attack). // selected stack can be ordered onto this tile (move, stack or attack).
// Shift detaches just this unit to the moving group instead. // Shift detaches just this unit to the moving group instead.
if (event.button !== 0) return; if (event.button !== 0) return;
// While picking a ranged target the click belongs to the map, even when
// it lands on an icon; swallowing it here would clear the attacker's
// selection instead of choosing the target.
if (this._targeting) return;
event.stopPropagation(); event.stopPropagation();
if (event.shiftKey) this.onUnitToggled(data.id); if (event.shiftKey) this.onUnitToggled(data.id);
else this.onUnitSelected(data.id); else this.onUnitSelected(data.id);
}); });
view.addEventListener("dblclick", (event) => { view.addEventListener("dblclick", (event) => {
// A double-click while picking a target is just two target clicks.
if (this._targeting) return;
// A unit standing on one of our cities yields to it: double-clicking the // A unit standing on one of our cities yields to it: double-clicking the
// unit opens the city, exactly as double-clicking the city icon would. // unit opens the city, exactly as double-clicking the city icon would.
const current = this._unitData.get(data.id) || data; const current = this._unitData.get(data.id) || data;
+94 -34
View File
@@ -1,7 +1,13 @@
// Dead-reckoning unit motion between snapshots, path drawing and the local // Dead-reckoning unit motion between snapshots, path drawing and the local
// move prediction that mirrors GameState.find_path. // move prediction that mirrors GameState.find_path.
import { mapToLocal, key, parseKey } from "../../../shared/hex.js"; import {
mapToLocal,
localToMap,
flightLine,
key,
parseKey,
} from "../../../shared/hex.js";
import { HexPathfinder } from "../../../shared/hex_pathfinder.js"; import { HexPathfinder } from "../../../shared/hex_pathfinder.js";
import { TRANSPORT_BY_ID } from "../../../shared/data/improvements.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";
@@ -51,9 +57,14 @@ export const motionMethods = {
const id = data.id; const id = data.id;
let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] })); let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] }));
let segmentHours = data.segmentHours || []; let segmentHours = data.segmentHours || [];
// A flight path is already a straight pixel line with no tile occupants to
// stop it: the truncation walk works in tiles and must not touch it.
const flight = this._isFlierData(data);
if (!flight) {
const clipped = this._truncateAtOccupant(path, segmentHours, data, occupancy); const clipped = this._truncateAtOccupant(path, segmentHours, data, occupancy);
path = clipped.path; path = clipped.path;
segmentHours = clipped.segmentHours; segmentHours = clipped.segmentHours;
}
const existing = this._unitMotion.get(id); const existing = this._unitMotion.get(id);
if (path.length < 2 || segmentHours.length === 0) { if (path.length < 2 || segmentHours.length === 0) {
if (existing && existing.predicting && existing.predictAge < PREDICTION_GRACE_HOURS) { if (existing && existing.predicting && existing.predictAge < PREDICTION_GRACE_HOURS) {
@@ -64,6 +75,7 @@ export const motionMethods = {
segmentHours: [], segmentHours: [],
pathHours: 0, pathHours: 0,
coords: { x: data.coords[0], y: data.coords[1] }, coords: { x: data.coords[0], y: data.coords[1] },
flight,
predicting: false, predicting: false,
predictAge: 0, predictAge: 0,
}); });
@@ -81,6 +93,7 @@ export const motionMethods = {
segmentHours, segmentHours,
pathHours, pathHours,
coords: { x: data.coords[0], y: data.coords[1] }, coords: { x: data.coords[0], y: data.coords[1] },
flight,
predicting: false, predicting: false,
predictAge: 0, predictAge: 0,
}); });
@@ -185,10 +198,14 @@ export const motionMethods = {
} }
const from = path[index]; const from = path[index];
const to = path[index + 1] || from; const to = path[index + 1] || from;
const a = mapToLocal(from.x, from.y);
const delta = this.topology.pixelDelta(from, to);
const step = segmentHours[index] || 1; const step = segmentHours[index] || 1;
const t = Math.max(0, Math.min(1, remaining / step)); const t = Math.max(0, Math.min(1, remaining / step));
// A flight path is already world pixels: interpolate the segment directly.
if (motion.flight) {
return { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
}
const a = mapToLocal(from.x, from.y);
const delta = this.topology.pixelDelta(from, to);
return { x: a.x + delta.x * t, y: a.y + delta.y * t }; return { x: a.x + delta.x * t, y: a.y + delta.y * t };
}, },
@@ -204,14 +221,21 @@ export const motionMethods = {
if (!this._selectedUnitIds.has(id)) continue; if (!this._selectedUnitIds.has(id)) continue;
if (motion.path.length < 2) continue; if (motion.path.length < 2) continue;
const index = pathProgress(motion).index; const index = pathProgress(motion).index;
const base = mapToLocal(motion.path[index].x, motion.path[index].y); const base = motion.flight
? motion.path[index]
: mapToLocal(motion.path[index].x, motion.path[index].y);
const current = this._positionAlongPath(motion); const current = this._positionAlongPath(motion);
let previous = base; let previous = base;
for (let i = index + 1; i < motion.path.length; i++) { for (let i = index + 1; i < motion.path.length; i++) {
const from = motion.path[i - 1];
const to = motion.path[i]; const to = motion.path[i];
const delta = this.topology.pixelDelta(from, to); // A flight path stores pixel points already; a ground path stores tiles
const next = { x: previous.x + delta.x, y: previous.y + delta.y }; // and is unwrapped a segment at a time.
const next = motion.flight
? to
: (() => {
const delta = this.topology.pixelDelta(motion.path[i - 1], to);
return { x: previous.x + delta.x, y: previous.y + delta.y };
})();
const start = i === index + 1 ? current : previous; const start = i === index + 1 ? current : previous;
fragment.appendChild(makeSegment(start, next, "#ffffff", 4)); fragment.appendChild(makeSegment(start, next, "#ffffff", 4));
previous = next; previous = next;
@@ -236,12 +260,13 @@ export const motionMethods = {
return mapToLocal(motion.coords.x, motion.coords.y); return mapToLocal(motion.coords.x, motion.coords.y);
}, },
// Draws the red dashed barrage arc from every ranged attacker with a standing // Draws the red dashed line from every ranged attacker with a standing target
// target to the target tile, and a red ring around the target. The shell lobs // to the target tile, and a red ring around the target. A gun's shell lobs in
// in a parabola rather than along the ground, and the dashes march and a // a parabola rather than along the ground; an aircraft's strike is no ballistic
// glowing shell travels the curve so the direction is unmistakable. They stay // launch, so its line stays straight. The dashes march and a glowing shell
// visible so an ongoing barrage cannot be forgotten, and sit on a layer above // travels the line so the direction is unmistakable. They stay visible so an
// the terrain but below the unit and city icons. // ongoing barrage cannot be forgotten, and sit on a layer above the terrain but
// below the unit and city icons.
// //
// The overlay is rebuilt at most every TARGET_REFRESH_MS and immediately when // The overlay is rebuilt at most every TARGET_REFRESH_MS and immediately when
// its contents change; the per-strike target lookup goes through one index of // its contents change; the per-strike target lookup goes through one index of
@@ -296,10 +321,12 @@ export const motionMethods = {
const a = fromPoint; const a = fromPoint;
const b = this._nearestCopy(toPoint, fromPoint); const b = this._nearestCopy(toPoint, fromPoint);
const distance = Math.hypot(b.x - a.x, b.y - a.y); const distance = Math.hypot(b.x - a.x, b.y - a.y);
// A quadratic Bezier lobs the shell: the control point is lifted above the // A gun lobs its shell: a quadratic Bezier whose control point is lifted
// midpoint, its height compensated for the layer's Y foreshortening so the // above the midpoint, its height compensated for the layer's Y
// arc keeps its shape on screen. // foreshortening so the arc keeps its shape on screen. An aircraft flies
const arc = Math.max(18, distance * 0.4) / CAMERA_TILT; // straight to the target, so its line is a plain segment.
const straight = !!(this.protoUnits[data.proto] || {}).air;
const arc = straight ? 0 : Math.max(18, distance * 0.4) / CAMERA_TILT;
const cx = (a.x + b.x) / 2; const cx = (a.x + b.x) / 2;
const cy = (a.y + b.y) / 2 - arc; const cy = (a.y + b.y) / 2 - arc;
let view = views.get(id); let view = views.get(id);
@@ -316,15 +343,20 @@ export const motionMethods = {
view = { path, shell, ring }; view = { path, shell, ring };
views.set(id, view); views.set(id, view);
} }
view.path.setAttribute("d", `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}`); view.path.setAttribute(
"d",
straight
? `M ${a.x} ${a.y} L ${b.x} ${b.y}`
: `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}`
);
view.path.setAttribute("class", selected ? "target-line target-selected" : "target-line"); view.path.setAttribute("class", selected ? "target-line target-selected" : "target-line");
// Marching dashes flow from the battery towards the target. // Marching dashes flow from the battery towards the target.
view.path.style.strokeDashoffset = `${-((now / 22) % 18)}px`; view.path.style.strokeDashoffset = `${-((now / 22) % 18)}px`;
// A glowing shell rides the curve to show exactly where it is going. // A glowing shell rides the line to show exactly where it is going.
const t = (now / 1300) % 1; const t = (now / 1300) % 1;
const mt = 1 - t; const mt = 1 - t;
view.shell.setAttribute("cx", `${mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`); view.shell.setAttribute("cx", `${straight ? a.x + (b.x - a.x) * t : mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`);
view.shell.setAttribute("cy", `${mt * mt * a.y + 2 * mt * t * cy + t * t * b.y}`); view.shell.setAttribute("cy", `${straight ? a.y + (b.y - a.y) * t : mt * mt * a.y + 2 * mt * t * cy + t * t * b.y}`);
view.ring.className = selected ? "target-ring target-selected" : "target-ring"; view.ring.className = selected ? "target-ring target-selected" : "target-ring";
view.ring.style.left = `${b.x}px`; view.ring.style.left = `${b.x}px`;
view.ring.style.top = `${b.y}px`; view.ring.style.top = `${b.y}px`;
@@ -528,9 +560,7 @@ export const motionMethods = {
const view = this._unitViews.get(id); const view = this._unitViews.get(id);
if (view && view.style.display === "none") continue; if (view && view.style.display === "none") continue;
const motion = this._unitMotion.get(id); const motion = this._unitMotion.get(id);
const tile = motion && motion.path.length >= 2 const tile = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
: { x: data.coords[0], y: data.coords[1] };
const k = key(tile.x, tile.y); const k = key(tile.x, tile.y);
if (!index.has(k)) index.set(k, id); if (!index.has(k)) index.set(k, id);
} }
@@ -567,9 +597,15 @@ export const motionMethods = {
const start = this._unitCoords(unitData.id) || const start = this._unitCoords(unitData.id) ||
{ x: unitData.coords[0], y: unitData.coords[1] }; { x: unitData.coords[0], y: unitData.coords[1] };
const segmentHours = []; const segmentHours = [];
if (proto.air || proto.missile) {
for (let i = 1; i < route.length; i++) {
segmentHours.push(this._flightHours(proto, route[i - 1], route[i], speed));
}
} else {
for (let i = 1; i < route.length; i++) { for (let i = 1; i < route.length; i++) {
segmentHours.push(this._stepHours(proto, unitData.civ, route[i], speed)); segmentHours.push(this._stepHours(proto, unitData.civ, route[i], speed));
} }
}
this._applyPrediction(unitData, { route, segmentHours, start }); this._applyPrediction(unitData, { route, segmentHours, start });
return route; return route;
}, },
@@ -581,6 +617,7 @@ export const motionMethods = {
segmentHours: plan.segmentHours, segmentHours: plan.segmentHours,
pathHours: 0, pathHours: 0,
coords: plan.start, coords: plan.start,
flight: this._isFlierData(unitData),
predicting: true, predicting: true,
predictAge: 0, predictAge: 0,
offset: existing ? existing.offset : { x: 0, y: 0 }, offset: existing ? existing.offset : { x: 0, y: 0 },
@@ -600,19 +637,39 @@ export const motionMethods = {
? `${unitData.civ}|${unitData.proto}|${start.x},${start.y}|${goal.x},${goal.y}|${speed}` ? `${unitData.civ}|${unitData.proto}|${start.x},${start.y}|${goal.x},${goal.y}|${speed}`
: null; : null;
if (cacheKey && cache.has(cacheKey)) return cache.get(cacheKey); if (cacheKey && cache.has(cacheKey)) return cache.get(cacheKey);
const path = await this._search(unitData, proto, start, goal, speed); // A flier's route is a straight pixel line to the goal, exactly as the
// server builds it; there is no territory, terrain or zone to search.
const path = (proto.air || proto.missile)
? flightLine(mapToLocal(start.x, start.y), goal, this.topology)
: await this._search(unitData, proto, start, goal, speed);
let plan = null; let plan = null;
if (path && path.length >= 2) { if (path && path.length >= 2) {
const segmentHours = []; const segmentHours = [];
if (proto.air || proto.missile) {
segmentHours.push(this._flightHours(proto, path[0], path[1], speed));
} else {
for (let i = 1; i < path.length; i++) { for (let i = 1; i < path.length; i++) {
segmentHours.push(this._stepHours(proto, unitData.civ, path[i], speed)); segmentHours.push(this._stepHours(proto, unitData.civ, path[i], speed));
} }
}
plan = { route: path, segmentHours, start }; plan = { route: path, segmentHours, start };
} }
if (cacheKey) cache.set(cacheKey, plan); if (cacheKey) cache.set(cacheKey, plan);
return plan; return plan;
}, },
// Whether a snapshot unit flies a straight pixel path (aircraft or missile).
_isFlierData(data) {
const proto = this.protoUnits[data.proto];
return !!(proto && (proto.air || proto.missile));
},
// Hours to fly a straight pixel segment, mirroring GameState._segmentFlightHours.
_flightHours(proto, a, b, speed = null) {
const effective = Math.max(speed || proto.speed, 0.0001);
return (Math.hypot(b.x - a.x, b.y - a.y) / (this._maxStepLength || 1)) / effective;
},
// The A* this mirrors recomputes the cheapest movement cost on every node, // The A* this mirrors recomputes the cheapest movement cost on every node,
// and the tile scan behind it dominated the search. Cache it here (the // and the tile scan behind it dominated the search. Cache it here (the
// TerrainStats memoises it too) and index the units once instead of scanning // TerrainStats memoises it too) and index the units once instead of scanning
@@ -712,9 +769,7 @@ export const motionMethods = {
for (const [id, data] of this._unitData) { for (const [id, data] of this._unitData) {
if (id === ignoreId) continue; if (id === ignoreId) continue;
const motion = this._unitMotion.get(id); const motion = this._unitMotion.get(id);
const tile = motion && motion.path.length >= 2 const tile = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
: { x: data.coords[0], y: data.coords[1] };
if (tile.x === coords.x && tile.y === coords.y) result.push(data); if (tile.x === coords.x && tile.y === coords.y) result.push(data);
} }
return result; return result;
@@ -728,9 +783,7 @@ export const motionMethods = {
for (const [id, data] of this._unitData) { for (const [id, data] of this._unitData) {
if (id === ignoreId) continue; if (id === ignoreId) continue;
const motion = this._unitMotion.get(id); const motion = this._unitMotion.get(id);
const tile = motion && motion.path.length >= 2 const tile = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
: { x: data.coords[0], y: data.coords[1] };
const k = key(tile.x, tile.y); const k = key(tile.x, tile.y);
const list = index.get(k); const list = index.get(k);
if (list) list.push(data); if (list) list.push(data);
@@ -756,16 +809,23 @@ export const motionMethods = {
return denied; return denied;
}, },
_unitCoords(id) { // The tile a motion is over right now. A flight stores world pixels, so its
const motion = this._unitMotion.get(id); // tile is derived from where the line currently is.
if (!motion) return null; _motionTile(motion) {
if (motion.path.length >= 2) { if (motion.path.length >= 2) {
if (motion.flight) return localToMap(this._positionAlongPath(motion), this.topology);
const index = pathProgress(motion).index; const index = pathProgress(motion).index;
return motion.path[Math.min(index, motion.path.length - 1)]; return motion.path[Math.min(index, motion.path.length - 1)];
} }
return motion.coords; return motion.coords;
}, },
_unitCoords(id) {
const motion = this._unitMotion.get(id);
if (!motion) return null;
return this._motionTile(motion);
},
// The civilisation a route is aimed at: the enemy city or unit on the goal, // The civilisation a route is aimed at: the enemy city or unit on the goal,
// or failing that whoever owns the goal tile. Crossing that civ's land is // or failing that whoever owns the goal tile. Crossing that civ's land is
// allowed; every other foreign territory is a wall. // allowed; every other foreign territory is a wall.
+6 -3
View File
@@ -63,10 +63,13 @@ export const AIR_STRIKE = {
cooldownHours: 48, cooldownHours: 48,
}; };
// A city's air defense fires one volley at a hostile aircraft the moment it // A city's air defense watches a corridor of `rangeTiles` around it: any
// enters the city's tile. Damage scales with the building's level. // hostile aircraft or missile inside that corridor takes continuous fire, dealt
// once per in-game hour, scaled by the building's level. Aircraft no longer
// have to enter the city's own tile to be engaged.
export const AIR_DEFENSE = { export const AIR_DEFENSE = {
damagePerLevel: 150, damagePerLevelPerHour: 150,
rangeTiles: 2,
}; };
// Taking a non-melee unit -- a gun, an aircraft -- is risky: four times in five // Taking a non-melee unit -- a gun, an aircraft -- is risky: four times in five
+111 -20
View File
@@ -2,7 +2,7 @@
// where a port may be raised, which cities may train aircraft or warships, and // where a port may be raised, which cities may train aircraft or warships, and
// the endurance model that keeps aircraft tied to a friendly airport. // the endurance model that keeps aircraft tied to a friendly airport.
import { key } from "../hex.js"; import { key, mapToLocal, flightLine, flightDistanceTiles } from "../hex.js";
import { AIR_DEFENSE, AIR_STRIKE, BOMBARD, CITY_BOMBARD } from "../data/combat.js"; import { AIR_DEFENSE, AIR_STRIKE, BOMBARD, CITY_BOMBARD } from "../data/combat.js";
import { BUILDING_MECHANIC } from "../data/buildings.js"; import { BUILDING_MECHANIC } from "../data/buildings.js";
import { RESOURCE_RULES } from "../data/resources.js"; import { RESOURCE_RULES } from "../data/resources.js";
@@ -43,6 +43,17 @@ export const airMethods = {
return this._isAirProto(this.unitProto(unit)); return this._isAirProto(this.unitProto(unit));
}, },
// A unit that flies straight over the hex grid instead of walking it: an
// aircraft, or a missile once launched. Their path is a list of world-pixel
// points, not tiles, and the browser predicts the same line.
_isFlierProto(proto) {
return !!(proto && (proto.air || proto.missile));
},
_isFlier(unit) {
return this._isFlierProto(this.unitProto(unit));
},
// A unit that attacks from range instead of charging into melee: every // A unit that attacks from range instead of charging into melee: every
// aircraft and anything with a reach beyond a single tile. A melee unit has a // aircraft and anything with a reach beyond a single tile. A melee unit has a
// range of one and stays on the ground. // range of one and stays on the ground.
@@ -83,16 +94,59 @@ export const airMethods = {
_pathHours(unit, path) { _pathHours(unit, path) {
let total = 0; let total = 0;
for (let i = 1; i < path.length; i++) total += this._tileTravelHours(unit, path[i]); for (const hours of this._segmentHours(unit, path)) total += hours;
return total; return total;
}, },
// The per-segment hours of a route. A ground route is a list of tiles and
// each step costs its tile's travel time; a flight is a list of world-pixel
// points and each leg costs its straight-line distance. This is the one place
// the two movement models differ, so time and fuel can never drift apart.
_segmentHours(unit, path) {
const hours = [];
if (!path || path.length < 2) return hours;
if (this._isFlier(unit)) {
for (let i = 1; i < path.length; i++) {
hours.push(this._segmentFlightHours(unit, path[i - 1], path[i]));
}
return hours;
}
for (let i = 1; i < path.length; i++) hours.push(this._tileTravelHours(unit, path[i]));
return hours;
},
// Hours to fly the straight pixel segment `a` to `b`.
_segmentFlightHours(unit, a, b) {
const speed = Math.max(unit.moveSpeed || this._unitSpeed(unit), 0.0001);
return (Math.hypot(b.x - a.x, b.y - a.y) / this._maxStepLength) / speed;
},
// Hours to fly straight from tile `from` to tile `to`, the measure the fuel
// and strike-reach checks use.
_flightHours(unit, from, to) {
const speed = Math.max(unit.moveSpeed || this._unitSpeed(unit), 0.0001);
return flightDistanceTiles(from, to, this.topology, this._maxStepLength) / speed;
},
// Where a flier is right now as a world pixel, whether parked or mid-leg.
_flierPixel(unit) {
if (this._hasRoute(unit)) {
const a = unit.path[unit.pathIndex];
const b = unit.path[unit.pathIndex + 1];
const total = this._segmentFlightHours(unit, a, b);
const t = total > 0 ? Math.min(1, Math.max(0, unit.progressHours / total)) : 0;
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
return mapToLocal(unit.coords.x, unit.coords.y);
},
// Routes an aircraft back to a friendly airport, unless the trip would run // Routes an aircraft back to a friendly airport, unless the trip would run
// past its remaining endurance, in which case it stays put and will crash. // past its remaining endurance, in which case it stays put and will crash.
_orderAirReturn(unit) { _orderAirReturn(unit) {
const airport = this._nearestFriendlyAirport(unit); const airport = this._nearestFriendlyAirport(unit);
if (!airport) return false; if (!airport) return false;
const path = this.findPath(unit, unit.coords, airport.coords); const from = this._flierPixel(unit);
const path = flightLine(from, airport.coords, this.topology);
if (path.length < 2) return false; if (path.length < 2) return false;
const proto = this.unitProto(unit); const proto = this.unitProto(unit);
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours; const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
@@ -161,6 +215,14 @@ export const airMethods = {
this._destroyUnit(unit); this._destroyUnit(unit);
} }
if (crashed.length > 0) this._visibilityDirty = true; if (crashed.length > 0) this._visibilityDirty = true;
// The anti-air corridor: every hostile battery within reach fires on each
// flier aloft. A parked aircraft is safe on its own runway, but anything
// crossing defended airspace takes fire for as long as it is inside.
for (const unit of this.units.slice()) {
if (!this._isFlier(unit) || !this._hasRoute(unit)) continue;
if (this._isAirUnit(unit) && this._friendlyAirportAt(unit.coords, unit.civ)) continue;
this._airDefenseVolley(unit);
}
}, },
// ----------------------------------------------------------- strike -- // ----------------------------------------------------------- strike --
@@ -264,18 +326,27 @@ export const airMethods = {
return razed; return razed;
}, },
// Fires the city's air defense at a hostile aircraft that has just entered its // Every hostile anti-air battery within `rangeTiles` of the flier fires one
// tile: one volley per pass, scaled by the building's level. Returns true when // in-game hour of continuous fire: damage scales with the building's level,
// the plane is shot down. // and each battery that fires spends its ammunition. These are not tile-entry
// volleys any more -- a plane is under fire whenever it is inside the
// corridor, so skirting the map's tiles no longer offers cover. Returns true
// when the aircraft is shot down.
_airDefenseVolley(unit) { _airDefenseVolley(unit) {
const city = this.cityAt(unit.coords); let damage = 0;
if (!city || city.civ === unit.civ) return false; for (const city of this.cities) {
if (city.civ === unit.civ) continue;
const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_DEFENSE); const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_DEFENSE);
if (level <= 0) return false; if (level <= 0) continue;
if (this.topology.tileDistance(unit.coords, city.coords) > AIR_DEFENSE.rangeTiles) {
continue;
}
// Air defense is cheap to keep ready and costly to fire: no ammunition, // Air defense is cheap to keep ready and costly to fire: no ammunition,
// no volley. // no fire.
if (!this.payCombatResources(city.civ, "airDefenseVolley", level)) return false; if (!this.payCombatResources(city.civ, "airDefenseVolley", level)) continue;
const damage = Math.max(1, Math.round(AIR_DEFENSE.damagePerLevel * level)); damage += Math.max(1, Math.round(AIR_DEFENSE.damagePerLevelPerHour * level));
}
if (damage <= 0) return false;
unit.hp -= damage; unit.hp -= damage;
if (unit.hp > 0) return false; if (unit.hp > 0) return false;
unit.hp = 0; unit.hp = 0;
@@ -285,6 +356,25 @@ export const airMethods = {
return true; return true;
}, },
// Resolves one missile's warhead where it lands and consumes the rocket. The
// missile flies a straight line to its target (requestMissileStrike); this is
// the moment of detonation.
_detonateMissile(unit) {
const goal = unit.missileTarget;
const nuclear = !!unit.nuclearPayload;
unit.missileTarget = null;
unit.nuclearPayload = false;
if (goal) {
if (nuclear) this._applyNuclearBlast(goal, unit.civ);
else this._resolveConventionalMissile(unit, goal);
}
// A nuclear blast at the missile's own tile already destroyed it above.
if (this.findUnit(unit.id)) this._destroyUnit(unit);
this._invalidateUnitCaches();
this._visibilityDirty = true;
this._emitChanged();
},
// Applies one ranged blow to a target unit, with the special fates of exposed // Applies one ranged blow to a target unit, with the special fates of exposed
// support units, and passes a tenth of the damage on to any structures on the // support units, and passes a tenth of the damage on to any structures on the
// tile. // tile.
@@ -316,10 +406,11 @@ export const airMethods = {
: (proto.range || 1); : (proto.range || 1);
}, },
// Whether an aircraft has the fuel for the whole sortie: fly to `goal`, bomb, // Whether an aircraft has the fuel for the whole sortie: fly straight to
// then run home to a friendly airport. A strike that could not make it back // `goal`, bomb, then run home to a friendly airport. A strike that could not
// is refused up front, so a sortie never strands a plane. Air routes ignore // make it back is refused up front, so a sortie never strands a plane. Both
// the ground, so tile distance is the number of flight steps. // legs are measured along the straight line, so the fuel here and the hours
// the mover actually spends always agree.
_canStrikeAndReturn(unit, goal, fuel = null) { _canStrikeAndReturn(unit, goal, fuel = null) {
const proto = this.unitProto(unit); const proto = this.unitProto(unit);
if (!proto) return false; if (!proto) return false;
@@ -328,10 +419,10 @@ export const airMethods = {
const remaining = fuel === null const remaining = fuel === null
? (unit.airHours === undefined ? proto.enduranceHours : unit.airHours) ? (unit.airHours === undefined ? proto.enduranceHours : unit.airHours)
: fuel; : fuel;
const tiles = const hours =
this.topology.tileDistance(unit.coords, goal) + this._flightHours(unit, unit.coords, goal) +
this.topology.tileDistance(goal, airport.coords); this._flightHours(unit, goal, airport.coords);
return tiles / this._unitSpeed(unit) <= remaining + 1e-9; return hours <= remaining + 1e-9;
}, },
// Whether a unit may strike `goal` right now: inside its reach and, for an // Whether a unit may strike `goal` right now: inside its reach and, for an
+103 -19
View File
@@ -1,7 +1,7 @@
// Movement: translating orders into paths, advancing units along them each // Movement: translating orders into paths, advancing units along them each
// 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, mapToLocal, localToMap, flightLine } from "../hex.js";
import { TRANSPORT_BY_ID } from "../data/improvements.js"; import { TRANSPORT_BY_ID } from "../data/improvements.js";
import { import {
MIN_STEP_COST, MIN_STEP_COST,
@@ -188,6 +188,12 @@ export const movementMethods = {
for (const unit of this.units.slice()) { for (const unit of this.units.slice()) {
if (!this.findUnit(unit.id)) continue; if (!this.findUnit(unit.id)) continue;
if (!this._hasRoute(unit) && (!unit.waypoints || unit.waypoints.length === 0)) continue; if (!this._hasRoute(unit) && (!unit.waypoints || unit.waypoints.length === 0)) continue;
// Aircraft and launched missiles fly straight lines in pixel space; the
// tile walker below cannot advance them.
if (this._isFlier(unit)) {
this._advanceFlier(unit, hours);
continue;
}
if (engaged.has(unit)) continue; if (engaged.has(unit)) continue;
let hoursLeft = hours; let hoursLeft = hours;
while (hoursLeft > 0) { while (hoursLeft > 0) {
@@ -225,24 +231,7 @@ export const movementMethods = {
this._moveUnitTo(unit, next); this._moveUnitTo(unit, next);
this._visibilityDirty = true; this._visibilityDirty = true;
this._onUnitArrived(unit); this._onUnitArrived(unit);
if (this._isAirUnit(unit)) { if (this._hasHostileOn(unit.coords, unit.civ)) {
// A hostile city's air defense gets one volley as the plane enters
// its tile; a plane shot down here never drops its bombs.
if (this._airDefenseVolley(unit)) break;
// Aircraft never join a melee. Reaching the target tile of a bomb
// run resolves the strike; the next hourly tick sends the plane
// home to re-arm.
if (unit.strikeTarget &&
unit.coords.x === unit.strikeTarget.x &&
unit.coords.y === unit.strikeTarget.y) {
this._resolveAirStrike(unit);
this._stopUnit(unit);
// Turn for home at once: idling on the target until the next
// hourly tick would burn fuel the round trip never budgeted for.
this._orderAirReturn(unit);
break;
}
} else if (this._hasHostileOn(unit.coords, unit.civ)) {
// The fight is joined: hold here and lock every participant. The // The fight is joined: hold here and lock every participant. The
// unit that charged in is the attacker; the occupants defend. // unit that charged in is the attacker; the occupants defend.
this._stopUnit(unit); this._stopUnit(unit);
@@ -267,6 +256,90 @@ export const movementMethods = {
} }
}, },
// Advances a flier along its straight pixel path: aircraft and launched
// missiles. Unlike the tile walker there is no terrain, no occupancy and no
// obstacle to route around, so the leg simply runs its hours and the plane's
// tile is derived from where the pixel line is.
_advanceFlier(unit, hours) {
unit._flightHalted = false;
let hoursLeft = hours;
while (hoursLeft > 1e-9) {
if (!this._hasRoute(unit)) {
if (!unit.waypoints || unit.waypoints.length === 0) break;
if (!this._followNextWaypoint(unit)) break;
continue;
}
const a = unit.path[unit.pathIndex];
const b = unit.path[unit.pathIndex + 1];
const total = this._segmentFlightHours(unit, a, b);
if (!(total > 0)) {
// A zero-length leg (already at the destination) completes at once.
this._completeFlightLeg(unit);
if (!this.findUnit(unit.id) || unit._flightHalted) break;
continue;
}
const needed = total - unit.progressHours;
if (hoursLeft + 1e-9 < needed) {
unit.progressHours += hoursLeft;
hoursLeft = 0;
this._syncFlierCoords(unit);
break;
}
hoursLeft -= needed;
unit.progressHours = total;
this._completeFlightLeg(unit);
if (!this.findUnit(unit.id) || unit._flightHalted) break;
}
if (this.findUnit(unit.id) && !this._hasRoute(unit) &&
(!unit.waypoints || unit.waypoints.length === 0)) {
this._stopUnit(unit);
}
},
// Finishes one flight leg: lands the flier on its destination tile, resolves
// a bomb run or missile warhead, and lets a scheduled move pick up its next
// destination. `_flightHalted` tells the mover to stop consuming hours, for
// example after a plane turns for home.
_completeFlightLeg(unit) {
unit.pathIndex += 1;
unit.progressHours = 0;
unit.coords = localToMap(unit.path[unit.pathIndex], this.topology);
this._visibilityDirty = true;
// A missile's flight is its whole life: the warhead goes off where it lands,
// and it must never capture the tile it flies over (a missile is not an
// aircraft, so the capture rules would otherwise treat it as a ground unit).
if (this._isMissileUnit(unit)) {
this._detonateMissile(unit);
return;
}
this._onUnitArrived(unit);
if (!this.findUnit(unit.id)) return;
// A bomb run that has reached its target bombs once, then turns for home.
if (unit.strikeTarget &&
unit.coords.x === unit.strikeTarget.x && unit.coords.y === unit.strikeTarget.y) {
this._resolveAirStrike(unit);
this._stopUnit(unit);
this._orderAirReturn(unit);
unit._flightHalted = true;
}
},
// Keeps a flying unit's tile in step with its pixel position, so visibility,
// combat and the snapshot follow the plane between snapshot ticks.
_syncFlierCoords(unit) {
if (!this._hasRoute(unit)) return;
const a = unit.path[unit.pathIndex];
const b = unit.path[unit.pathIndex + 1];
const total = this._segmentFlightHours(unit, a, b);
const t = total > 0 ? Math.min(1, Math.max(0, unit.progressHours / total)) : 0;
const pixel = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
const tile = localToMap(pixel, this.topology);
if (tile.x !== unit.coords.x || tile.y !== unit.coords.y) {
unit.coords = tile;
this._visibilityDirty = true;
}
},
// A hostile military unit standing on the next tile is an opponent to fight, // A hostile military unit standing on the next tile is an opponent to fight,
// not an obstacle to route around. // not an obstacle to route around.
_isBlockingEnemy(unit, occupant) { _isBlockingEnemy(unit, occupant) {
@@ -499,6 +572,17 @@ export const movementMethods = {
? `${unit.civ}|${unit.proto}|${start.x},${start.y}|${goal.x},${goal.y}` ? `${unit.civ}|${unit.proto}|${start.x},${start.y}|${goal.x},${goal.y}`
: null; : null;
if (cacheKey && pathCache.has(cacheKey)) return pathCache.get(cacheKey); if (cacheKey && pathCache.has(cacheKey)) return pathCache.get(cacheKey);
// A flier ignores the ground: a straight line is its whole route, so the A*
// search -- and with it terrain, zone-of-control and occupancy -- never
// runs. The route is a pair of world-pixel points, the same line the
// browser draws and predicts.
if (this._isFlier(unit)) {
const path = start.x === goal.x && start.y === goal.y
? [mapToLocal(start.x, start.y)]
: flightLine(mapToLocal(start.x, start.y), goal, this.topology);
if (cacheKey) pathCache.set(cacheKey, path);
return path;
}
const occupancy = this._buildOccupancy(unit); const occupancy = this._buildOccupancy(unit);
const targetCiv = this._targetCivFor(unit, goal, occupancy); const targetCiv = this._targetCivFor(unit, goal, occupancy);
const zoc = this._zoneOfControl(unit.civ); const zoc = this._zoneOfControl(unit.civ);
+6 -6
View File
@@ -297,14 +297,14 @@ export const serializationMethods = {
return this.units.map((unit) => { return this.units.map((unit) => {
const rawPath = unit.path || []; const rawPath = unit.path || [];
const pathIndex = unit.pathIndex; const pathIndex = unit.pathIndex;
const path = rawPath.map((coords) => [coords.x, coords.y]); // A ground path is a list of tiles; a flight is a list of world-pixel
const segmentHours = []; // points. Both ship as [x, y] pairs and get their per-segment hours from
for (let i = 1; i < rawPath.length; i++) { // the one shared calculator, so the browser need not know which it is.
segmentHours.push(this._tileTravelHours(unit, rawPath[i])); const path = rawPath.map((point) => [point.x, point.y]);
} const segmentHours = this._segmentHours(unit, rawPath);
let stepHours = 0; let stepHours = 0;
if (pathIndex + 1 < rawPath.length) { if (pathIndex + 1 < rawPath.length) {
stepHours = this._tileTravelHours(unit, rawPath[pathIndex + 1]); stepHours = this._segmentHours(unit, [rawPath[pathIndex], rawPath[pathIndex + 1]])[0] || 0;
} }
return { return {
id: unit.id, id: unit.id,
+12 -4
View File
@@ -35,7 +35,9 @@ export const wmdMethods = {
// Fires one or more missiles at `goal`. A conventional warhead strikes the // Fires one or more missiles at `goal`. A conventional warhead strikes the
// unit or city there; a nuclear one flattens the whole tile. `nuclear` is // unit or city there; a nuclear one flattens the whole tile. `nuclear` is
// honoured only for a nation that has researched the bomb, and an ICBM is // honoured only for a nation that has researched the bomb, and an ICBM is
// always nuclear. Every missile is consumed, so the stack is gone afterwards. // always nuclear. Every missile is consumed, but only when its straight-line
// flight reaches the target: `_detonateMissile` runs the warhead on arrival,
// so a missile can be shot down by air defense on the way.
requestMissileStrike(unitIds, goal, { nuclear = false } = {}) { requestMissileStrike(unitIds, goal, { nuclear = false } = {}) {
const units = this._unitsForOrder(unitIds).filter((unit) => this._isMissileUnit(unit)); const units = this._unitsForOrder(unitIds).filter((unit) => this._isMissileUnit(unit));
if (units.length === 0) return false; if (units.length === 0) return false;
@@ -55,9 +57,15 @@ export const wmdMethods = {
if (units.some((unit) => !this._missileInRange(unit, goal))) return false; if (units.some((unit) => !this._missileInRange(unit, goal))) return false;
this._declareWar(civ, target ? target.civ : city.civ); this._declareWar(civ, target ? target.civ : city.civ);
for (const unit of units) { for (const unit of units) {
if (useNuclear) this._applyNuclearBlast(goal, civ); const path = this.findPath(unit, unit.coords, goal);
else this._resolveConventionalMissile(unit, goal); if (path.length < 2) return false;
this._destroyUnit(unit); unit.path = path;
unit.pathIndex = 0;
unit.progressHours = 0;
unit.moveSpeed = null;
unit.waypoints = [];
unit.missileTarget = { x: goal.x, y: goal.y };
unit.nuclearPayload = useNuclear;
} }
this._invalidateUnitCaches(); this._invalidateUnitCaches();
this._visibilityDirty = true; this._visibilityDirty = true;
+50
View File
@@ -55,6 +55,56 @@ export function mapToLocal(x, y) {
return { x: COL_STEP * x, y: HEX_H * (y + 0.5 * parity(x)) }; return { x: COL_STEP * x, y: HEX_H * (y + 0.5 * parity(x)) };
} }
// The tile whose centre is nearest a world pixel point: the inverse of
// `mapToLocal`. Three candidate columns are tested (the point can sit just past
// a column boundary); the winning cell is folded back into the map when the
// world wraps.
export function localToMap(point, topology = null) {
const column = Math.round(point.x / COL_STEP);
let best = null;
let bestDistance = Infinity;
for (let x = column - 1; x <= column + 1; x++) {
const originY = HEX_H * 0.5 * parity(x);
const y = Math.round((point.y - originY) / HEX_H);
const center = mapToLocal(x, y);
const distance = (center.x - point.x) ** 2 + (center.y - point.y) ** 2;
if (distance < bestDistance) {
bestDistance = distance;
best = { x, y };
}
}
return topology ? topology.wrapCoords(best.x, best.y) : best;
}
// The copy of a world pixel point nearest `reference`, a whole number of map
// periods away. A flat map returns the point unchanged. Mirrors the browser's
// `_nearestCopy`, so a flight across the seam is drawn where the server flies.
export function nearestCopyPoint(point, reference, topology) {
const period = topology && topology.periodPixels ? topology.periodPixels() : 0;
if (!period) return { x: point.x, y: point.y };
const k = Math.round((reference.x - point.x) / period);
return { x: point.x + k * period, y: point.y };
}
// A straight flight: the world-pixel points from `fromPoint` to the nearest
// wrapped copy of the tile `toCoords`. `fromPoint` is already a pixel, so a
// plane mid-flight can be redirected without snapping back to a tile centre.
export function flightLine(fromPoint, toCoords, topology) {
const to = mapToLocal(toCoords.x, toCoords.y);
return [
{ x: fromPoint.x, y: fromPoint.y },
nearestCopyPoint(to, fromPoint, topology),
];
}
// Straight-line distance between two tile centres in tile units, taking the
// shortest wrapped route and scaling by the longest hex step. This is the
// aircraft mile: fuel and mission reach are both measured along it.
export function flightDistanceTiles(from, to, topology, maxStepLength) {
const delta = topology.pixelDelta(from, to);
return Math.hypot(delta.x, delta.y) / (maxStepLength || 1);
}
export class MapTopology { export class MapTopology {
constructor(size, cylindrical) { constructor(size, cylindrical) {
this.width = size.x; this.width = size.x;
+126
View File
@@ -0,0 +1,126 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { mapToLocal, flightLine, flightDistanceTiles, key } from "../shared/hex.js";
import { AIR_DEFENSE } from "../shared/data/combat.js";
const BIG_BUDGET = 1.0e15;
function buildingIndex(state, id) {
return state.protoBuildings.findIndex((building) => building.id === id);
}
function protoOf(state, id) {
return state.protoUnits.find((unit) => unit.id === id);
}
function giveBuilding(state, city, id, level = 1) {
city.buildings[buildingIndex(state, id)] = level;
state._gdpPerCapitaCache.clear();
}
// Any tile exactly `distance` hex steps from `center`.
function tileAtDistance(state, center, distance) {
const seen = new Set([key(center.x, center.y)]);
let frontier = [{ x: center.x, y: center.y }];
for (let step = 0; step < distance; step++) {
const next = [];
for (const cell of frontier) {
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
const k = key(neighbour.x, neighbour.y);
if (seen.has(k) || !state.tiles[k]) continue;
seen.add(k);
next.push(neighbour);
}
}
frontier = next;
}
return frontier[0] || null;
}
export class AirMovementTest extends TestCase {
test_a_flight_is_a_straight_two_point_line() {
const state = smallState();
const airport = buildingIndex(state, "airport");
const cities = state.cities.filter((city) => city.civ === 0);
for (const city of cities) city.buildings[airport] = 1;
const [home, away] = cities;
const jet = protoOf(state, "jet_fighter");
const unit = state._spawnUnit(home.coords, 0, jet, home.id);
const path = state.findPath(unit, home.coords, away.coords);
this.assertEqual(path.length, 2, "a flight is two world-pixel points");
const expected = flightLine(
mapToLocal(home.coords.x, home.coords.y),
away.coords,
state.topology
);
this.assertApprox(path[0].x, expected[0].x, 1e-9, "the flight starts at the tile centre");
this.assertApprox(path[1].x, expected[1].x, 1e-9);
this.assertApprox(path[1].y, expected[1].y, 1e-9);
// The one segment's hours are the straight-line distance over speed, so
// fuel and flight time can never disagree.
const hours = state._pathHours(unit, path);
const expectedHours =
flightDistanceTiles(home.coords, away.coords, state.topology, state._maxStepLength) /
jet.speed;
this.assertApprox(hours, expectedHours, 1e-9, "time follows the straight line");
const segmentHours = state._segmentHours(unit, path);
this.assertEqual(segmentHours.length, 1, "one leg, one segment");
this.assertApprox(segmentHours[0], expectedHours, 1e-9);
}
test_a_ferry_flight_only_needs_a_runway_at_the_destination() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const airport = buildingIndex(state, "airport");
const cities = state.cities.filter((city) => city.civ === 0);
for (const city of cities) city.buildings[airport] = 1;
const [home, away] = cities;
const jet = protoOf(state, "jet_fighter");
const unit = state._spawnUnit(home.coords, 0, jet, home.id);
this.assertTrue(
state.requestGroupMove([unit.id], away.coords),
"a ferry to a friendly airport is accepted"
);
this.assertEqual(unit.path.length, 2, "the ferry is a straight line");
// The plane reaches the far airport and lands, refuelling there.
for (let hour = 0; hour < 40 && unit.coords.x !== away.coords.x; hour++) {
state.advanceHour();
}
this.assertEqual(unit.coords.x, away.coords.x, "it crossed in a straight line");
this.assertEqual(unit.coords.y, away.coords.y);
this.assertFalse(unit.airborne, "and landed");
}
test_air_defense_fires_across_its_corridor() {
const state = smallState();
const city = state.cities.find((entry) => entry.civ === 1);
giveBuilding(state, city, "aa_building", 1);
const jet = protoOf(state, "jet_fighter");
const unit = state._spawnUnit(state.landCells[0], 0, jet);
// Inside the corridor but not standing on the city tile: the old tile-entry
// volley would have missed it.
const near = tileAtDistance(state, city.coords, AIR_DEFENSE.rangeTiles);
this.assertNotNull(near, "there is a tile inside the corridor");
unit.coords = { x: near.x, y: near.y };
this.assertFalse(
unit.coords.x === city.coords.x && unit.coords.y === city.coords.y,
"the plane is not over the city"
);
const before = unit.hp;
state._airDefenseVolley(unit);
this.assertLess(unit.hp, before, "a plane inside the corridor takes fire");
// One step further out is beyond the battery's reach.
const far = tileAtDistance(state, city.coords, AIR_DEFENSE.rangeTiles + 1);
this.assertNotNull(far, "there is a tile outside the corridor");
unit.hp = unit.maxHp;
unit.coords = { x: far.x, y: far.y };
const safe = unit.hp;
state._airDefenseVolley(unit);
this.assertEqual(unit.hp, safe, "a plane outside the corridor is untouched");
}
}
+43
View File
@@ -556,6 +556,49 @@ export class GameScreenTest extends TestCase {
} }
} }
async test_air_strike_on_a_fogged_neutral_city_asks_to_declare_war() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const home = state.cities.find((c) => c.civ === 0);
grantBuilding(state, home, "airport");
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
const jet = state._spawnUnit(home.coords, 0, jetProto, home.id);
const city = state.cities.find((c) => c.civ === 1);
// Defenceless and under the fog: no garrison is visible, but a known city
// tile is still a legitimate air-strike target.
for (const unit of state.unitsAt(city.coords).slice()) state._destroyUnit(unit);
screen.onState(state.snapshot(0));
screen._selectUnits([jet.id]);
this.assertTrue(
screen._isEnemyCityAt({ x: city.coords.x, y: city.coords.y }),
"the fogged city is a target"
);
screen._toggleTargeting();
this.assertNotNull(screen._targeting, "targeting is armed");
screen._chooseTarget({ x: city.coords.x, y: city.coords.y });
for (let i = 0; i < 100 && env.$("#confirm-title").text() !== "Declare war?"; i++) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
this.assertEqual(
env.$("#confirm-title").text(),
"Declare war?",
"attacking a neutral city asks first, exactly like marching in"
);
env.$("#confirm-ok").trigger("click");
const order = network.orders.find((entry) => entry.type === "attack");
this.assertNotNull(order, "confirming sends the air strike");
this.assertEqual(order.coords[0], city.coords.x);
this.assertEqual(order.coords[1], city.coords.y);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_air_strike_beyond_the_round_trip_is_refused() { async test_air_strike_beyond_the_round_trip_is_refused() {
const env = await setupDom(); const env = await setupDom();
try { try {
+7 -1
View File
@@ -221,7 +221,13 @@ export class GameServerTest extends TestCase {
server.handleOrder(1, { type: "attack", unit: missile.id, coords: [target.x, target.y] }), server.handleOrder(1, { type: "attack", unit: missile.id, coords: [target.x, target.y] }),
"the server accepts the missile order" "the server accepts the missile order"
); );
this.assertNull(state.findUnit(missile.id), "the rocket is consumed, not marched"); // The rocket flies a straight line to the target and is consumed when the
// warhead goes off, not the moment it is launched.
for (let hour = 0; hour < 200 && state.findUnit(missile.id); hour++) {
server.advanceMovement(1.0);
server.tickHour();
}
this.assertNull(state.findUnit(missile.id), "the rocket is consumed on arrival");
this.assertNull(state.findUnit(enemy.id), "and the target is destroyed"); this.assertNull(state.findUnit(enemy.id), "and the target is destroyed");
} }
+69 -1
View File
@@ -1,5 +1,16 @@
import { TestCase } from "./framework/test_case.js"; import { TestCase } from "./framework/test_case.js";
import { MapTopology, mapToLocal, key, parseKey, COL_STEP, HEX_H } from "../shared/hex.js"; import {
MapTopology,
mapToLocal,
localToMap,
nearestCopyPoint,
flightLine,
flightDistanceTiles,
key,
parseKey,
COL_STEP,
HEX_H,
} from "../shared/hex.js";
export class HexTest extends TestCase { export class HexTest extends TestCase {
flat() { flat() {
@@ -68,6 +79,63 @@ export class HexTest extends TestCase {
); );
} }
test_local_to_map_inverts_map_to_local() {
const flat = this.flat();
for (const coords of [{ x: 0, y: 0 }, { x: 3, y: -4 }, { x: -7, y: 2 }]) {
this.assertEqual(localToMap(mapToLocal(coords.x, coords.y), flat), coords);
}
// A pixel a few units off a centre still resolves to that tile.
const centre = mapToLocal(2, 3);
this.assertEqual(localToMap({ x: centre.x + 4, y: centre.y - 3 }, flat), { x: 2, y: 3 });
// On a cylinder the resolved tile folds back into range.
this.assertEqual(localToMap(mapToLocal(5, 0), this.cylinder()), { x: -5, y: 0 });
}
test_nearest_copy_point_folds_by_whole_periods() {
const point = mapToLocal(0, 0);
const period = this.cylinder().periodPixels();
const reference = { x: point.x - period * 1.2, y: 0 };
const copy = nearestCopyPoint(point, reference, this.cylinder());
this.assertApprox(copy.x, point.x - period, 1e-9);
this.assertEqual(copy.y, point.y);
// A flat map returns the point untouched.
this.assertEqual(nearestCopyPoint(point, reference, this.flat()), point);
}
test_flight_line_takes_the_shortest_wrapped_copy() {
const topology = this.cylinder();
const from = mapToLocal(-5, 0);
// -5 and 4 are neighbours across the seam, one map period apart.
const line = flightLine(from, { x: 4, y: 0 }, topology);
this.assertEqual(line.length, 2);
this.assertEqual(line[0], from);
// Adjacent columns are offset by half a hex, so a neighbour hop is a
// diagonal of hypot(COL_STEP, HEX_H/2), not a bare COL_STEP.
const hop = Math.hypot(COL_STEP, HEX_H / 2);
this.assertApprox(
Math.hypot(line[1].x - line[0].x, line[1].y - line[0].y),
hop,
1e-6,
"the flight leaves one edge and re-enters the other"
);
}
test_flight_distance_measures_tiles_along_the_line() {
const maxStep = HEX_H;
const hop = Math.hypot(COL_STEP, HEX_H / 2);
this.assertApprox(
flightDistanceTiles({ x: 0, y: 0 }, { x: 1, y: 0 }, this.flat(), maxStep),
hop / maxStep,
1e-9
);
// Across the seam on a cylinder it is the short hop, not the long way.
this.assertApprox(
flightDistanceTiles({ x: -5, y: 0 }, { x: 4, y: 0 }, this.cylinder(), maxStep),
hop / maxStep,
1e-9
);
}
test_key_and_parse_key_round_trip_and_odd_column_offset() { test_key_and_parse_key_round_trip_and_odd_column_offset() {
for (const coords of [{ x: 0, y: 0 }, { x: 3, y: -4 }, { x: -12, y: 7 }]) { for (const coords of [{ x: 0, y: 0 }, { x: 3, y: -4 }, { x: -12, y: 7 }]) {
this.assertEqual(parseKey(key(coords.x, coords.y)), coords); this.assertEqual(parseKey(key(coords.x, coords.y)), coords);
+24
View File
@@ -387,6 +387,30 @@ export class MapViewInputTest extends MapViewFixture {
} }
} }
async test_a_unit_icon_does_not_steal_a_target_click() {
const state = smallState();
const attacker = state.units.find((u) => u.civ === 0);
const goal = adjacentLand(state, attacker);
const defender = state._spawnUnit(goal, 1, state.protoUnits[0]);
const { env, map } = await this.build(state);
try {
let selected = -1;
let reachedMap = false;
map.onUnitSelected = (id) => { selected = id; };
map.$viewport.on("mousedown", () => { reachedMap = true; });
map.beginTargeting({ unitIds: [attacker.id], origin: attacker.coords, range: 3 });
const view = map._unitViews.get(defender.id);
this.assertNotNull(view, "the defender has a view");
// Mousedown on the enemy icon while picking a target must fall through to
// the map, not select the enemy (which would clear the attacker).
view.dispatchEvent(new env.window.MouseEvent("mousedown", { button: 0, bubbles: true }));
this.assertEqual(selected, -1, "the target icon does not steal the click");
this.assertTrue(reachedMap, "the click reaches the map so the target is chosen");
} finally {
teardownDom(env);
}
}
async test_ctrl_right_click_schedules_a_destination() { async test_ctrl_right_click_schedules_a_destination() {
const state = smallState(); const state = smallState();
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
+49
View File
@@ -203,6 +203,33 @@ export class MapViewRenderTest extends MapViewFixture {
} }
} }
async test_an_aircraft_is_predicted_along_the_server_flight_line() {
const state = smallState();
const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
const cities = state.cities.filter((c) => c.civ === 0);
for (const city of cities) city.buildings[airport] = 1;
const [home, away] = cities;
const jet = state.protoUnits.find((p) => p.id === "jet_fighter");
const unit = state._spawnUnit(home.coords, 0, jet, home.id);
const { env, map, snapshot } = await this.build(state);
try {
const data = snapshot.units.find((u) => u.id === unit.id);
const route = await map.planMove(data, away.coords);
this.assertNotNull(route, "a flight was planned");
this.assertEqual(route.length, 2, "the flight is a straight two-point line");
// The browser's flight time matches the server's for the same leg.
const clientHours = map._flightHours(jet, route[0], route[1], null);
const serverHours = state._pathHours(unit, state.findPath(unit, home.coords, away.coords));
this.assertApprox(clientHours, serverHours, 1e-9, "browser and server agree on flight time");
await map.predictMove(data, away.coords, null, route);
const motion = map._unitMotion.get(unit.id);
this.assertTrue(motion.flight, "the motion is flagged as a flight");
this.assertEqual(motion.path.length, 2);
} finally {
teardownDom(env);
}
}
async test_plan_move_reuses_a_shared_route() { async test_plan_move_reuses_a_shared_route() {
const state = smallState(); const state = smallState();
const { env, map, snapshot } = await this.build(state); const { env, map, snapshot } = await this.build(state);
@@ -250,6 +277,28 @@ export class MapViewRenderTest extends MapViewFixture {
} }
} }
async test_an_air_strike_line_is_straight_not_an_arc() {
const state = smallState();
const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c));
const jet = state._spawnUnit(spot, 0, state.protoUnits.find((p) => p.id === "jet_fighter"));
const goal = state.topology
.neighbours(spot.x, spot.y)
.find((n) => state._isLand(n) && !state.unitAt(n));
jet.strikeTarget = { x: goal.x, y: goal.y };
const { env, map } = await this.build(state);
try {
map.setSelectedUnit(jet.id);
map.animate(0);
const d = env.$("#layer-targets .target-line").get(0).getAttribute("d");
this.assertTrue(d.includes(" L "), "the air strike is a straight segment");
this.assertTrue(!d.includes("Q"), "no ballistic Bezier for an aircraft");
this.assertSize(env.$("#layer-targets .target-shell"), 1, "a shell still travels the line");
this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed");
} finally {
teardownDom(env);
}
}
async test_draws_a_bombardment_line_and_ring_always_visible() { async test_draws_a_bombardment_line_and_ring_always_visible() {
const state = smallState(); const state = smallState();
const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c)); const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c));
+13 -3
View File
@@ -59,6 +59,12 @@ function freeLand(state) {
return null; return null;
} }
// A missile is no longer consumed the instant it is fired: it flies a straight
// line to its target and only then detonates. Fly the clock until it is gone.
function flyUntilGone(state, id, maxHours = 200) {
for (let hour = 0; hour < maxHours && state.findUnit(id); hour++) state.advanceHour();
}
export class WmdTest extends TestCase { export class WmdTest extends TestCase {
test_nuclear_weapons_research_needs_an_enrichment_centre() { test_nuclear_weapons_research_needs_an_enrichment_centre() {
const state = smallState(); const state = smallState();
@@ -89,7 +95,9 @@ export class WmdTest extends TestCase {
state.requestMissileStrike([missile.id], targetCoords), state.requestMissileStrike([missile.id], targetCoords),
"the strike is accepted" "the strike is accepted"
); );
this.assertNull(state.findUnit(missile.id), "the rocket is consumed"); this.assertNotNull(state.findUnit(missile.id), "the rocket is in flight");
flyUntilGone(state, missile.id);
this.assertNull(state.findUnit(missile.id), "the rocket is consumed on arrival");
this.assertNull(state.findUnit(enemy.id), "the infantry is destroyed by the blast"); this.assertNull(state.findUnit(enemy.id), "the infantry is destroyed by the blast");
} }
@@ -112,7 +120,8 @@ export class WmdTest extends TestCase {
state.requestMissileStrike([icbm.id], far), state.requestMissileStrike([icbm.id], far),
"an ICBM reaches any tile" "an ICBM reaches any tile"
); );
this.assertNull(state.findUnit(icbm.id), "the ICBM is consumed"); flyUntilGone(state, icbm.id);
this.assertNull(state.findUnit(icbm.id), "the ICBM is consumed on arrival");
this.assertNull(state.findUnit(enemy.id), "the target is destroyed"); this.assertNull(state.findUnit(enemy.id), "the target is destroyed");
} }
@@ -136,7 +145,8 @@ export class WmdTest extends TestCase {
state.requestMissileStrike([missile.id], targetCoords, { nuclear: true }), state.requestMissileStrike([missile.id], targetCoords, { nuclear: true }),
"with the bomb the strike goes ahead" "with the bomb the strike goes ahead"
); );
this.assertNull(state.findUnit(missile.id), "the missile was fired"); flyUntilGone(state, missile.id);
this.assertNull(state.findUnit(missile.id), "the missile flew and detonated");
this.assertNull(state.findUnit(enemy.id), "the garrison is wiped out"); this.assertNull(state.findUnit(enemy.id), "the garrison is wiped out");
} }