636 lines
27 KiB
JavaScript
636 lines
27 KiB
JavaScript
// Movement: translating orders into paths, advancing units along them each
|
|
// tick, and the travel-cost model the browser mirrors for prediction.
|
|
|
|
import { key, mapToLocal, localToMap, flightLine } from "../hex.js";
|
|
import { TRANSPORT_BY_ID } from "../data/improvements.js";
|
|
import {
|
|
MIN_STEP_COST,
|
|
DEFAULT_UNIT_SPEED,
|
|
CONTROLLED_SPEED_MULTIPLIER,
|
|
} from "./constants.js";
|
|
|
|
export const movementMethods = {
|
|
requestMove(unitId, goal) {
|
|
return this.requestGroupMove([unitId], goal);
|
|
},
|
|
|
|
// Orders one or more units onto `goal`. Friendly units may stack there; a
|
|
// hostile military unit turns the move into an attack. Units moving together
|
|
// adopt the speed of the slowest member, so a stack holds formation.
|
|
//
|
|
// `speed` (optional) lets the caller pin that formation pace. The server
|
|
// splits a big group move across ticks and passes the pace it computed for
|
|
// the whole group, so every slice still moves at the slowest member's speed
|
|
// even when the slow unit lands in a later slice.
|
|
requestGroupMove(unitIds, goal, speed = null) {
|
|
const units = this._moveableUnits(unitIds);
|
|
if (units.length === 0) return false;
|
|
const pace = speed !== null && speed !== undefined
|
|
? speed
|
|
: (units.length > 1 ? Math.min(...units.map((unit) => this._unitSpeed(unit))) : null);
|
|
// Units of the same type starting from the same tile share a route, so the
|
|
// search runs once per type instead of once per unit in the stack.
|
|
const pathCache = new Map();
|
|
let moved = 0;
|
|
for (const unit of units) {
|
|
// Only a unit that actually receives the new route drops its scheduled
|
|
// stops; a unit whose route cannot be found keeps its existing movement
|
|
// rather than being silently emptied out.
|
|
if (this._orderMove(unit, goal, pace, pathCache)) {
|
|
unit.waypoints = [];
|
|
moved += 1;
|
|
}
|
|
}
|
|
if (moved === 0) return false;
|
|
this._emitChanged();
|
|
return true;
|
|
},
|
|
|
|
// Explicit attack on the hostile force holding `goal`: an enemy unit stack or
|
|
// an enemy city. The attack is a march to the tile; combat is joined on
|
|
// arrival. If the enemy has moved away by then, the order is simply a move.
|
|
requestAttack(unitIds, goal) {
|
|
const units = this._moveableUnits(unitIds).filter((unit) => this._isMilitary(unit));
|
|
if (units.length === 0) return false;
|
|
const civ = units[0].civ;
|
|
const enemyMilitary = this.enemyUnitsAt(goal, civ).some((other) => this._isMilitary(other));
|
|
const city = this.cityAt(goal);
|
|
const enemyCity = !!city && city.civ !== civ;
|
|
if (!enemyMilitary && !enemyCity) return false;
|
|
return this.requestGroupMove(units.map((unit) => unit.id), goal);
|
|
},
|
|
|
|
// Orders units onto one or more destinations in turn. With `append`, the new
|
|
// destinations join the queue already scheduled for each unit; otherwise they
|
|
// replace it. When a unit reaches one destination a fresh path is computed to
|
|
// the next, so the route adapts to a changing world.
|
|
requestScheduledMove(unitIds, goals, append = false) {
|
|
const destinations = (Array.isArray(goals) ? goals : [goals])
|
|
.filter((goal) => goal && Number.isFinite(goal.x) && Number.isFinite(goal.y))
|
|
.map((goal) => ({ x: goal.x, y: goal.y }));
|
|
if (destinations.length === 0) return false;
|
|
const units = this._moveableUnits(unitIds);
|
|
if (units.length === 0) return false;
|
|
const speed = units.length > 1
|
|
? Math.min(...units.map((unit) => this._unitSpeed(unit)))
|
|
: null;
|
|
const pathCache = new Map();
|
|
let moved = 0;
|
|
for (const unit of units) {
|
|
if (!Array.isArray(unit.waypoints)) unit.waypoints = [];
|
|
if (append) {
|
|
for (const goal of destinations) unit.waypoints.push({ ...goal });
|
|
if (this._hasRoute(unit) || this._followNextWaypoint(unit, speed, pathCache)) moved += 1;
|
|
} else {
|
|
const previous = unit.waypoints;
|
|
unit.waypoints = destinations.slice(1).map((goal) => ({ ...goal }));
|
|
if (this._orderMove(unit, destinations[0], speed, pathCache)) moved += 1;
|
|
// An unroutable first leg leaves the unit exactly as it was.
|
|
else unit.waypoints = previous;
|
|
}
|
|
}
|
|
if (moved === 0) return false;
|
|
this._emitChanged();
|
|
return true;
|
|
},
|
|
|
|
_moveableUnits(unitIds) {
|
|
const ids = Array.isArray(unitIds) ? unitIds : [unitIds];
|
|
const units = [];
|
|
for (const id of ids) {
|
|
const unit = this.findUnit(Number(id));
|
|
if (!unit) continue;
|
|
const proto = this.unitProto(unit);
|
|
if (!proto || !proto.moveable) continue;
|
|
units.push(unit);
|
|
}
|
|
return units;
|
|
},
|
|
|
|
// Whether the unit still has an unfinished route to its current destination.
|
|
_hasRoute(unit) {
|
|
return !!unit.path && unit.path.length > 1 && unit.pathIndex < unit.path.length - 1;
|
|
},
|
|
|
|
// Pops the next scheduled destination and routes to it, keeping the rest of
|
|
// the queue. Returns false when nothing is left or the next destination is
|
|
// unreachable (in which case the whole schedule is abandoned).
|
|
_followNextWaypoint(unit, speed = null, pathCache = null) {
|
|
if (!unit.waypoints || unit.waypoints.length === 0) return false;
|
|
const next = unit.waypoints.shift();
|
|
const pace = speed !== null ? speed : unit.moveSpeed;
|
|
if (!this._orderMove(unit, next, pace, pathCache)) {
|
|
this._stopUnit(unit);
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
_orderMove(unit, goal, speed, pathCache = null) {
|
|
// Attacking: a hostile military unit standing on the goal. Combat itself is
|
|
// joined on arrival; this only decides whether the march declares war.
|
|
const target = this.enemyUnitsAt(goal, unit.civ).find((other) => this._isMilitary(other)) || null;
|
|
const attacking = !!target && this._isMilitary(unit);
|
|
const proto = this.unitProto(unit);
|
|
const air = this._isAirProto(proto);
|
|
const path = this.findPath(unit, unit.coords, goal, pathCache);
|
|
if (path.length < 2) {
|
|
return false;
|
|
}
|
|
// Aircraft may only be based at a friendly airport: they cannot be sent to
|
|
// an empty tile or to an allied city without a runway. Rebasing is a ferry
|
|
// flight, whose only bound is the fuel in the tanks (the mission radius
|
|
// limits combat sorties, not ferrying). A strike is issued through
|
|
// requestAirStrike, which routes the plane to the enemy instead.
|
|
if (air) {
|
|
const city = this.cityAt(goal);
|
|
const airport = !!(city && city.civ === unit.civ && this.hasCityBuilding(city, "airport"));
|
|
if (!airport) return false;
|
|
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
|
|
if (this._pathHours(unit, path) > fuel + 1e-9) return false;
|
|
}
|
|
// Marching a military unit into, or attacking, a country we are at peace
|
|
// with declares war on it. The browser confirms the move first, but the
|
|
// server enforces the rule so the two can never disagree. Aircraft fly
|
|
// over foreign land without provoking anyone; only a strike declares war.
|
|
if (this._isMilitary(unit)) {
|
|
if (attacking) this._declareWar(unit.civ, target.civ);
|
|
if (!air) {
|
|
for (const coords of path) {
|
|
const owner = this.civAt(coords);
|
|
// Crossing an ally's or an at-war nation's land is legal and declares
|
|
// nothing; only a neutral third party's land turns the march into war.
|
|
if (!this._canTraverseTerritory(unit.civ, owner, -1)) {
|
|
this._declareWar(unit.civ, owner);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
unit.path = path;
|
|
unit.pathIndex = 0;
|
|
unit.progressHours = 0;
|
|
unit.moveSpeed = speed;
|
|
unit.autoReturn = false;
|
|
// A move cancels any standing bombardment; the battery is relocating.
|
|
unit.strikeTarget = null;
|
|
return true;
|
|
},
|
|
|
|
advanceMovement(hours) {
|
|
if (hours <= 0) return;
|
|
// Units sharing a tile with a hostile military unit are locked in place
|
|
// until the daily combat round resolves, so neither side can slip away
|
|
// mid-fight. The set grows as units charge in so an ordered swap cannot let
|
|
// two enemies march through each other.
|
|
const engaged = this._engagedUnits();
|
|
// Iterate a copy: air defense can shoot a plane down inside this loop, and
|
|
// splicing the live array would skip the unit that follows it.
|
|
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) {
|
|
// Reaching the end of one leg picks up the next scheduled destination
|
|
// and recomputes the path from where the unit now stands.
|
|
if (!this._hasRoute(unit) && !this._followNextWaypoint(unit)) break;
|
|
const next = unit.path[unit.pathIndex + 1];
|
|
const occupants = this.unitsAt(next).filter((other) => other !== unit);
|
|
const enemy = occupants.find((other) => other.civ !== unit.civ);
|
|
if (enemy && !this._isBlockingEnemy(unit, enemy)) {
|
|
// A hostile civilian is an obstacle; route around it, and when the
|
|
// destination has become unreachable, abandon the move instead of
|
|
// stalling on the spot forever.
|
|
if (this._repathAround(unit)) {
|
|
const ahead = this.unitsAt(unit.path[unit.pathIndex + 1])
|
|
.find((other) => other !== unit && other.civ !== unit.civ);
|
|
if (ahead) break;
|
|
continue;
|
|
}
|
|
this._stopUnit(unit);
|
|
break;
|
|
}
|
|
// A hostile military unit does not stop the charge: combat is fought on
|
|
// a single tile, so the unit steps in and the two stacks share it.
|
|
// Friendly occupants do not block either: the unit simply stacks.
|
|
const cost = Math.max(this._tileTravelHours(unit, next), MIN_STEP_COST);
|
|
const needed = cost - unit.progressHours;
|
|
// A hair of slack lets a run of equal steps that sums to exactly the
|
|
// tick's hours (for example ten 0.2-hour air steps) finish in one tick
|
|
// instead of stalling on a floating-point remainder.
|
|
if (hoursLeft + 1e-9 >= needed) {
|
|
hoursLeft -= needed;
|
|
unit.pathIndex += 1;
|
|
unit.progressHours = 0;
|
|
this._moveUnitTo(unit, next);
|
|
this._visibilityDirty = true;
|
|
this._onUnitArrived(unit);
|
|
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);
|
|
unit.invader = true;
|
|
for (const other of this.unitsAt(unit.coords)) {
|
|
if (other.civ !== unit.civ && this._isMilitary(other)) {
|
|
other.invader = false;
|
|
engaged.add(other);
|
|
}
|
|
}
|
|
engaged.add(unit);
|
|
break;
|
|
}
|
|
} else {
|
|
unit.progressHours += hoursLeft;
|
|
hoursLeft = 0;
|
|
}
|
|
}
|
|
if (!this._hasRoute(unit) && (!unit.waypoints || unit.waypoints.length === 0)) {
|
|
this._stopUnit(unit);
|
|
}
|
|
}
|
|
},
|
|
|
|
// 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) {
|
|
return this._isMilitary(unit) && this._isMilitary(occupant) && occupant.civ !== unit.civ;
|
|
},
|
|
|
|
// Every unit on the tile, ours or not, that is hostile to `civ`.
|
|
_hasHostileOn(coords, civ) {
|
|
return this.unitsAt(coords).some((unit) => unit.civ !== civ);
|
|
},
|
|
|
|
// Recomputes a route from where the unit stands to the destination it was
|
|
// already heading for. Returns false when no route exists any more.
|
|
_repathAround(unit) {
|
|
if (!unit.path || unit.path.length < 2) return false;
|
|
const goal = unit.path[unit.path.length - 1];
|
|
const path = this.findPath(unit, unit.coords, goal);
|
|
if (path.length < 2) return false;
|
|
unit.path = path;
|
|
unit.pathIndex = 0;
|
|
unit.progressHours = 0;
|
|
return true;
|
|
},
|
|
|
|
// Abandons every movement for the unit: no route, no partial step and no
|
|
// remaining scheduled destinations.
|
|
_stopUnit(unit) {
|
|
unit.path = [];
|
|
unit.pathIndex = 0;
|
|
unit.progressHours = 0;
|
|
unit.moveSpeed = null;
|
|
unit.waypoints = [];
|
|
},
|
|
|
|
// ------------------------------------------------------ travel cost --
|
|
|
|
_isControlledBy(unit, coords) {
|
|
return this.civAt(coords) === unit.civ;
|
|
},
|
|
|
|
_unitSpeed(unit) {
|
|
const proto = this.unitProto(unit);
|
|
return Math.max(proto ? proto.speed : DEFAULT_UNIT_SPEED, 0.0001);
|
|
},
|
|
|
|
_effectiveSpeedAt(unit, coords) {
|
|
// A group move pins every unit to the slowest member's speed.
|
|
let speed = unit.moveSpeed || this._unitSpeed(unit);
|
|
if (this._isControlledBy(unit, coords)) speed *= CONTROLLED_SPEED_MULTIPLIER;
|
|
return speed;
|
|
},
|
|
|
|
_tileTravelHours(unit, coords) {
|
|
// Aircraft fly over the ground: their speed is a flat tiles-per-hour figure,
|
|
// untouched by terrain, roads or whose land lies below.
|
|
if (this._isAirUnit(unit)) {
|
|
return 1 / (unit.moveSpeed || this._unitSpeed(unit));
|
|
}
|
|
// A road or railway levels the terrain: every such tile costs the same to
|
|
// cross, a railway half again as cheap as a road, and a nation's trains
|
|
// technology shortens its own railways further.
|
|
return this._tileMovementMultiplier(coords, unit.civ) / this._effectiveSpeedAt(unit, coords);
|
|
},
|
|
|
|
// The cheapest transport multiplier a tile offers: its railway, else its road,
|
|
// else the terrain's own. Flat across the whole tile, so a road levels hills
|
|
// and forests alike. A nation's trains technology speeds up its railways only.
|
|
_tileMovementMultiplier(coords, civ = -1) {
|
|
const k = key(coords.x, coords.y);
|
|
if (this.railways.has(k)) {
|
|
const base = TRANSPORT_BY_ID.railway.movementCostMultiplier;
|
|
return civ >= 0 ? base * this.railSpeedMultiplier(civ) : base;
|
|
}
|
|
if (this.roads.has(k)) return TRANSPORT_BY_ID.road.movementCostMultiplier;
|
|
return this.terrainStats.movementCostMultiplier(coords);
|
|
},
|
|
|
|
_fastestTileTravelHours(unit) {
|
|
// A railway (or, failing that, a road) is the cheapest possible tile, so the
|
|
// heuristic must use it when it beats the most generous terrain or A* could
|
|
// overestimate and miss the cheapest route. A railway can be cheaper still
|
|
// for a nation with trains technology.
|
|
const railway = TRANSPORT_BY_ID.railway.movementCostMultiplier *
|
|
this.railSpeedMultiplier(unit.civ);
|
|
const fastest = Math.min(
|
|
this._minMovementCost,
|
|
TRANSPORT_BY_ID.road.movementCostMultiplier,
|
|
railway
|
|
);
|
|
return fastest / (this._unitSpeed(unit) * CONTROLLED_SPEED_MULTIPLIER);
|
|
},
|
|
|
|
_heuristic(unit, from, to) {
|
|
const delta = this.topology.pixelDelta(from, to);
|
|
const tiles = Math.hypot(delta.x, delta.y) / this._maxStepLength;
|
|
return tiles * this._fastestTileTravelHours(unit);
|
|
},
|
|
|
|
_computeMaxStepLength() {
|
|
let longest = 0;
|
|
for (const neighbour of this._neighbours({ x: 0, y: 0 })) {
|
|
const delta = this.topology.pixelDelta({ x: 0, y: 0 }, neighbour);
|
|
longest = Math.max(longest, Math.hypot(delta.x, delta.y));
|
|
}
|
|
return longest > 0 ? longest : 1.0;
|
|
},
|
|
|
|
// --------------------------------------------------------- pathfinding --
|
|
|
|
// Whether `unit` may occupy `coords`: passable terrain and no hostile unit
|
|
// there unless the move is an attack (military into military, which turns the
|
|
// step into a fight). Friendly units may share a tile, so they never block.
|
|
// Enemy cities are only open to military units, since walking one in
|
|
// captures it.
|
|
//
|
|
// A foreign land is a wall only when its owner is a neutral third party: a
|
|
// route may cross the land of the nation it is aimed at (`targetCiv`), of an
|
|
// ally, and of a nation already at war (the war was declared regardless), as
|
|
// `_canTraverseTerritory` spells out. So a march never drags a bystander into
|
|
// a war for purely technical reasons.
|
|
//
|
|
// A hostile unit or city is only a valid destination (`isGoal`), never a tile
|
|
// to pass through: pathfinding routes around enemies instead of through them.
|
|
//
|
|
// `occupancy` (optional) is the per-tile unit index built once by findPath.
|
|
// Without it every candidate tile scans the whole army; the search therefore
|
|
// looks up occupancy in constant time, exactly as the browser's prediction
|
|
// does in map_view/motion.js.
|
|
_canUnitEnter(unit, coords, isGoal = false, targetCiv = -1, occupancy = null, zoc = null, from = null) {
|
|
const proto = this.unitProto(unit);
|
|
const air = this._isAirProto(proto);
|
|
if (air) {
|
|
// Aircraft fly over fog and foreign lands alike, so only the tile's
|
|
// existence and its terrain class constrain them.
|
|
const tile = this.tiles[key(coords.x, coords.y)];
|
|
if (!tile || !proto.traversableTerrains.includes(tile.terrainClass)) return false;
|
|
} else if (!this.canEnter(unit.civ, proto, coords)) {
|
|
return false;
|
|
}
|
|
// Zone of control: a hostile unit denies its own tile and the six around it.
|
|
// Troops may enter or leave the ring, but never cross straight from one
|
|
// denied tile into another. A deliberate attack on the goal is exempt, so
|
|
// the ring never shields a garrison from a charge.
|
|
if (!air && !isGoal && zoc && from &&
|
|
zoc.has(key(from.x, from.y)) && zoc.has(key(coords.x, coords.y))) {
|
|
return false;
|
|
}
|
|
const owner = this.civAt(coords);
|
|
// A spy slips through foreign territory at will; every other unit treats a
|
|
// neutral third party's land as a wall (the targeted civilisation, an ally
|
|
// and anyone already at war are all exempt).
|
|
if (!air && !isGoal && !proto.spy && !this._canTraverseTerritory(unit.civ, owner, targetCiv)) {
|
|
return false;
|
|
}
|
|
const occupants = occupancy
|
|
? (occupancy.get(key(coords.x, coords.y)) || [])
|
|
: this.unitsAt(coords).filter((other) => other !== unit);
|
|
if (occupants.length > 0) {
|
|
const hostile = occupants.filter((other) => other.civ !== unit.civ);
|
|
if (hostile.length > 0) {
|
|
if (!isGoal) return false;
|
|
if (!this._isMilitary(unit)) return false;
|
|
if (!hostile.some((other) => this._isMilitary(other))) return false;
|
|
}
|
|
}
|
|
const city = this._cityByCoords.get(key(coords.x, coords.y));
|
|
if (city && city.civ !== unit.civ) {
|
|
// A hostile city is only ever a destination: ground troops march in to
|
|
// capture it, and aircraft may bomb its garrison. Aircraft never occupy
|
|
// it (captures skip them), so the tile stays closed as a through-route.
|
|
if (!isGoal) return false;
|
|
if (!this._isMilitary(unit)) return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
// Whether `civ` may cross land owned by `owner` on a through-route. A nation's
|
|
// own land is always open, and so is the land of the nation a route is aimed
|
|
// at (`targetCiv`), of an ally, and of a nation already at war -- the war was
|
|
// declared regardless. Only a neutral third party is a wall, so a march never
|
|
// drags a bystander into a war for purely technical reasons. Mirrored by the
|
|
// browser in map_view/motion.js `_canTraverseTerritory`.
|
|
_canTraverseTerritory(civ, owner, targetCiv = -1) {
|
|
if (owner < 0 || owner === civ || owner === targetCiv) return true;
|
|
if (this.isAtWar(civ, owner)) return true;
|
|
if (this.areAllied(civ, owner)) return true;
|
|
return false;
|
|
},
|
|
|
|
// Every unit bucketed by the tile it stands on, so a search can look up
|
|
// occupancy in constant time instead of scanning the whole army for every
|
|
// tile it considers. The mover is left out: it never blocks itself. The
|
|
// index is thrown away after the search, so it can never drift from the
|
|
// live unit list.
|
|
_buildOccupancy(unit) {
|
|
const index = new Map();
|
|
for (const other of this.units) {
|
|
if (other === unit) continue;
|
|
const k = key(other.coords.x, other.coords.y);
|
|
const list = index.get(k);
|
|
if (list) list.push(other);
|
|
else index.set(k, [other]);
|
|
}
|
|
return index;
|
|
},
|
|
|
|
// The tiles denied to `civ` by hostile ground forces: every hostile military
|
|
// unit's tile and the six around it. Aircraft project no zone.
|
|
_zoneOfControl(civ) {
|
|
const denied = new Set();
|
|
for (const other of this.units) {
|
|
if (other.civ === civ) continue;
|
|
if (!this._isMilitary(other) || this._isAirUnit(other)) continue;
|
|
const k = key(other.coords.x, other.coords.y);
|
|
denied.add(k);
|
|
for (const neighbour of this._neighbours(other.coords)) {
|
|
denied.add(key(neighbour.x, neighbour.y));
|
|
}
|
|
}
|
|
return denied;
|
|
},
|
|
|
|
// `pathCache` (optional) shares routes between units whose civ, type, start
|
|
// and goal are identical: the route shape depends only on the unit type's
|
|
// traversable terrain and the owning civ's territory/fog, while movement
|
|
// speed merely scales every step cost by the same factor and so cannot change
|
|
// which route is cheapest. The path array is treated as read-only by callers.
|
|
findPath(unit, start, goal, pathCache = null) {
|
|
const cacheKey = pathCache
|
|
? `${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);
|
|
const enterCache = new Map();
|
|
const costCache = new Map();
|
|
const heuristicCache = new Map();
|
|
const path = this._pathfinder.findPath(
|
|
start,
|
|
goal,
|
|
(coords) => this._neighbours(coords),
|
|
(coords, isGoal, from) => {
|
|
const fromKey = from ? key(from.x, from.y) : "";
|
|
const k = `${key(coords.x, coords.y)}${isGoal ? ":goal" : ""}@${fromKey}`;
|
|
if (!enterCache.has(k)) {
|
|
enterCache.set(
|
|
k,
|
|
this._canUnitEnter(unit, coords, isGoal, targetCiv, occupancy, zoc, from)
|
|
);
|
|
}
|
|
return enterCache.get(k);
|
|
},
|
|
(_from, to) => {
|
|
const k = key(to.x, to.y);
|
|
if (!costCache.has(k)) costCache.set(k, this._tileTravelHours(unit, to));
|
|
return costCache.get(k);
|
|
},
|
|
(from, to) => {
|
|
const k = key(from.x, from.y);
|
|
if (!heuristicCache.has(k)) heuristicCache.set(k, this._heuristic(unit, from, to));
|
|
return heuristicCache.get(k);
|
|
}
|
|
);
|
|
if (cacheKey) pathCache.set(cacheKey, path);
|
|
return path;
|
|
},
|
|
// 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.
|
|
_targetCivFor(unit, goal, occupancy = null) {
|
|
const city = this._cityByCoords.get(key(goal.x, goal.y));
|
|
if (city && city.civ !== unit.civ) return city.civ;
|
|
const occupants = occupancy
|
|
? (occupancy.get(key(goal.x, goal.y)) || [])
|
|
: this.unitsAt(goal);
|
|
for (const other of occupants) {
|
|
if (other.civ !== unit.civ) return other.civ;
|
|
}
|
|
return this.civAt(goal);
|
|
},
|
|
};
|