Flew aircraft and missiles straight and let strikes target fogged cities
This commit is contained in:
@@ -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
|
||||
(`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
|
||||
melee. A strike may target a garrison inside a hostile city — the plane enters
|
||||
that tile as a goal only to bomb and turns for home, and captures skip
|
||||
melee. A strike may target a garrison inside a hostile city — the plane reaches
|
||||
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
|
||||
when the whole round trip fits the remaining
|
||||
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
|
||||
`map_view/motion.js` draws the red dashed line and target ring on
|
||||
`#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
|
||||
`GameState._buildTerrain` and the settlement/road step cache the world shape
|
||||
(`shared/game_state/world.js`) rather than regenerating it for every game with
|
||||
|
||||
+1
-1
@@ -322,7 +322,7 @@ simplification worth revisiting. Anything unticked is still open.
|
||||
(any unit caught in the blast is destroyed too)
|
||||
- [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
|
||||
- [ ] 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
|
||||
|
||||
### Intelligence
|
||||
|
||||
@@ -387,6 +387,14 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
/* Picking a target takes over the cursor. */
|
||||
#map-viewport.targeting { cursor: crosshair; }
|
||||
#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
|
||||
delivery leg, an animated merchandise icon riding it and a dot on every
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// 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.
|
||||
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",
|
||||
"date": "2026-09-24",
|
||||
@@ -54,11 +60,5 @@ export const DEVLOG = [
|
||||
"date": "2026-09-23",
|
||||
"subject": "Drew the cylindrical map as a repeating world",
|
||||
"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."
|
||||
}
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MapView } from "./map_view.js";
|
||||
import { CityModal, NationModal, TileModal, ConfirmModal, NewsModal, MapModesModal, CapabilityModal } from "./modals.js";
|
||||
import { GameFeed } from "./feed.js";
|
||||
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
|
||||
import { flightDistanceTiles } from "../../shared/hex.js";
|
||||
import { bootLog } from "./loading.js";
|
||||
import { formatCapabilityReport } from "./capabilities.js";
|
||||
import { panelMethods } from "./game_screen/panels.js";
|
||||
@@ -456,6 +457,19 @@ export class GameScreen {
|
||||
}
|
||||
}
|
||||
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
|
||||
// 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.
|
||||
@@ -478,9 +492,12 @@ export class GameScreen {
|
||||
if (!route) return false;
|
||||
if (!proto.air) return false;
|
||||
// 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);
|
||||
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) {
|
||||
this.confirmModal.show("Out of range", "That target lies beyond this unit's reach.", null);
|
||||
|
||||
@@ -47,6 +47,7 @@ import { defenseFactors, unitDefense, productionFactors, productionMultiplier, p
|
||||
import { formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
|
||||
import { dateString, hoursInNextMonth } from "../../../shared/game_clock.js";
|
||||
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
|
||||
import { flightDistanceTiles } from "../../../shared/hex.js";
|
||||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||||
import { isEconomicMode } from "../map_view/economic.js";
|
||||
import { isWaterTile } from "../map_view/textures.js";
|
||||
@@ -312,6 +313,9 @@ export const panelMethods = {
|
||||
if (!route || route.length < 2) continue;
|
||||
routes.set(unit.id, route);
|
||||
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) {
|
||||
const owner = this.map.territory.get(`${point.x},${point.y}`);
|
||||
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
|
||||
// holds a hostile military unit; a spy operation files its report or opens its
|
||||
// bombardment when the tile holds the city it wants.
|
||||
// holds a hostile military unit, or -- for an aircraft -- a hostile city; a
|
||||
// spy operation files its report or opens its bombardment when the tile holds
|
||||
// the city it wants.
|
||||
_chooseTarget(coords) {
|
||||
const targeting = this._targeting;
|
||||
if (!targeting) return;
|
||||
@@ -644,7 +649,7 @@ export const panelMethods = {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this._isEnemyMilitaryAt(coords)) return;
|
||||
if (!this._isEnemyMilitaryAt(coords) && !this._airStrikeOrder(units, coords)) return;
|
||||
this._orderMove(units, coords, "attack");
|
||||
},
|
||||
|
||||
@@ -671,8 +676,8 @@ export const panelMethods = {
|
||||
return !!(proto && proto.air);
|
||||
},
|
||||
|
||||
// Whether `coords` holds a hostile military unit, the only thing a strike may
|
||||
// target.
|
||||
// Whether `coords` holds a hostile military unit, the target of a ranged
|
||||
// strike.
|
||||
_isEnemyMilitaryAt(coords) {
|
||||
return this.map.unitIdsAt(coords).some((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
|
||||
// standing hourly target rather than a march.
|
||||
_rangedAttackOrder(units, coords) {
|
||||
@@ -689,12 +718,13 @@ export const panelMethods = {
|
||||
return this._isEnemyMilitaryAt(coords);
|
||||
},
|
||||
|
||||
// An aircraft attack: the plane flies to the hostile unit and bombs it on
|
||||
// arrival, then returns home to re-arm.
|
||||
// An aircraft attack: the plane flies to the hostile unit or city and bombs
|
||||
// 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) {
|
||||
if (units.length === 0) 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
|
||||
@@ -765,14 +795,18 @@ export const panelMethods = {
|
||||
const toTarget = this.map.topology.tileDistance(from, coords);
|
||||
const airport = this._airReturnAirport(unit, coords);
|
||||
if (!airport) return "This aircraft has no friendly airport to return to.";
|
||||
const toHome = this.map.topology.tileDistance(coords, {
|
||||
x: airport.coords[0],
|
||||
y: airport.coords[1],
|
||||
});
|
||||
const home = { x: airport.coords[0], y: airport.coords[1] };
|
||||
const speed = proto.speed || 1;
|
||||
const fuel = unit.airHours === undefined || unit.airHours === null
|
||||
? proto.enduranceHours
|
||||
: 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
|
||||
// half the flight home, capped by the mission radius.
|
||||
const effectiveRange = Math.floor(
|
||||
@@ -780,7 +814,7 @@ export const panelMethods = {
|
||||
);
|
||||
const beyondMission =
|
||||
toTarget > (proto.missionRange || Infinity);
|
||||
const beyondFuel = (toTarget + toHome) / speed > fuel + 1e-9;
|
||||
const beyondFuel = hours > fuel + 1e-9;
|
||||
if (beyondMission || beyondFuel) {
|
||||
return `That target is ${toTarget} tiles away, but this aircraft's effective range is ${effectiveRange} tiles.`;
|
||||
}
|
||||
|
||||
@@ -643,10 +643,11 @@ export const entityMethods = {
|
||||
|
||||
_currentTile(motion) {
|
||||
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;
|
||||
// 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.coords;
|
||||
@@ -687,11 +688,17 @@ export const entityMethods = {
|
||||
// selected stack can be ordered onto this tile (move, stack or attack).
|
||||
// Shift detaches just this unit to the moving group instead.
|
||||
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();
|
||||
if (event.shiftKey) this.onUnitToggled(data.id);
|
||||
else this.onUnitSelected(data.id);
|
||||
});
|
||||
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
|
||||
// unit opens the city, exactly as double-clicking the city icon would.
|
||||
const current = this._unitData.get(data.id) || data;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
// Dead-reckoning unit motion between snapshots, path drawing and the local
|
||||
// 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 { TRANSPORT_BY_ID } from "../../../shared/data/improvements.js";
|
||||
import { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js";
|
||||
@@ -51,9 +57,14 @@ export const motionMethods = {
|
||||
const id = data.id;
|
||||
let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] }));
|
||||
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);
|
||||
path = clipped.path;
|
||||
segmentHours = clipped.segmentHours;
|
||||
}
|
||||
const existing = this._unitMotion.get(id);
|
||||
if (path.length < 2 || segmentHours.length === 0) {
|
||||
if (existing && existing.predicting && existing.predictAge < PREDICTION_GRACE_HOURS) {
|
||||
@@ -64,6 +75,7 @@ export const motionMethods = {
|
||||
segmentHours: [],
|
||||
pathHours: 0,
|
||||
coords: { x: data.coords[0], y: data.coords[1] },
|
||||
flight,
|
||||
predicting: false,
|
||||
predictAge: 0,
|
||||
});
|
||||
@@ -81,6 +93,7 @@ export const motionMethods = {
|
||||
segmentHours,
|
||||
pathHours,
|
||||
coords: { x: data.coords[0], y: data.coords[1] },
|
||||
flight,
|
||||
predicting: false,
|
||||
predictAge: 0,
|
||||
});
|
||||
@@ -185,10 +198,14 @@ export const motionMethods = {
|
||||
}
|
||||
const from = path[index];
|
||||
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 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 };
|
||||
},
|
||||
|
||||
@@ -204,14 +221,21 @@ export const motionMethods = {
|
||||
if (!this._selectedUnitIds.has(id)) continue;
|
||||
if (motion.path.length < 2) continue;
|
||||
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);
|
||||
let previous = base;
|
||||
for (let i = index + 1; i < motion.path.length; i++) {
|
||||
const from = motion.path[i - 1];
|
||||
const to = motion.path[i];
|
||||
const delta = this.topology.pixelDelta(from, to);
|
||||
const next = { x: previous.x + delta.x, y: previous.y + delta.y };
|
||||
// A flight path stores pixel points already; a ground path stores tiles
|
||||
// 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;
|
||||
fragment.appendChild(makeSegment(start, next, "#ffffff", 4));
|
||||
previous = next;
|
||||
@@ -236,12 +260,13 @@ export const motionMethods = {
|
||||
return mapToLocal(motion.coords.x, motion.coords.y);
|
||||
},
|
||||
|
||||
// Draws the red dashed barrage arc from every ranged attacker with a standing
|
||||
// target to the target tile, and a red ring around the target. The shell lobs
|
||||
// in a parabola rather than along the ground, and the dashes march and a
|
||||
// glowing shell travels the curve so the direction is unmistakable. They stay
|
||||
// visible so an ongoing barrage cannot be forgotten, and sit on a layer above
|
||||
// the terrain but below the unit and city icons.
|
||||
// Draws the red dashed line from every ranged attacker with a standing target
|
||||
// to the target tile, and a red ring around the target. A gun's shell lobs in
|
||||
// a parabola rather than along the ground; an aircraft's strike is no ballistic
|
||||
// launch, so its line stays straight. The dashes march and a glowing shell
|
||||
// travels the line so the direction is unmistakable. They stay visible so an
|
||||
// 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
|
||||
// its contents change; the per-strike target lookup goes through one index of
|
||||
@@ -296,10 +321,12 @@ export const motionMethods = {
|
||||
const a = fromPoint;
|
||||
const b = this._nearestCopy(toPoint, fromPoint);
|
||||
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
|
||||
// midpoint, its height compensated for the layer's Y foreshortening so the
|
||||
// arc keeps its shape on screen.
|
||||
const arc = Math.max(18, distance * 0.4) / CAMERA_TILT;
|
||||
// A gun lobs its shell: a quadratic Bezier whose control point is lifted
|
||||
// above the midpoint, its height compensated for the layer's Y
|
||||
// foreshortening so the arc keeps its shape on screen. An aircraft flies
|
||||
// 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 cy = (a.y + b.y) / 2 - arc;
|
||||
let view = views.get(id);
|
||||
@@ -316,15 +343,20 @@ export const motionMethods = {
|
||||
view = { path, shell, ring };
|
||||
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");
|
||||
// Marching dashes flow from the battery towards the target.
|
||||
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 mt = 1 - t;
|
||||
view.shell.setAttribute("cx", `${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("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", `${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.style.left = `${b.x}px`;
|
||||
view.ring.style.top = `${b.y}px`;
|
||||
@@ -528,9 +560,7 @@ export const motionMethods = {
|
||||
const view = this._unitViews.get(id);
|
||||
if (view && view.style.display === "none") continue;
|
||||
const motion = this._unitMotion.get(id);
|
||||
const tile = motion && motion.path.length >= 2
|
||||
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
|
||||
: { x: data.coords[0], y: data.coords[1] };
|
||||
const tile = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
|
||||
const k = key(tile.x, tile.y);
|
||||
if (!index.has(k)) index.set(k, id);
|
||||
}
|
||||
@@ -567,9 +597,15 @@ export const motionMethods = {
|
||||
const start = this._unitCoords(unitData.id) ||
|
||||
{ x: unitData.coords[0], y: unitData.coords[1] };
|
||||
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++) {
|
||||
segmentHours.push(this._stepHours(proto, unitData.civ, route[i], speed));
|
||||
}
|
||||
}
|
||||
this._applyPrediction(unitData, { route, segmentHours, start });
|
||||
return route;
|
||||
},
|
||||
@@ -581,6 +617,7 @@ export const motionMethods = {
|
||||
segmentHours: plan.segmentHours,
|
||||
pathHours: 0,
|
||||
coords: plan.start,
|
||||
flight: this._isFlierData(unitData),
|
||||
predicting: true,
|
||||
predictAge: 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}`
|
||||
: null;
|
||||
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;
|
||||
if (path && path.length >= 2) {
|
||||
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++) {
|
||||
segmentHours.push(this._stepHours(proto, unitData.civ, path[i], speed));
|
||||
}
|
||||
}
|
||||
plan = { route: path, segmentHours, start };
|
||||
}
|
||||
if (cacheKey) cache.set(cacheKey, 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,
|
||||
// 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
|
||||
@@ -712,9 +769,7 @@ export const motionMethods = {
|
||||
for (const [id, data] of this._unitData) {
|
||||
if (id === ignoreId) continue;
|
||||
const motion = this._unitMotion.get(id);
|
||||
const tile = motion && motion.path.length >= 2
|
||||
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
|
||||
: { x: data.coords[0], y: data.coords[1] };
|
||||
const tile = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
|
||||
if (tile.x === coords.x && tile.y === coords.y) result.push(data);
|
||||
}
|
||||
return result;
|
||||
@@ -728,9 +783,7 @@ export const motionMethods = {
|
||||
for (const [id, data] of this._unitData) {
|
||||
if (id === ignoreId) continue;
|
||||
const motion = this._unitMotion.get(id);
|
||||
const tile = motion && motion.path.length >= 2
|
||||
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
|
||||
: { x: data.coords[0], y: data.coords[1] };
|
||||
const tile = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
|
||||
const k = key(tile.x, tile.y);
|
||||
const list = index.get(k);
|
||||
if (list) list.push(data);
|
||||
@@ -756,16 +809,23 @@ export const motionMethods = {
|
||||
return denied;
|
||||
},
|
||||
|
||||
_unitCoords(id) {
|
||||
const motion = this._unitMotion.get(id);
|
||||
if (!motion) return null;
|
||||
// The tile a motion is over right now. A flight stores world pixels, so its
|
||||
// tile is derived from where the line currently is.
|
||||
_motionTile(motion) {
|
||||
if (motion.path.length >= 2) {
|
||||
if (motion.flight) return localToMap(this._positionAlongPath(motion), this.topology);
|
||||
const index = pathProgress(motion).index;
|
||||
return motion.path[Math.min(index, motion.path.length - 1)];
|
||||
}
|
||||
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,
|
||||
// or failing that whoever owns the goal tile. Crossing that civ's land is
|
||||
// allowed; every other foreign territory is a wall.
|
||||
|
||||
@@ -63,10 +63,13 @@ export const AIR_STRIKE = {
|
||||
cooldownHours: 48,
|
||||
};
|
||||
|
||||
// A city's air defense fires one volley at a hostile aircraft the moment it
|
||||
// enters the city's tile. Damage scales with the building's level.
|
||||
// A city's air defense watches a corridor of `rangeTiles` around it: any
|
||||
// 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 = {
|
||||
damagePerLevel: 150,
|
||||
damagePerLevelPerHour: 150,
|
||||
rangeTiles: 2,
|
||||
};
|
||||
|
||||
// Taking a non-melee unit -- a gun, an aircraft -- is risky: four times in five
|
||||
|
||||
+111
-20
@@ -2,7 +2,7 @@
|
||||
// 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.
|
||||
|
||||
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 { BUILDING_MECHANIC } from "../data/buildings.js";
|
||||
import { RESOURCE_RULES } from "../data/resources.js";
|
||||
@@ -43,6 +43,17 @@ export const airMethods = {
|
||||
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
|
||||
// aircraft and anything with a reach beyond a single tile. A melee unit has a
|
||||
// range of one and stays on the ground.
|
||||
@@ -83,16 +94,59 @@ export const airMethods = {
|
||||
|
||||
_pathHours(unit, path) {
|
||||
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;
|
||||
},
|
||||
|
||||
// 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
|
||||
// past its remaining endurance, in which case it stays put and will crash.
|
||||
_orderAirReturn(unit) {
|
||||
const airport = this._nearestFriendlyAirport(unit);
|
||||
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;
|
||||
const proto = this.unitProto(unit);
|
||||
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
|
||||
@@ -161,6 +215,14 @@ export const airMethods = {
|
||||
this._destroyUnit(unit);
|
||||
}
|
||||
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 --
|
||||
@@ -264,18 +326,27 @@ export const airMethods = {
|
||||
return razed;
|
||||
},
|
||||
|
||||
// Fires the city's air defense at a hostile aircraft that has just entered its
|
||||
// tile: one volley per pass, scaled by the building's level. Returns true when
|
||||
// the plane is shot down.
|
||||
// Every hostile anti-air battery within `rangeTiles` of the flier fires one
|
||||
// in-game hour of continuous fire: damage scales with the building's level,
|
||||
// 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) {
|
||||
const city = this.cityAt(unit.coords);
|
||||
if (!city || city.civ === unit.civ) return false;
|
||||
let damage = 0;
|
||||
for (const city of this.cities) {
|
||||
if (city.civ === unit.civ) continue;
|
||||
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,
|
||||
// no volley.
|
||||
if (!this.payCombatResources(city.civ, "airDefenseVolley", level)) return false;
|
||||
const damage = Math.max(1, Math.round(AIR_DEFENSE.damagePerLevel * level));
|
||||
// no fire.
|
||||
if (!this.payCombatResources(city.civ, "airDefenseVolley", level)) continue;
|
||||
damage += Math.max(1, Math.round(AIR_DEFENSE.damagePerLevelPerHour * level));
|
||||
}
|
||||
if (damage <= 0) return false;
|
||||
unit.hp -= damage;
|
||||
if (unit.hp > 0) return false;
|
||||
unit.hp = 0;
|
||||
@@ -285,6 +356,25 @@ export const airMethods = {
|
||||
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
|
||||
// support units, and passes a tenth of the damage on to any structures on the
|
||||
// tile.
|
||||
@@ -316,10 +406,11 @@ export const airMethods = {
|
||||
: (proto.range || 1);
|
||||
},
|
||||
|
||||
// Whether an aircraft has the fuel for the whole sortie: fly to `goal`, bomb,
|
||||
// then run home to a friendly airport. A strike that could not make it back
|
||||
// is refused up front, so a sortie never strands a plane. Air routes ignore
|
||||
// the ground, so tile distance is the number of flight steps.
|
||||
// Whether an aircraft has the fuel for the whole sortie: fly straight to
|
||||
// `goal`, bomb, then run home to a friendly airport. A strike that could not
|
||||
// make it back is refused up front, so a sortie never strands a plane. Both
|
||||
// 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) {
|
||||
const proto = this.unitProto(unit);
|
||||
if (!proto) return false;
|
||||
@@ -328,10 +419,10 @@ export const airMethods = {
|
||||
const remaining = fuel === null
|
||||
? (unit.airHours === undefined ? proto.enduranceHours : unit.airHours)
|
||||
: fuel;
|
||||
const tiles =
|
||||
this.topology.tileDistance(unit.coords, goal) +
|
||||
this.topology.tileDistance(goal, airport.coords);
|
||||
return tiles / this._unitSpeed(unit) <= remaining + 1e-9;
|
||||
const hours =
|
||||
this._flightHours(unit, unit.coords, goal) +
|
||||
this._flightHours(unit, goal, airport.coords);
|
||||
return hours <= remaining + 1e-9;
|
||||
},
|
||||
|
||||
// Whether a unit may strike `goal` right now: inside its reach and, for an
|
||||
|
||||
+103
-19
@@ -1,7 +1,7 @@
|
||||
// Movement: translating orders into paths, advancing units along them each
|
||||
// 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 {
|
||||
MIN_STEP_COST,
|
||||
@@ -188,6 +188,12 @@ export const movementMethods = {
|
||||
for (const unit of this.units.slice()) {
|
||||
if (!this.findUnit(unit.id)) 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;
|
||||
let hoursLeft = hours;
|
||||
while (hoursLeft > 0) {
|
||||
@@ -225,24 +231,7 @@ export const movementMethods = {
|
||||
this._moveUnitTo(unit, next);
|
||||
this._visibilityDirty = true;
|
||||
this._onUnitArrived(unit);
|
||||
if (this._isAirUnit(unit)) {
|
||||
// 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)) {
|
||||
if (this._hasHostileOn(unit.coords, unit.civ)) {
|
||||
// The fight is joined: hold here and lock every participant. The
|
||||
// unit that charged in is the attacker; the occupants defend.
|
||||
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,
|
||||
// not an obstacle to route around.
|
||||
_isBlockingEnemy(unit, occupant) {
|
||||
@@ -499,6 +572,17 @@ export const movementMethods = {
|
||||
? `${unit.civ}|${unit.proto}|${start.x},${start.y}|${goal.x},${goal.y}`
|
||||
: null;
|
||||
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 targetCiv = this._targetCivFor(unit, goal, occupancy);
|
||||
const zoc = this._zoneOfControl(unit.civ);
|
||||
|
||||
@@ -297,14 +297,14 @@ export const serializationMethods = {
|
||||
return this.units.map((unit) => {
|
||||
const rawPath = unit.path || [];
|
||||
const pathIndex = unit.pathIndex;
|
||||
const path = rawPath.map((coords) => [coords.x, coords.y]);
|
||||
const segmentHours = [];
|
||||
for (let i = 1; i < rawPath.length; i++) {
|
||||
segmentHours.push(this._tileTravelHours(unit, rawPath[i]));
|
||||
}
|
||||
// A ground path is a list of tiles; a flight is a list of world-pixel
|
||||
// points. Both ship as [x, y] pairs and get their per-segment hours from
|
||||
// the one shared calculator, so the browser need not know which it is.
|
||||
const path = rawPath.map((point) => [point.x, point.y]);
|
||||
const segmentHours = this._segmentHours(unit, rawPath);
|
||||
let stepHours = 0;
|
||||
if (pathIndex + 1 < rawPath.length) {
|
||||
stepHours = this._tileTravelHours(unit, rawPath[pathIndex + 1]);
|
||||
stepHours = this._segmentHours(unit, [rawPath[pathIndex], rawPath[pathIndex + 1]])[0] || 0;
|
||||
}
|
||||
return {
|
||||
id: unit.id,
|
||||
|
||||
@@ -35,7 +35,9 @@ export const wmdMethods = {
|
||||
// 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
|
||||
// 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 } = {}) {
|
||||
const units = this._unitsForOrder(unitIds).filter((unit) => this._isMissileUnit(unit));
|
||||
if (units.length === 0) return false;
|
||||
@@ -55,9 +57,15 @@ export const wmdMethods = {
|
||||
if (units.some((unit) => !this._missileInRange(unit, goal))) return false;
|
||||
this._declareWar(civ, target ? target.civ : city.civ);
|
||||
for (const unit of units) {
|
||||
if (useNuclear) this._applyNuclearBlast(goal, civ);
|
||||
else this._resolveConventionalMissile(unit, goal);
|
||||
this._destroyUnit(unit);
|
||||
const path = this.findPath(unit, unit.coords, goal);
|
||||
if (path.length < 2) return false;
|
||||
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._visibilityDirty = true;
|
||||
|
||||
@@ -55,6 +55,56 @@ export function mapToLocal(x, y) {
|
||||
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 {
|
||||
constructor(size, cylindrical) {
|
||||
this.width = size.x;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
|
||||
@@ -221,7 +221,13 @@ export class GameServerTest extends TestCase {
|
||||
server.handleOrder(1, { type: "attack", unit: missile.id, coords: [target.x, target.y] }),
|
||||
"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");
|
||||
}
|
||||
|
||||
|
||||
+69
-1
@@ -1,5 +1,16 @@
|
||||
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 {
|
||||
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() {
|
||||
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);
|
||||
|
||||
@@ -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() {
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
|
||||
@@ -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() {
|
||||
const state = smallState();
|
||||
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() {
|
||||
const state = smallState();
|
||||
const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c));
|
||||
|
||||
+13
-3
@@ -59,6 +59,12 @@ function freeLand(state) {
|
||||
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 {
|
||||
test_nuclear_weapons_research_needs_an_enrichment_centre() {
|
||||
const state = smallState();
|
||||
@@ -89,7 +95,9 @@ export class WmdTest extends TestCase {
|
||||
state.requestMissileStrike([missile.id], targetCoords),
|
||||
"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");
|
||||
}
|
||||
|
||||
@@ -112,7 +120,8 @@ export class WmdTest extends TestCase {
|
||||
state.requestMissileStrike([icbm.id], far),
|
||||
"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");
|
||||
}
|
||||
|
||||
@@ -136,7 +145,8 @@ export class WmdTest extends TestCase {
|
||||
state.requestMissileStrike([missile.id], targetCoords, { nuclear: true }),
|
||||
"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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user