944 lines
38 KiB
JavaScript
944 lines
38 KiB
JavaScript
// Dead-reckoning unit motion between snapshots, path drawing and the local
|
|
// move prediction that mirrors GameState.find_path.
|
|
|
|
import {
|
|
mapToLocal,
|
|
localToMap,
|
|
flightLine,
|
|
key,
|
|
parseKey,
|
|
} 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 { CORNERS } from "./distortion.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 || [];
|
|
// A flight path is already a straight pixel line with no tile occupants to
|
|
// stop it: the truncation walk works in tiles and must not touch it.
|
|
const flight = this._isFlierData(data);
|
|
if (!flight) {
|
|
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] },
|
|
flight,
|
|
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] },
|
|
flight,
|
|
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 on the copy of the world nearest the camera, so
|
|
// a single icon layer is enough however many periods the viewport spans. 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. A pan changes the folded position only when an entity crosses
|
|
// a period boundary, so panning writes almost nothing here.
|
|
_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 };
|
|
const wrapped = this._wrappedPosition(position);
|
|
// 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`);
|
|
}
|
|
// Construction and tile-improvement markers carry their map position on the
|
|
// element so they fold with the units and cities they sit under.
|
|
for (const views of [this._siteViews, this._warfareViews]) {
|
|
if (!views) continue;
|
|
for (const el of views.values()) {
|
|
if (el._localX === undefined) continue;
|
|
this._setPosition(el, `${el._localX + this._wrapShift(el._localX)}px`,
|
|
`${el._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 step = segmentHours[index] || 1;
|
|
const t = Math.max(0, Math.min(1, remaining / step));
|
|
// A flight path is already world pixels: interpolate the segment directly.
|
|
if (motion.flight) {
|
|
return { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
|
|
}
|
|
const a = mapToLocal(from.x, from.y);
|
|
const delta = this.topology.pixelDelta(from, to);
|
|
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 = motion.flight
|
|
? motion.path[index]
|
|
: 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 to = motion.path[i];
|
|
// A flight path stores pixel points already; a ground path stores tiles
|
|
// and is unwrapped a segment at a time.
|
|
const next = motion.flight
|
|
? to
|
|
: (() => {
|
|
const delta = this.topology.pixelDelta(motion.path[i - 1], to);
|
|
return { 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 line from every ranged attacker with a standing target
|
|
// to the target tile, and a red ring around the target. A gun's shell lobs in
|
|
// a parabola rather than along the ground; an aircraft's strike is no ballistic
|
|
// launch, so its line stays straight. The dashes march and a glowing shell
|
|
// travels the line so the direction is unmistakable. They stay visible so an
|
|
// ongoing barrage cannot be forgotten. The arc and shell ride a layer above the
|
|
// unit and city icons so a shell is never hidden behind the battery that fired
|
|
// it; the ring stays on the layer below them.
|
|
//
|
|
// 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 ||
|
|
!this.$strikeArcs || !this.$strikeArcs.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. A stack fires as one, so batteries sharing an origin tile and a
|
|
// target are grouped and drawn as a single arc and ring.
|
|
const groups = new Map();
|
|
let signature = "";
|
|
for (const [id, data] of this._unitData) {
|
|
if (!data.strikeTarget) continue;
|
|
signature += `${id}:${data.strikeTarget[0]},${data.strikeTarget[1]};`;
|
|
const motion = this._unitMotion.get(id);
|
|
const origin = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
|
|
const pair = `${key(origin.x, origin.y)}>${key(data.strikeTarget[0], data.strikeTarget[1])}`;
|
|
let group = groups.get(pair);
|
|
if (!group) {
|
|
group = { ids: [], target: data.strikeTarget };
|
|
groups.set(pair, group);
|
|
}
|
|
group.ids.push(id);
|
|
}
|
|
signature += `|${[...this._selectedUnitIds].join(",")}`;
|
|
if (!force && tick === this._strikeTick && signature === this._strikeSignature) return;
|
|
this._strikeTick = tick;
|
|
this._strikeSignature = signature;
|
|
|
|
const views = this._targetViews;
|
|
if (groups.size === 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 group of groups.values()) {
|
|
const id = group.ids[0];
|
|
const data = this._unitData.get(id);
|
|
if (!data) continue;
|
|
const target = { x: group.target[0], y: group.target[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; a stack stands out when any of its
|
|
// batteries is selected.
|
|
const selected = group.ids.some((unitId) => this._selectedUnitIds.has(unitId)) ||
|
|
(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 gun lobs its shell: a quadratic Bezier whose control point is lifted
|
|
// above the midpoint, its height compensated for the layer's Y
|
|
// foreshortening so the arc keeps its shape on screen. An aircraft flies
|
|
// straight to the target, so its line is a plain segment.
|
|
const straight = !!(this.protoUnits[data.proto] || {}).air;
|
|
const arc = straight ? 0 : 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",
|
|
straight
|
|
? `M ${a.x} ${a.y} L ${b.x} ${b.y}`
|
|
: `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 line to show exactly where it is going.
|
|
const t = (now / 1300) % 1;
|
|
const mt = 1 - t;
|
|
view.shell.setAttribute("cx", `${straight ? a.x + (b.x - a.x) * t : mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`);
|
|
view.shell.setAttribute("cy", `${straight ? a.y + (b.y - a.y) * t : 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.$strikeArcs[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;
|
|
}
|
|
},
|
|
|
|
// ------------------------------------------------------- targeting --
|
|
|
|
// Enters target-picking mode for a ranged strike. It draws the red reach
|
|
// boundary around `origin`; as the pointer moves it puts the target circle on
|
|
// the hovered tile and the barrage arc from the attacker to it.
|
|
beginTargeting({ unitIds, origin, range, globalRange }) {
|
|
this._targeting = {
|
|
unitIds: unitIds ? unitIds.slice() : [],
|
|
origin: { x: origin.x, y: origin.y },
|
|
range: Math.max(1, range | 0),
|
|
globalRange: !!globalRange,
|
|
};
|
|
this._targetHover = null;
|
|
if (this.$viewport && this.$viewport.length) this.$viewport.addClass("targeting");
|
|
this._buildRangeBoundary();
|
|
this._drawTargeting();
|
|
},
|
|
|
|
clearTargeting() {
|
|
this._targeting = null;
|
|
this._targetHover = null;
|
|
this._rangeTiles = null;
|
|
if (this.$viewport && this.$viewport.length) this.$viewport.removeClass("targeting");
|
|
if (this._rangeBoundary) { this._rangeBoundary.remove(); this._rangeBoundary = null; }
|
|
if (this._rangeArc) { this._rangeArc.remove(); this._rangeArc = null; }
|
|
if (this._rangeRing) { this._rangeRing.remove(); this._rangeRing = null; }
|
|
if (this._targetingSvg && !this._targetingSvg.childNodes.length) {
|
|
this._targetingSvg.remove();
|
|
this._targetingSvg = null;
|
|
}
|
|
},
|
|
|
|
// The reachable tiles, flood-filled out from the origin through the topology
|
|
// so the wrap seam is handled. A global-range weapon reaches everywhere, so
|
|
// no boundary is drawn.
|
|
_buildRangeBoundary() {
|
|
this._rangeTiles = null;
|
|
if (!this._targeting || this._targeting.globalRange || !this.topology) return;
|
|
const { origin, range } = this._targeting;
|
|
const reachable = new Set([key(origin.x, origin.y)]);
|
|
const queue = [{ x: origin.x, y: origin.y, d: 0 }];
|
|
while (queue.length) {
|
|
const node = queue.shift();
|
|
if (node.d >= range) continue;
|
|
for (const nb of this.topology.neighbours(node.x, node.y)) {
|
|
const k = key(nb.x, nb.y);
|
|
if (reachable.has(k)) continue;
|
|
reachable.add(k);
|
|
queue.push({ x: nb.x, y: nb.y, d: node.d + 1 });
|
|
}
|
|
}
|
|
this._rangeTiles = reachable;
|
|
},
|
|
|
|
_ensureTargetingSvg() {
|
|
if (this._targetingSvg && this._targetingSvg.parentNode) return this._targetingSvg;
|
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
svg.setAttribute("class", "targeting-arcs");
|
|
this.$targeting[0].appendChild(svg);
|
|
this._targetingSvg = svg;
|
|
return svg;
|
|
},
|
|
|
|
// Draws the reach boundary (static until the origin changes) and refreshes
|
|
// the aim overlay.
|
|
_drawTargeting() {
|
|
if (!this.$targeting || !this.$targeting.length || !this._targeting) return;
|
|
this._drawRangeBoundary();
|
|
this._drawTargetAim();
|
|
},
|
|
|
|
_drawRangeBoundary() {
|
|
if (this._targeting.globalRange || !this._rangeTiles || !this.topology) {
|
|
if (this._rangeBoundary) { this._rangeBoundary.remove(); this._rangeBoundary = null; }
|
|
return;
|
|
}
|
|
const svg = this._ensureTargetingSvg();
|
|
if (!this._rangeBoundary) {
|
|
this._rangeBoundary = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
this._rangeBoundary.setAttribute("class", "range-boundary");
|
|
svg.appendChild(this._rangeBoundary);
|
|
}
|
|
let d = "";
|
|
for (const k of this._rangeTiles) {
|
|
const tile = parseKey(k);
|
|
const a = mapToLocal(tile.x, tile.y);
|
|
const ca = CORNERS.map((corner) => ({ x: a.x + corner.x, y: a.y + corner.y }));
|
|
for (const nb of this.topology.neighbours(tile.x, tile.y)) {
|
|
if (this._rangeTiles.has(key(nb.x, nb.y))) continue;
|
|
const b = mapToLocal(nb.x, nb.y);
|
|
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
// The shared edge is the adjacent corner pair whose midpoint sits on the
|
|
// line between the two centres.
|
|
let best = null;
|
|
let bestD = Infinity;
|
|
for (let i = 0; i < 6; i++) {
|
|
const p = ca[i];
|
|
const q = ca[(i + 1) % 6];
|
|
const m = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
|
|
const dd = (m.x - mid.x) ** 2 + (m.y - mid.y) ** 2;
|
|
if (dd < bestD) { bestD = dd; best = [p, q]; }
|
|
}
|
|
d += `M${best[0].x.toFixed(2)} ${best[0].y.toFixed(2)}L${best[1].x.toFixed(2)} ${best[1].y.toFixed(2)}`;
|
|
}
|
|
}
|
|
this._rangeBoundary.setAttribute("d", d);
|
|
},
|
|
|
|
// Updates the hover target circle and the barrage arc. Called as the pointer
|
|
// moves and after a camera change so a stale aim is never left behind.
|
|
_drawTargetAim() {
|
|
if (!this._targeting || !this.$targeting || !this.$targeting.length) return;
|
|
if (!this._targetHover) {
|
|
if (this._rangeArc) this._rangeArc.setAttribute("d", "");
|
|
if (this._rangeRing) this._rangeRing.style.display = "none";
|
|
return;
|
|
}
|
|
const svg = this._ensureTargetingSvg();
|
|
const from = mapToLocal(this._targeting.origin.x, this._targeting.origin.y);
|
|
const to = this._nearestCopy(
|
|
mapToLocal(this._targetHover.x, this._targetHover.y),
|
|
from
|
|
);
|
|
if (!this._rangeArc) {
|
|
this._rangeArc = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
this._rangeArc.setAttribute("class", "target-line target-selected");
|
|
svg.appendChild(this._rangeArc);
|
|
}
|
|
// The same parabolic lob the standing barrages use.
|
|
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
|
const lift = Math.max(18, distance * 0.4) / CAMERA_TILT;
|
|
const cx = (from.x + to.x) / 2;
|
|
const cy = (from.y + to.y) / 2 - lift;
|
|
this._rangeArc.setAttribute("d", `M ${from.x} ${from.y} Q ${cx} ${cy} ${to.x} ${to.y}`);
|
|
if (!this._rangeRing) {
|
|
this._rangeRing = document.createElement("div");
|
|
this._rangeRing.className = "target-ring target-selected";
|
|
this.$targeting[0].appendChild(this._rangeRing);
|
|
}
|
|
this._rangeRing.style.display = "";
|
|
this._rangeRing.style.left = `${to.x}px`;
|
|
this._rangeRing.style.top = `${to.y}px`;
|
|
},
|
|
|
|
// The pointer moved while picking a target: snap to the tile under it.
|
|
_onTargetingHover(event) {
|
|
if (!this._targeting || !this.$viewport || !this.$viewport.length) return;
|
|
const coords = this.tileAtScreen(event.clientX, event.clientY);
|
|
if (!coords) return;
|
|
if (this._targetHover && this._targetHover.x === coords.x && this._targetHover.y === coords.y) {
|
|
return;
|
|
}
|
|
this._targetHover = { x: coords.x, y: coords.y };
|
|
this._drawTargetAim();
|
|
},
|
|
|
|
// 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 ? this._motionTile(motion) : { 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 = [];
|
|
if (proto.air || proto.missile) {
|
|
for (let i = 1; i < route.length; i++) {
|
|
segmentHours.push(this._flightHours(proto, route[i - 1], route[i], speed));
|
|
}
|
|
} else {
|
|
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,
|
|
flight: this._isFlierData(unitData),
|
|
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);
|
|
// A flier's route is a straight pixel line to the goal, exactly as the
|
|
// server builds it; there is no territory, terrain or zone to search.
|
|
const path = (proto.air || proto.missile)
|
|
? flightLine(mapToLocal(start.x, start.y), goal, this.topology)
|
|
: await this._search(unitData, proto, start, goal, speed);
|
|
let plan = null;
|
|
if (path && path.length >= 2) {
|
|
const segmentHours = [];
|
|
if (proto.air || proto.missile) {
|
|
segmentHours.push(this._flightHours(proto, path[0], path[1], speed));
|
|
} else {
|
|
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;
|
|
},
|
|
|
|
// Whether a snapshot unit flies a straight pixel path (aircraft or missile).
|
|
_isFlierData(data) {
|
|
const proto = this.protoUnits[data.proto];
|
|
return !!(proto && (proto.air || proto.missile));
|
|
},
|
|
|
|
// Hours to fly a straight pixel segment, mirroring GameState._segmentFlightHours.
|
|
_flightHours(proto, a, b, speed = null) {
|
|
const effective = Math.max(speed || proto.speed, 0.0001);
|
|
return (Math.hypot(b.x - a.x, b.y - a.y) / (this._maxStepLength || 1)) / effective;
|
|
},
|
|
|
|
// 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 ? this._motionTile(motion) : { 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 ? this._motionTile(motion) : { 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;
|
|
},
|
|
|
|
// The tile a motion is over right now. A flight stores world pixels, so its
|
|
// tile is derived from where the line currently is.
|
|
_motionTile(motion) {
|
|
if (motion.path.length >= 2) {
|
|
if (motion.flight) return localToMap(this._positionAlongPath(motion), this.topology);
|
|
const index = pathProgress(motion).index;
|
|
return motion.path[Math.min(index, motion.path.length - 1)];
|
|
}
|
|
return motion.coords;
|
|
},
|
|
|
|
_unitCoords(id) {
|
|
const motion = this._unitMotion.get(id);
|
|
if (!motion) return null;
|
|
return this._motionTile(motion);
|
|
},
|
|
|
|
// 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;
|
|
},
|
|
|
|
_pairKey(a, b) {
|
|
return a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
},
|
|
|
|
_isAtWar(a, b) {
|
|
if (a < 0 || b < 0 || a === b) return false;
|
|
return this._wars.has(this._pairKey(a, b));
|
|
},
|
|
|
|
_isAllied(a, b) {
|
|
if (a < 0 || b < 0 || a === b) return false;
|
|
return this._allies.has(this._pairKey(a, b));
|
|
},
|
|
|
|
// Mirrors GameState._canTraverseTerritory: a nation's own land, the land of
|
|
// the nation a route is aimed at, an ally's and a nation already at war are
|
|
// all open; only a neutral third party's land is a wall. Kept in step with
|
|
// the server or prediction will draw a route the server refuses.
|
|
_canTraverseTerritory(civ, owner, targetCiv = -1) {
|
|
if (owner < 0 || owner === civ || owner === targetCiv) return true;
|
|
if (this._isAtWar(civ, owner)) return true;
|
|
if (this._isAllied(civ, owner)) return true;
|
|
return false;
|
|
},
|
|
|
|
// Mirrors GameState._canUnitEnter: friendly units share a tile, while a
|
|
// hostile unit or city is only a valid destination (`isGoal`). A foreign land
|
|
// is a wall only for a neutral third party; the civilisation being targeted,
|
|
// an ally and a nation already at war may be crossed (see
|
|
// `_canTraverseTerritory`), so a route never cuts through a bystander 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;
|
|
// 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(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;
|
|
},
|
|
};
|