// 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, 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 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 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; } }, // ------------------------------------------------------- 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; }, };