Files
Battle-for-Tismo/shared/game_state/movement.js
T

363 lines
14 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 {
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;
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)) {
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;
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)) moved += 1;
} else {
const previous = unit.waypoints;
unit.waypoints = destinations.slice(1).map((goal) => ({ ...goal }));
if (this._orderMove(unit, destinations[0], speed)) 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) {
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)) {
this._stopUnit(unit);
return false;
}
return true;
},
_orderMove(unit, goal, speed) {
// 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 path = this.findPath(unit, unit.coords, goal);
if (path.length < 2) {
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.
if (this._isMilitary(unit)) {
if (attacking) this._declareWar(unit.civ, target.civ);
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;
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;
if (hoursLeft >= needed) {
hoursLeft -= needed;
unit.pathIndex += 1;
unit.progressHours = 0;
unit.coords = 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);
}
}
},
// 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) {
return this.terrainStats.movementCostMultiplier(coords) / 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);
if (!this.canEnter(unit.civ, proto, coords)) return false;
const owner = this.civAt(coords);
if (!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) {
if (!isGoal) return false;
if (!this._isMilitary(unit)) return false;
}
return true;
},
findPath(unit, start, goal) {
const targetCiv = this._targetCivFor(unit, goal);
const enterCache = new Map();
const costCache = new Map();
const heuristicCache = new Map();
return 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);
}
);
},
// 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);
},
};