677 lines
27 KiB
JavaScript
677 lines
27 KiB
JavaScript
// Dead-reckoning unit motion between snapshots, path drawing and the local
|
|
// move prediction that mirrors GameState.find_path.
|
|
|
|
import { mapToLocal, key } 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";
|
|
import {
|
|
CAMERA_TILT,
|
|
RESYNC_HOURS,
|
|
PREDICTION_GRACE_HOURS,
|
|
TARGET_REFRESH_MS,
|
|
} from "./constants.js";
|
|
import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js";
|
|
|
|
// A pathfinding slice runs for this long before handing the thread back, so
|
|
// even a cross-map search never blocks a frame for long.
|
|
const PATH_SLICE_MS = 5;
|
|
const pathNow = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
const pathDefer = (fn) => setTimeout(fn, 0);
|
|
|
|
export const motionMethods = {
|
|
// A living hostile civilian standing on the path blocks dead reckoning: the
|
|
// animation must stop before it. An enemy military unit is the fight itself
|
|
// and combat is fought on a single tile, so the mover charges onto its tile
|
|
// and stops there. Friendly units stack and never block. `occupancy` is the
|
|
// snapshot's per-tile unit buckets (see `_syncUnits`), so the check does not
|
|
// rescan the army for every step of every route.
|
|
_truncateAtOccupant(path, segmentHours, data, occupancy = null) {
|
|
const proto = this.protoUnits[data.proto];
|
|
for (let i = 1; i < path.length; i++) {
|
|
const occupant = occupancy
|
|
? (occupancy.get(key(path[i].x, path[i].y)) || []).find(
|
|
(other) => other.id !== data.id && other.civ !== data.civ
|
|
)
|
|
: this._unitsAtCoords(path[i], data.id).find((other) => other.civ !== data.civ);
|
|
if (!occupant) continue;
|
|
if (proto && proto.military && this._isMilitaryData(occupant)) {
|
|
return { path: path.slice(0, i + 1), segmentHours: segmentHours.slice(0, i) };
|
|
}
|
|
return {
|
|
path: path.slice(0, i),
|
|
segmentHours: segmentHours.slice(0, Math.max(0, i - 1)),
|
|
};
|
|
}
|
|
return { path, segmentHours };
|
|
},
|
|
|
|
_updateUnitMotion(data, occupancy = null) {
|
|
const id = data.id;
|
|
let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] }));
|
|
let segmentHours = data.segmentHours || [];
|
|
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) {
|
|
return;
|
|
}
|
|
this._unitMotion.set(id, {
|
|
path: [],
|
|
segmentHours: [],
|
|
pathHours: 0,
|
|
coords: { x: data.coords[0], y: data.coords[1] },
|
|
predicting: false,
|
|
predictAge: 0,
|
|
});
|
|
return;
|
|
}
|
|
const authoritative = hoursAlong(data, segmentHours);
|
|
let pathHours = authoritative;
|
|
if (existing && samePath(existing.path, path)) {
|
|
pathHours = Math.abs(authoritative - existing.pathHours) > RESYNC_HOURS
|
|
? authoritative
|
|
: existing.pathHours;
|
|
}
|
|
this._unitMotion.set(id, {
|
|
path,
|
|
segmentHours,
|
|
pathHours,
|
|
coords: { x: data.coords[0], y: data.coords[1] },
|
|
predicting: false,
|
|
predictAge: 0,
|
|
});
|
|
},
|
|
|
|
animate(dtHours) {
|
|
for (const [, motion] of this._unitMotion) {
|
|
if (motion.path.length < 2) continue;
|
|
motion.pathHours += dtHours;
|
|
if (motion.predicting) motion.predictAge += dtHours;
|
|
}
|
|
this._syncChunks();
|
|
this._applyEntityWrap();
|
|
this._drawPaths();
|
|
this._drawStrikeTargets();
|
|
this._animateDeliveryIcons();
|
|
this._flushFog();
|
|
this._glRender();
|
|
},
|
|
|
|
// Repositions every unit and city at the wrapped copy nearest the camera.
|
|
// Only writes a coordinate when it actually changed: most of the army is idle
|
|
// on any given frame, and rewriting identical left/top values for every unit
|
|
// still forces style recalculation, which a high-refresh display pays for
|
|
// every frame.
|
|
_applyEntityWrap() {
|
|
for (const [id, motion] of this._unitMotion) {
|
|
const view = this._unitViews.get(id);
|
|
if (!view) continue;
|
|
const position = motion.path.length >= 2
|
|
? this._positionAlongPath(motion)
|
|
: mapToLocal(motion.coords.x, motion.coords.y);
|
|
// Choose the wrapped copy from the tile centre, then apply the stack
|
|
// offset, so members of one stack never split across the wrap seam.
|
|
const wrapped = this._wrappedPosition(position);
|
|
const offset = motion.offset || { x: 0, y: 0 };
|
|
// Sprites stand on the tilted ground, so only the world Y is
|
|
// foreshortened; the stack offset stays a plain pixel spread.
|
|
this._setPosition(
|
|
view,
|
|
`${wrapped.x + offset.x}px`,
|
|
`${wrapped.y * CAMERA_TILT + offset.y}px`
|
|
);
|
|
}
|
|
for (const view of this._cityViews.values()) {
|
|
if (view._localX === undefined) continue;
|
|
const left = `${view._localX + this._wrapShift(view._localX)}px`;
|
|
const top = `${view._localY * CAMERA_TILT}px`;
|
|
this._setPosition(view, left, top);
|
|
if (view._label) this._setPosition(view._label, left, top);
|
|
}
|
|
for (const marker of this._battleViews.values()) {
|
|
if (marker._localX === undefined) continue;
|
|
this._setPosition(
|
|
marker,
|
|
`${marker._localX + this._wrapShift(marker._localX)}px`,
|
|
`${marker._localY * CAMERA_TILT}px`
|
|
);
|
|
}
|
|
},
|
|
|
|
// Writes a screen position to an element only when it moved. The previous
|
|
// value is kept on the element itself, so no extra per-entity bookkeeping is
|
|
// needed when units and cities come and go.
|
|
_setPosition(el, left, top) {
|
|
if (el._posLeft !== left) {
|
|
el._posLeft = left;
|
|
el.style.left = left;
|
|
}
|
|
if (el._posTop !== top) {
|
|
el._posTop = top;
|
|
el.style.top = top;
|
|
}
|
|
},
|
|
|
|
_positionAlongPath(motion) {
|
|
const path = motion.path;
|
|
const segmentHours = motion.segmentHours;
|
|
let remaining = motion.pathHours;
|
|
let index = 0;
|
|
while (index < segmentHours.length) {
|
|
const step = segmentHours[index];
|
|
if (step > 0 && remaining < step) break;
|
|
remaining -= step;
|
|
index += 1;
|
|
}
|
|
if (index >= segmentHours.length) {
|
|
index = segmentHours.length - 1;
|
|
if (index < 0) {
|
|
const local = mapToLocal(path[path.length - 1].x, path[path.length - 1].y);
|
|
return local;
|
|
}
|
|
remaining = segmentHours[index];
|
|
}
|
|
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));
|
|
return { x: a.x + delta.x * t, y: a.y + delta.y * t };
|
|
},
|
|
|
|
_drawPaths() {
|
|
// With nothing selected there is nothing to draw; leave the layer alone
|
|
// rather than emptying and repopulating it every frame.
|
|
if (this._selectedUnitIds.size === 0) {
|
|
if (this.$paths[0].firstChild) this.$paths.empty();
|
|
return;
|
|
}
|
|
const fragment = document.createDocumentFragment();
|
|
for (const [id, motion] of this._unitMotion) {
|
|
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 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 };
|
|
const start = i === index + 1 ? current : previous;
|
|
const a = this._wrappedPosition(start);
|
|
const b = this._wrappedPosition(next);
|
|
fragment.appendChild(makeSegment(a, b, "#ffffff", 4));
|
|
previous = next;
|
|
}
|
|
}
|
|
if (!fragment.childNodes.length) {
|
|
if (this.$paths[0].firstChild) this.$paths.empty();
|
|
return;
|
|
}
|
|
this.$paths.empty()[0].appendChild(fragment);
|
|
},
|
|
|
|
// Where a unit is drawn right now, whether it is parked or mid-route.
|
|
_unitRenderPosition(id) {
|
|
const motion = this._unitMotion.get(id);
|
|
if (!motion) {
|
|
const data = this._unitData.get(id);
|
|
if (!data) return null;
|
|
return mapToLocal(data.coords[0], data.coords[1]);
|
|
}
|
|
if (motion.path.length >= 2) return this._positionAlongPath(motion);
|
|
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.
|
|
//
|
|
// 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
|
|
// the units, so the frame cost is O(units + strikes) instead of O(strikes x
|
|
// units). The nodes are reused, so an unchanged number of barrages allocates
|
|
// nothing.
|
|
_drawStrikeTargets(force = false) {
|
|
if (!this.$targets || !this.$targets.length) return;
|
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
const tick = Math.floor(now / TARGET_REFRESH_MS);
|
|
// Collect the standing barrages and a signature that changes the moment a
|
|
// barrage appears, moves or is selected, so those edits never wait for the
|
|
// next tick.
|
|
const strikes = [];
|
|
let signature = "";
|
|
for (const [id, data] of this._unitData) {
|
|
if (!data.strikeTarget) continue;
|
|
strikes.push(id);
|
|
signature += `${id}:${data.strikeTarget[0]},${data.strikeTarget[1]};`;
|
|
}
|
|
signature += `|${[...this._selectedUnitIds].join(",")}`;
|
|
if (!force && tick === this._strikeTick && signature === this._strikeSignature) return;
|
|
this._strikeTick = tick;
|
|
this._strikeSignature = signature;
|
|
|
|
const views = this._targetViews;
|
|
if (strikes.length === 0) {
|
|
this._clearStrikeTargets();
|
|
return;
|
|
}
|
|
|
|
// One pass over the units resolves the garrison inside every target tile,
|
|
// replacing the per-strike scan of the whole army.
|
|
const targetUnits = this._targetUnitIndex();
|
|
const ns = "http://www.w3.org/2000/svg";
|
|
const seen = new Set();
|
|
for (const id of strikes) {
|
|
const data = this._unitData.get(id);
|
|
if (!data) continue;
|
|
const target = { x: data.strikeTarget[0], y: data.strikeTarget[1] };
|
|
const targetId = targetUnits.get(key(target.x, target.y));
|
|
// The selected attacker or target stands out from the standing barrages
|
|
// the player is not looking at.
|
|
const selected = this._selectedUnitIds.has(id) ||
|
|
(targetId !== undefined && this._selectedUnitIds.has(targetId));
|
|
const fromPoint = this._unitRenderPosition(id);
|
|
if (!fromPoint) continue;
|
|
const toPoint = targetId !== undefined
|
|
? this._unitRenderPosition(targetId)
|
|
: mapToLocal(target.x, target.y);
|
|
if (!toPoint) continue;
|
|
const a = this._wrappedPosition(fromPoint);
|
|
const b = this._wrappedPosition(toPoint);
|
|
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;
|
|
const cx = (a.x + b.x) / 2;
|
|
const cy = (a.y + b.y) / 2 - arc;
|
|
let view = views.get(id);
|
|
if (!view) {
|
|
const path = document.createElementNS(ns, "path");
|
|
const shell = document.createElementNS(ns, "circle");
|
|
shell.setAttribute("class", "target-shell");
|
|
shell.setAttribute("r", "3.5");
|
|
const ring = document.createElement("div");
|
|
const svg = this._ensureTargetSvg();
|
|
svg.appendChild(path);
|
|
svg.appendChild(shell);
|
|
this.$targets[0].appendChild(ring);
|
|
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("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.
|
|
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.ring.className = selected ? "target-ring target-selected" : "target-ring";
|
|
view.ring.style.left = `${b.x}px`;
|
|
view.ring.style.top = `${b.y}px`;
|
|
seen.add(id);
|
|
}
|
|
for (const [id, view] of Array.from(views)) {
|
|
if (seen.has(id)) continue;
|
|
view.path.remove();
|
|
view.shell.remove();
|
|
view.ring.remove();
|
|
views.delete(id);
|
|
}
|
|
},
|
|
|
|
// The <svg> holding every arc. Created lazily and only while there is at least
|
|
// one barrage to draw, so an empty map keeps the layer clean.
|
|
_ensureTargetSvg() {
|
|
if (this._targetSvg && this._targetSvg.parentNode) return this._targetSvg;
|
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
svg.setAttribute("class", "target-arcs");
|
|
this.$targets[0].appendChild(svg);
|
|
this._targetSvg = svg;
|
|
return svg;
|
|
},
|
|
|
|
_clearStrikeTargets() {
|
|
for (const view of this._targetViews.values()) {
|
|
view.path.remove();
|
|
view.shell.remove();
|
|
view.ring.remove();
|
|
}
|
|
this._targetViews.clear();
|
|
if (this._targetSvg) {
|
|
this._targetSvg.remove();
|
|
this._targetSvg = null;
|
|
}
|
|
if (this.$targets[0].firstChild) this.$targets.empty();
|
|
},
|
|
|
|
// The first visible unit standing on each tile, built in one pass. Mirrors
|
|
// `_buildOccupancy`, but also skips units hidden by the fog, which is what
|
|
// the old per-strike `unitIdsAt` scan did.
|
|
_targetUnitIndex() {
|
|
const index = new Map();
|
|
for (const [id, data] of this._unitData) {
|
|
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 k = key(tile.x, tile.y);
|
|
if (!index.has(k)) index.set(k, id);
|
|
}
|
|
return index;
|
|
},
|
|
|
|
// -------------------------------------------------------- move preview --
|
|
|
|
// Mirrors GameState.find_path closely enough that a click feels instant; the
|
|
// next snapshot confirms or corrects the route. `planMove` computes the route
|
|
// without touching the view, so callers can inspect it (for example to warn
|
|
// before entering foreign territory) before committing to the prediction.
|
|
planMove(unitData, goal, speed = null, cache = null) {
|
|
return this._planMove(unitData, goal, speed, cache).then((plan) => (plan ? plan.route : null));
|
|
},
|
|
|
|
predictMove(unitData, goal, speed = null, route = null) {
|
|
if (route) {
|
|
this.predictRoute(unitData, route, speed);
|
|
return Promise.resolve(route);
|
|
}
|
|
return this._planMove(unitData, goal, speed).then((plan) => {
|
|
if (!plan) return null;
|
|
this._applyPrediction(unitData, plan);
|
|
return plan.route;
|
|
});
|
|
},
|
|
|
|
// Starts a prediction from a route that was already planned, skipping the
|
|
// search. Used right after a move order so the route is only found once.
|
|
predictRoute(unitData, route, speed = null) {
|
|
const proto = this.protoUnits[unitData.proto];
|
|
if (!proto || !route || route.length < 2) return null;
|
|
const start = this._unitCoords(unitData.id) ||
|
|
{ x: unitData.coords[0], y: unitData.coords[1] };
|
|
const segmentHours = [];
|
|
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;
|
|
},
|
|
|
|
_applyPrediction(unitData, plan) {
|
|
const existing = this._unitMotion.get(unitData.id);
|
|
this._unitMotion.set(unitData.id, {
|
|
path: plan.route,
|
|
segmentHours: plan.segmentHours,
|
|
pathHours: 0,
|
|
coords: plan.start,
|
|
predicting: true,
|
|
predictAge: 0,
|
|
offset: existing ? existing.offset : { x: 0, y: 0 },
|
|
});
|
|
this.animate(0);
|
|
},
|
|
|
|
async _planMove(unitData, goal, speed = null, cache = null) {
|
|
const proto = this.protoUnits[unitData.proto];
|
|
if (!proto || !proto.moveable || !this.topology) return null;
|
|
const start = this._unitCoords(unitData.id) ||
|
|
{ x: unitData.coords[0], y: unitData.coords[1] };
|
|
if (start.x === goal.x && start.y === goal.y) return null;
|
|
// Units of the same type marching from the same tile to the same goal follow
|
|
// the same route, so the search (and its per-segment hours) runs once.
|
|
const cacheKey = cache
|
|
? `${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);
|
|
let plan = null;
|
|
if (path && path.length >= 2) {
|
|
const segmentHours = [];
|
|
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;
|
|
},
|
|
|
|
// 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
|
|
// every unit for every tile.
|
|
_search(unitData, proto, start, goal, speed) {
|
|
const civ = unitData.civ;
|
|
const targetCiv = this._targetCivFor(unitData, goal);
|
|
const occupancy = this._buildOccupancy(unitData.id);
|
|
const zoc = this._zoneOfControl(civ);
|
|
const enterCache = new Map();
|
|
const costCache = new Map();
|
|
const heuristicCache = new Map();
|
|
const bestSpeed = Math.max(speed || proto.speed, 0.0001);
|
|
// Mirror GameState._fastestTileTravelHours: a railway (or a road) may
|
|
// undercut the cheapest terrain, and the heuristic must stay admissible if
|
|
// A* is to find it.
|
|
const railCost = unitData.civ === this.localCiv
|
|
? TRANSPORT_BY_ID.railway.movementCostMultiplier / (1 + (this.railSpeed || 0))
|
|
: TRANSPORT_BY_ID.railway.movementCostMultiplier;
|
|
const cheapest = Math.min(
|
|
this.terrainStats.minMovementCostMultiplier(),
|
|
TRANSPORT_BY_ID.road.movementCostMultiplier,
|
|
railCost
|
|
);
|
|
const fastestHours = cheapest / (bestSpeed * CONTROLLED_SPEED_MULTIPLIER);
|
|
const iterator = new HexPathfinder().findPathIter(
|
|
start,
|
|
goal,
|
|
(coords) => this.topology.neighbours(coords.x, coords.y),
|
|
(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._canEnter(civ, proto, coords, unitData.id, isGoal, occupancy, targetCiv, zoc, from)
|
|
);
|
|
}
|
|
return enterCache.get(k);
|
|
},
|
|
(_from, to) => {
|
|
const k = key(to.x, to.y);
|
|
if (!costCache.has(k)) costCache.set(k, this._stepHours(proto, civ, to, speed));
|
|
return costCache.get(k);
|
|
},
|
|
(from, to) => {
|
|
const k = key(from.x, from.y);
|
|
if (!heuristicCache.has(k)) {
|
|
const delta = this.topology.pixelDelta(from, to);
|
|
heuristicCache.set(k, (Math.hypot(delta.x, delta.y) / this._maxStepLength) * fastestHours);
|
|
}
|
|
return heuristicCache.get(k);
|
|
}
|
|
);
|
|
return this._runSearch(iterator);
|
|
},
|
|
|
|
// Drives the search in short time slices across timer turns, so a long route
|
|
// never freezes the frame. Starting a new search cancels the previous one.
|
|
_runSearch(iterator) {
|
|
this.cancelPathfinding();
|
|
const token = { cancelled: false };
|
|
this._pathToken = token;
|
|
return new Promise((resolve) => {
|
|
const pump = () => {
|
|
if (token.cancelled) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
const deadline = pathNow() + PATH_SLICE_MS;
|
|
let step = iterator.next();
|
|
while (!step.done && pathNow() < deadline) step = iterator.next();
|
|
if (step.done) {
|
|
this._pathToken = null;
|
|
resolve(step.value);
|
|
} else {
|
|
pathDefer(pump);
|
|
}
|
|
};
|
|
pump();
|
|
});
|
|
},
|
|
|
|
cancelPathfinding() {
|
|
if (!this._pathToken) return;
|
|
this._pathToken.cancelled = true;
|
|
this._pathToken = null;
|
|
},
|
|
|
|
_isMilitaryData(data) {
|
|
const proto = this.protoUnits[data.proto];
|
|
return !!(proto && proto.military);
|
|
},
|
|
|
|
_unitsAtCoords(coords, ignoreId = 0) {
|
|
const result = [];
|
|
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] };
|
|
if (tile.x === coords.x && tile.y === coords.y) result.push(data);
|
|
}
|
|
return result;
|
|
},
|
|
|
|
// Same as _unitsAtCoords, but every unit is bucketed by its tile once so a
|
|
// search can look up occupancy in constant time instead of scanning them all
|
|
// for every tile it considers.
|
|
_buildOccupancy(ignoreId) {
|
|
const index = new Map();
|
|
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 k = key(tile.x, tile.y);
|
|
const list = index.get(k);
|
|
if (list) list.push(data);
|
|
else index.set(k, [data]);
|
|
}
|
|
return index;
|
|
},
|
|
|
|
// Mirrors GameState._zoneOfControl: the tiles hostile ground forces deny to
|
|
// `civ`, so prediction routes around them the same way the server does.
|
|
_zoneOfControl(civ) {
|
|
const denied = new Set();
|
|
for (const [, data] of this._unitData) {
|
|
if (data.civ === civ) continue;
|
|
const proto = this.protoUnits[data.proto];
|
|
if (!proto || !proto.military || proto.air) continue;
|
|
const k = key(data.coords[0], data.coords[1]);
|
|
denied.add(k);
|
|
for (const neighbour of this.topology.neighbours(data.coords[0], data.coords[1])) {
|
|
denied.add(key(neighbour.x, neighbour.y));
|
|
}
|
|
}
|
|
return denied;
|
|
},
|
|
|
|
_unitCoords(id) {
|
|
const motion = this._unitMotion.get(id);
|
|
if (!motion) return null;
|
|
if (motion.path.length >= 2) {
|
|
const index = pathProgress(motion).index;
|
|
return motion.path[Math.min(index, motion.path.length - 1)];
|
|
}
|
|
return motion.coords;
|
|
},
|
|
|
|
// 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(unitData, goal) {
|
|
const k = key(goal.x, goal.y);
|
|
const cityCiv = this._cityCiv.get(k);
|
|
if (cityCiv !== undefined && cityCiv !== unitData.civ) return cityCiv;
|
|
const hostile = this._unitsAtCoords(goal, unitData.id).find((data) => data.civ !== unitData.civ);
|
|
if (hostile) return hostile.civ;
|
|
return this.territory.has(k) ? this.territory.get(k) : -1;
|
|
},
|
|
|
|
// Mirrors GameState._canUnitEnter: friendly units share a tile, while a
|
|
// hostile unit or city is only a valid destination (`isGoal`). Foreign
|
|
// territory is a wall too, except for the civilisation being targeted, so a
|
|
// route never cuts through a third party and provokes a war.
|
|
_canEnter(civ, proto, coords, moverId = 0, isGoal = false, occupancy = null, targetCiv = -1, zoc = null, from = null) {
|
|
const k = key(coords.x, coords.y);
|
|
const air = !!(proto && proto.air);
|
|
const tile = this.tiles[k];
|
|
if (!tile) return false;
|
|
if (!air && !this.explored.has(k)) return false;
|
|
if (!proto.traversableTerrains.includes(tile.terrainClass)) return false;
|
|
// Mirrors the server's zone of control: never cross from one denied tile
|
|
// straight into another, except as a deliberate attack on the goal.
|
|
if (!air && !isGoal && zoc && from &&
|
|
zoc.has(key(from.x, from.y)) && zoc.has(key(k))) {
|
|
return false;
|
|
}
|
|
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
|
|
if (!air && !isGoal && owner >= 0 && owner !== civ && owner !== targetCiv) return false;
|
|
const occupants = occupancy ? (occupancy.get(k) || []) : this._unitsAtCoords(coords, moverId);
|
|
const hostile = occupants.filter((data) => data.civ !== civ);
|
|
if (hostile.length > 0) {
|
|
if (!isGoal) return false;
|
|
if (proto.military !== true) return false;
|
|
const anyMilitary = hostile.some((data) => this._isMilitaryData(data));
|
|
if (!anyMilitary) return false;
|
|
}
|
|
const cityCiv = this._cityCiv.get(k);
|
|
if (cityCiv !== undefined && cityCiv !== civ) {
|
|
// Mirrors GameState._canUnitEnter: a hostile city is only a destination,
|
|
// open to ground troops capturing it and to aircraft bombing its garrison.
|
|
if (!isGoal) return false;
|
|
if (proto.military !== true) return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
_stepHours(proto, civ, coords, speed = null) {
|
|
const effective = Math.max(speed || proto.speed, 0.0001);
|
|
// Mirrors GameState._tileTravelHours: aircraft cross every tile at a flat
|
|
// rate, while ground units are shaped by roads and terrain.
|
|
if (proto.air) return 1 / effective;
|
|
const k = key(coords.x, coords.y);
|
|
let pace = effective;
|
|
if (this.territory.get(k) === civ) pace *= CONTROLLED_SPEED_MULTIPLIER;
|
|
let multiplier;
|
|
if (this.railways.has(k)) {
|
|
multiplier = TRANSPORT_BY_ID.railway.movementCostMultiplier;
|
|
// Only the viewer's own trains technology is known to the browser.
|
|
if (civ === this.localCiv) multiplier /= 1 + (this.railSpeed || 0);
|
|
} else if (this.roads.has(k)) {
|
|
multiplier = TRANSPORT_BY_ID.road.movementCostMultiplier;
|
|
} else {
|
|
multiplier = this.terrainStats.movementCostMultiplier(coords);
|
|
}
|
|
return multiplier / pace;
|
|
},
|
|
};
|