Files
Battle-for-Tismo/client/js/map_view/motion.js
T

669 lines
26 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._syncWrapCopies();
this._flushFog();
this._glRender();
},
// Places every unit and city at its raw map coordinate; the repeat copies
// (see `_syncWrapCopies`) show it in the neighbouring periods. 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);
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,
`${position.x + offset.x}px`,
`${position.y * CAMERA_TILT + offset.y}px`
);
}
for (const view of this._cityViews.values()) {
if (view._localX === undefined) continue;
const left = `${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}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;
fragment.appendChild(makeSegment(start, next, "#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 = 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;
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;
},
};