- aircraft attack orders fly a sortie to the target, bomb once, return to a friendly airport and re-arm (two-day cooldown, charged per aircraft); rebasing is bounded by fuel rather than the 10-tile combat radius - artillery and warships open a sustained hourly bombardment on a standing target, drawn as a red dashed line and ring while either endpoint is selected - a game launched from localhost can enable a testing mode whose train and build rows offer free, instant orders; the server only honours them for a loopback admin in a testing game - an open city panel now unlocks aircraft as soon as an airport finishes
432 lines
18 KiB
JavaScript
432 lines
18 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 } from "../hex.js";
|
|
import { ROADS } from "../data/roads.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.
|
|
requestGroupMove(unitIds, goal) {
|
|
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;
|
|
// 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, speed, 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);
|
|
if (owner >= 0 && owner !== unit.civ) 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();
|
|
for (const unit of this.units) {
|
|
if (!this._hasRoute(unit) && (!unit.waypoints || unit.waypoints.length === 0)) 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;
|
|
unit.coords = next;
|
|
this._visibilityDirty = true;
|
|
this._onUnitArrived(unit);
|
|
if (this._isAirUnit(unit)) {
|
|
// 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);
|
|
break;
|
|
}
|
|
} else 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);
|
|
}
|
|
}
|
|
},
|
|
|
|
// 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 levels the terrain: every road tile costs the same to cross.
|
|
const multiplier = this.roads.has(key(coords.x, coords.y))
|
|
? ROADS.movementCostMultiplier
|
|
: this.terrainStats.movementCostMultiplier(coords);
|
|
return multiplier / this._effectiveSpeedAt(unit, coords);
|
|
},
|
|
|
|
_fastestTileTravelHours(unit) {
|
|
return this._minMovementCost / (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.
|
|
//
|
|
// Foreign territory is a wall as well, so a route never cuts through a third
|
|
// party and provokes a war for purely technical reasons. The one exception is
|
|
// the civilisation being targeted (`targetCiv`, the owner of the goal), whose
|
|
// land the route may cross to reach it.
|
|
//
|
|
// 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.
|
|
_canUnitEnter(unit, coords, isGoal = false, targetCiv = -1) {
|
|
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;
|
|
}
|
|
const owner = this.civAt(coords);
|
|
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
|
|
const occupants = 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) {
|
|
// Aircraft never occupy an enemy city; they attack from the air instead.
|
|
if (air) return false;
|
|
if (!isGoal) return false;
|
|
if (!this._isMilitary(unit)) return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
// `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);
|
|
const targetCiv = this._targetCivFor(unit, goal);
|
|
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) => {
|
|
const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
|
|
if (!enterCache.has(k)) enterCache.set(k, this._canUnitEnter(unit, coords, isGoal, targetCiv));
|
|
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) {
|
|
const city = this._cityByCoords.get(key(goal.x, goal.y));
|
|
if (city && city.civ !== unit.civ) return city.civ;
|
|
for (const other of this.unitsAt(goal)) {
|
|
if (other !== unit && other.civ !== unit.civ) return other.civ;
|
|
}
|
|
return this.civAt(goal);
|
|
},
|
|
};
|