// Dead-reckoning unit motion between snapshots, path drawing and the local // move prediction that mirrors GameState.find_path. import { mapToLocal, key } from "../../../shared/hex.js"; import { HexPathfinder } from "../../../shared/hex_pathfinder.js"; import { TRANSPORT_BY_ID } from "../../../shared/data/improvements.js"; import { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js"; import { CAMERA_TILT, RESYNC_HOURS, PREDICTION_GRACE_HOURS, TARGET_REFRESH_MS, } from "./constants.js"; import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js"; // A pathfinding slice runs for this long before handing the thread back, so // even a cross-map search never blocks a frame for long. const PATH_SLICE_MS = 5; const pathNow = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); const pathDefer = (fn) => setTimeout(fn, 0); export const motionMethods = { // A living hostile civilian standing on the path blocks dead reckoning: the // animation must stop before it. An enemy military unit is the fight itself // and combat is fought on a single tile, so the mover charges onto its tile // and stops there. Friendly units stack and never block. `occupancy` is the // snapshot's per-tile unit buckets (see `_syncUnits`), so the check does not // rescan the army for every step of every route. _truncateAtOccupant(path, segmentHours, data, occupancy = null) { const proto = this.protoUnits[data.proto]; for (let i = 1; i < path.length; i++) { const occupant = occupancy ? (occupancy.get(key(path[i].x, path[i].y)) || []).find( (other) => other.id !== data.id && other.civ !== data.civ ) : this._unitsAtCoords(path[i], data.id).find((other) => other.civ !== data.civ); if (!occupant) continue; if (proto && proto.military && this._isMilitaryData(occupant)) { return { path: path.slice(0, i + 1), segmentHours: segmentHours.slice(0, i) }; } return { path: path.slice(0, i), segmentHours: segmentHours.slice(0, Math.max(0, i - 1)), }; } return { path, segmentHours }; }, _updateUnitMotion(data, occupancy = null) { const id = data.id; let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] })); let segmentHours = data.segmentHours || []; const clipped = this._truncateAtOccupant(path, segmentHours, data, occupancy); path = clipped.path; segmentHours = clipped.segmentHours; const existing = this._unitMotion.get(id); if (path.length < 2 || segmentHours.length === 0) { if (existing && existing.predicting && existing.predictAge < PREDICTION_GRACE_HOURS) { return; } this._unitMotion.set(id, { path: [], segmentHours: [], pathHours: 0, coords: { x: data.coords[0], y: data.coords[1] }, predicting: false, predictAge: 0, }); return; } const authoritative = hoursAlong(data, segmentHours); let pathHours = authoritative; if (existing && samePath(existing.path, path)) { pathHours = Math.abs(authoritative - existing.pathHours) > RESYNC_HOURS ? authoritative : existing.pathHours; } this._unitMotion.set(id, { path, segmentHours, pathHours, coords: { x: data.coords[0], y: data.coords[1] }, predicting: false, predictAge: 0, }); }, animate(dtHours) { for (const [, motion] of this._unitMotion) { if (motion.path.length < 2) continue; motion.pathHours += dtHours; if (motion.predicting) motion.predictAge += dtHours; } this._syncChunks(); this._applyEntityWrap(); this._drawPaths(); this._drawStrikeTargets(); this._animateDeliveryIcons(); this._flushFog(); this._glRender(); }, // Repositions every unit and city at the wrapped copy nearest the camera. // Only writes a coordinate when it actually changed: most of the army is idle // on any given frame, and rewriting identical left/top values for every unit // still forces style recalculation, which a high-refresh display pays for // every frame. _applyEntityWrap() { for (const [id, motion] of this._unitMotion) { const view = this._unitViews.get(id); if (!view) continue; const position = motion.path.length >= 2 ? this._positionAlongPath(motion) : mapToLocal(motion.coords.x, motion.coords.y); // Choose the wrapped copy from the tile centre, then apply the stack // offset, so members of one stack never split across the wrap seam. const wrapped = this._wrappedPosition(position); const offset = motion.offset || { x: 0, y: 0 }; // Sprites stand on the tilted ground, so only the world Y is // foreshortened; the stack offset stays a plain pixel spread. this._setPosition( view, `${wrapped.x + offset.x}px`, `${wrapped.y * CAMERA_TILT + offset.y}px` ); } for (const view of this._cityViews.values()) { if (view._localX === undefined) continue; const left = `${view._localX + this._wrapShift(view._localX)}px`; const top = `${view._localY * CAMERA_TILT}px`; this._setPosition(view, left, top); if (view._label) this._setPosition(view._label, left, top); } for (const marker of this._battleViews.values()) { if (marker._localX === undefined) continue; this._setPosition( marker, `${marker._localX + this._wrapShift(marker._localX)}px`, `${marker._localY * CAMERA_TILT}px` ); } }, // Writes a screen position to an element only when it moved. The previous // value is kept on the element itself, so no extra per-entity bookkeeping is // needed when units and cities come and go. _setPosition(el, left, top) { if (el._posLeft !== left) { el._posLeft = left; el.style.left = left; } if (el._posTop !== top) { el._posTop = top; el.style.top = top; } }, _positionAlongPath(motion) { const path = motion.path; const segmentHours = motion.segmentHours; let remaining = motion.pathHours; let index = 0; while (index < segmentHours.length) { const step = segmentHours[index]; if (step > 0 && remaining < step) break; remaining -= step; index += 1; } if (index >= segmentHours.length) { index = segmentHours.length - 1; if (index < 0) { const local = mapToLocal(path[path.length - 1].x, path[path.length - 1].y); return local; } remaining = segmentHours[index]; } const from = path[index]; const to = path[index + 1] || from; const a = mapToLocal(from.x, from.y); const delta = this.topology.pixelDelta(from, to); const step = segmentHours[index] || 1; const t = Math.max(0, Math.min(1, remaining / step)); return { x: a.x + delta.x * t, y: a.y + delta.y * t }; }, _drawPaths() { // With nothing selected there is nothing to draw; leave the layer alone // rather than emptying and repopulating it every frame. if (this._selectedUnitIds.size === 0) { if (this.$paths[0].firstChild) this.$paths.empty(); return; } const fragment = document.createDocumentFragment(); for (const [id, motion] of this._unitMotion) { if (!this._selectedUnitIds.has(id)) continue; if (motion.path.length < 2) continue; const index = pathProgress(motion).index; const base = mapToLocal(motion.path[index].x, motion.path[index].y); const current = this._positionAlongPath(motion); let previous = base; for (let i = index + 1; i < motion.path.length; i++) { const from = motion.path[i - 1]; const to = motion.path[i]; const delta = this.topology.pixelDelta(from, to); const next = { x: previous.x + delta.x, y: previous.y + delta.y }; const start = i === index + 1 ? current : previous; const a = this._wrappedPosition(start); const b = this._wrappedPosition(next); fragment.appendChild(makeSegment(a, b, "#ffffff", 4)); previous = next; } } if (!fragment.childNodes.length) { if (this.$paths[0].firstChild) this.$paths.empty(); return; } this.$paths.empty()[0].appendChild(fragment); }, // Where a unit is drawn right now, whether it is parked or mid-route. _unitRenderPosition(id) { const motion = this._unitMotion.get(id); if (!motion) { const data = this._unitData.get(id); if (!data) return null; return mapToLocal(data.coords[0], data.coords[1]); } if (motion.path.length >= 2) return this._positionAlongPath(motion); return mapToLocal(motion.coords.x, motion.coords.y); }, // Draws the red dashed barrage arc from every ranged attacker with a standing // target to the target tile, and a red ring around the target. The shell lobs // in a parabola rather than along the ground, and the dashes march and a // glowing shell travels the curve so the direction is unmistakable. They stay // visible so an ongoing barrage cannot be forgotten, and sit on a layer above // the terrain but below the unit and city icons. // // The overlay is rebuilt at most every TARGET_REFRESH_MS and immediately when // its contents change; the per-strike target lookup goes through one index of // the units, so the frame cost is O(units + strikes) instead of O(strikes x // units). The nodes are reused, so an unchanged number of barrages allocates // nothing. _drawStrikeTargets(force = false) { if (!this.$targets || !this.$targets.length) return; const now = typeof performance !== "undefined" ? performance.now() : Date.now(); const tick = Math.floor(now / TARGET_REFRESH_MS); // Collect the standing barrages and a signature that changes the moment a // barrage appears, moves or is selected, so those edits never wait for the // next tick. const strikes = []; let signature = ""; for (const [id, data] of this._unitData) { if (!data.strikeTarget) continue; strikes.push(id); signature += `${id}:${data.strikeTarget[0]},${data.strikeTarget[1]};`; } signature += `|${[...this._selectedUnitIds].join(",")}`; if (!force && tick === this._strikeTick && signature === this._strikeSignature) return; this._strikeTick = tick; this._strikeSignature = signature; const views = this._targetViews; if (strikes.length === 0) { this._clearStrikeTargets(); return; } // One pass over the units resolves the garrison inside every target tile, // replacing the per-strike scan of the whole army. const targetUnits = this._targetUnitIndex(); const ns = "http://www.w3.org/2000/svg"; const seen = new Set(); for (const id of strikes) { const data = this._unitData.get(id); if (!data) continue; const target = { x: data.strikeTarget[0], y: data.strikeTarget[1] }; const targetId = targetUnits.get(key(target.x, target.y)); // The selected attacker or target stands out from the standing barrages // the player is not looking at. const selected = this._selectedUnitIds.has(id) || (targetId !== undefined && this._selectedUnitIds.has(targetId)); const fromPoint = this._unitRenderPosition(id); if (!fromPoint) continue; const toPoint = targetId !== undefined ? this._unitRenderPosition(targetId) : mapToLocal(target.x, target.y); if (!toPoint) continue; const a = this._wrappedPosition(fromPoint); const b = this._wrappedPosition(toPoint); const distance = Math.hypot(b.x - a.x, b.y - a.y); // A quadratic Bezier lobs the shell: the control point is lifted above the // midpoint, its height compensated for the layer's Y foreshortening so the // arc keeps its shape on screen. const arc = Math.max(18, distance * 0.4) / CAMERA_TILT; const cx = (a.x + b.x) / 2; const cy = (a.y + b.y) / 2 - arc; let view = views.get(id); if (!view) { const path = document.createElementNS(ns, "path"); const shell = document.createElementNS(ns, "circle"); shell.setAttribute("class", "target-shell"); shell.setAttribute("r", "3.5"); const ring = document.createElement("div"); const svg = this._ensureTargetSvg(); svg.appendChild(path); svg.appendChild(shell); this.$targets[0].appendChild(ring); view = { path, shell, ring }; views.set(id, view); } view.path.setAttribute("d", `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}`); view.path.setAttribute("class", selected ? "target-line target-selected" : "target-line"); // Marching dashes flow from the battery towards the target. view.path.style.strokeDashoffset = `${-((now / 22) % 18)}px`; // A glowing shell rides the curve to show exactly where it is going. const t = (now / 1300) % 1; const mt = 1 - t; view.shell.setAttribute("cx", `${mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`); view.shell.setAttribute("cy", `${mt * mt * a.y + 2 * mt * t * cy + t * t * b.y}`); view.ring.className = selected ? "target-ring target-selected" : "target-ring"; view.ring.style.left = `${b.x}px`; view.ring.style.top = `${b.y}px`; seen.add(id); } for (const [id, view] of Array.from(views)) { if (seen.has(id)) continue; view.path.remove(); view.shell.remove(); view.ring.remove(); views.delete(id); } }, // The 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; }, };