// @ts-check /* ============================================================ Movement with local avoidance: a unit senses same-owner neighbors ahead of it and steers sideways around them, instead of walking straight through and only getting untangled after the fact by separation.js's reactive correction pass (still there as a safety net for cases avoidance alone can't resolve, like several units genuinely told to stand on the same point). Steering is lateral (perpendicular to the direction of travel), not a straight push-back — a neighbor sitting exactly on the travel line has zero radial sideways component by symmetry, so pure repulsion stalls an approaching unit directly behind it forever instead of routing around. A deterministic per-pair tie-break picks a side for that dead-ahead case so the choice doesn't flicker frame to frame. ============================================================ */ "use strict"; import { radiusOf } from "./colliders.js"; import { UNITS } from "./entities.js"; import { queryNeighbors } from "./grid.js"; import { sampleTerrain, sideMod } from "./map.js"; import { hashStr } from "./rng.js"; const AVOID_RANGE = 30; // how far out a unit senses neighbors to steer around, beyond their combined radii const AVOID_WEIGHT = 1.6; // how strongly a sensed neighbor bends the seek direction // Derived from the roster, not hardcoded: the old literal (10, "Breacher") went stale the moment // a wider hull was added (the Bulk Freighter is 15), which silently under-sized the avoidance // query — it only kept working because the grid pads each query by a full cell. Computing it // once at module load from static UNITS data keeps it correct as the roster grows, with no // purity issue (no randomness/clock). A larger query returns a superset of candidates that the // exact-distance check still filters identically, so replays stay byte-identical. export const MAX_UNIT_RADIUS = Math.max(...Object.values(UNITS).map(u => u.radius || 0)); // The formation slot for an escorting unit — a point on a protective ring around the friendly // ship it's guarding (order {type:"escort", targetId, slot, slots}). The ring expands if the // group is large enough to crowd it, so N escorts space out evenly around the target. Returns // null when the target is gone or dead, so the caller drops the order. Pure + deterministic: // geometry only (no clock/RNG). The escort re-seeks this point every tick, so it trails the // target wherever it's ordered — and a combat escort still auto-acquires threats that come near. const ESCORT_GAP = 22; // arc spacing between neighbouring escorts const ESCORT_STANDOFF = 26; // clearance from the target's hull out to the ring /** @param {State} state @param {Unit} unit @returns {{x:number, y:number}|null} */ export function escortSlot(state, unit) { const o = unit.order; const target = state.units.get(o.targetId); if (!target || target.hp <= 0) return null; const n = Math.max(1, o.slots || 1); const angle = ((o.slot || 0) / n) * Math.PI * 2 - Math.PI / 2; // start at the top, go round const minR = (n * ESCORT_GAP) / (2 * Math.PI); // ring big enough to seat the whole group const R = Math.max(radiusOf(target) + ESCORT_STANDOFF, minR); return { x: target.x + Math.cos(angle) * R, y: target.y + Math.sin(angle) * R }; } // Advance an escorting unit one tick toward its formation slot. Returns false — and drops the // order — when the guarded target is gone; true while it's still escorting. All three escort // branches (combat ship, worker, support drone) did exactly this by hand, so the shared // escortSlot→drop-or-step logic lives here once. Pure follow: never cleared on arrival, so the // escort trails the target wherever it goes. /** @param {State} state @param {Unit} unit @param {number} speed @param {number} dt @returns {boolean} */ export function keepEscortStation(state, unit, speed, dt) { const slot = escortSlot(state, unit); if (!slot) { unit.order = null; return false; } stepToward(state, unit, slot.x, slot.y, speed, dt); return true; } // Advance a formation LEADER holding station (order {type:"hold-formation", anchorX, anchorY, // offsetX, offsetY} — engine/commands.js issueHoldFormation) one tick toward its own fixed // anchor point. Unlike escort's ring (which re-seeks a MOVING external target every tick), the // anchor here never moves — that's the point: this is a unit (or a formation's leader — see // keepFollowingLeader below for everyone else in the group) defending its own position, not // following anyone. Nothing can "vanish" and drop the order the way an escort's target can; it // persists until a new order replaces it. /** @param {State} state @param {Unit} unit @param {number} speed @param {number} dt */ export function keepFormationStation(state, unit, speed, dt) { const o = unit.order; stepToward(state, unit, o.anchorX + o.offsetX, o.anchorY + o.offsetY, orderedSpeed(speed, o), dt); } // Advance a formation FOLLOWER (order {type:"follow-leader", leader, offsetX, offsetY} — // engine/commands.js dispatchFormation) one tick toward a fixed offset from its leader's LIVE // position — re-seeked every tick, exactly like escort's ring around its guarded ship, except // the "target" is another player unit that itself is under direct orders (move/attack-move/ // hold-formation) rather than just standing still. This is what makes "select the leader alone // and move it" drag the rest of the formation along for free: the leader gets a fresh order, // the followers were never re-ordered at all — they're still chasing `leader.pos + offset` // every tick, whatever that position now is. Drops the order (returns to independent control) // the moment the leader dies, the same as an escort losing its guarded ship. /** @param {State} state @param {Unit} unit @param {number} speed @param {number} dt @returns {boolean} */ export function keepFollowingLeader(state, unit, speed, dt) { const o = unit.order; const leader = o.leader; // Ask the STATE whether the leader still exists, not just its hp. A follow-leader order holds a // live object REFERENCE, and two removal paths delete a unit that is still at full hp — // deployColonyShip (engine/colony.js) and updateUnitRecycle (engine/recycle.js). Escorting a // colony ship and deploying it is the canonical Odyssey play, and an hp-only check left both // escorts permanently pinned to a ship no longer in state.units, orbiting its frozen last // position. (escort orders are immune: escortSlot resolves through state.units.get every tick.) if (!leader || leader.hp <= 0 || !state.units.has(leader.id)) { unit.order = null; return false; } stepToward(state, unit, leader.x + o.offsetX, leader.y + o.offsetY, speed, dt); return true; } // A formation's overall pace is capped to its SLOWEST member (order.speedCap, stamped by // engine/commands.js's dispatchFormation and groupSpeedCap) so a fast leader can't outrun the // group it's supposedly leading — without this, followers (who each move at their OWN top speed // toward the leader's live position) would perpetually lag behind while the leader is in motion, // stretching the shape out instead of holding it. A Ranger leading a formation into scout mode // (engine/commands.js issueScout, engine/scout.js) gets the same cap for the same reason — a // protective scout, not a lone unit outrunning its own escort. Followers never need this // themselves: every follower's own speed is, by construction, at least the group minimum, so they // can always keep pace with a leader that's capped to it. Identity (returns `speed` unchanged) // for any order without a cap. /** @param {number} speed @param {Order} order @returns {number} */ export function orderedSpeed(speed, order) { return Number.isFinite(order?.speedCap) ? Math.min(speed, order.speedCap) : speed; } // Moves `unit` at most `speed * dt` toward (tx, ty). Returns true once it // has arrived (within 1 unit), so callers can clear the order that got it // there. /** @param {State} state @param {Unit} unit @param {number} tx @param {number} ty @param {number} speed @param {number} dt @returns {boolean} */ export function stepToward(state, unit, tx, ty, speed, dt) { // Hardening: a non-finite destination (NaN/Infinity — a tampered save whose order // coords slipped coercion, or a future bug) would drive unit.x/unit.y to NaN below, // and a single NaN position poisons the separation spatial hash for every neighbour // it buckets with. Refuse to step toward an unreachable point: leave the unit's // (finite) position untouched and report arrival, so the caller clears the order as // if it had already been reached. Finite orders never take this branch, so valid // movement replays byte-identically. if (!Number.isFinite(tx) || !Number.isFinite(ty)) return true; const dx0 = tx - unit.x, dy0 = ty - unit.y; const distToTarget = Math.hypot(dx0, dy0); if (distToTarget <= 1) { unit.x = tx; unit.y = ty; return true; } const seekX = dx0 / distToTarget, seekY = dy0 / distToTarget; let dirX = seekX, dirY = seekY; const lateral = senseLateralAvoidance(state, unit, seekX, seekY); if (lateral !== 0) { const perpX = -seekY, perpY = seekX; // 90° left of the seek direction const steerX = seekX + perpX * lateral * AVOID_WEIGHT; const steerY = seekY + perpY * lateral * AVOID_WEIGHT; const mag = Math.hypot(steerX, steerY); if (mag > 1e-6) { dirX = steerX / mag; dirY = steerY / mag; } } // Per-planet speed modifier (PLANET_MODIFIERS) and per-cell terrain both scale // the step at the one choke point every mover funnels through. Terrain is a // slow-down only (rough ground), never zero, so a unit always makes forward // progress — nothing can be trapped. Optional-chained so map-less test stubs // read the default 1; `allTerrain` on a def opts a unit out (forward-compat). const speedMult = sideMod(state, unit.owner, "speedMult"); const def = UNITS[unit.type]; const terrainMult = state.map?.terrain && !(def && def.allTerrain) ? sampleTerrain(state.map.terrain, unit.x, unit.y).speedMult : 1; const step = Math.min(distToTarget, speed * speedMult * terrainMult * dt); unit.x += dirX * step; unit.y += dirY * step; return false; } // Sums a sideways-steer contribution from every same-owner neighbor // ahead of the unit and within sensing range — positive means "steer // left of the seek direction," negative "steer right." A neighbor // behind the unit (already passed) is ignored. /** @param {State} state @param {Unit} unit @param {number} seekX @param {number} seekY @returns {number} */ function senseLateralAvoidance(state, unit, seekX, seekY) { const perpX = -seekY, perpY = seekX; const selfR = radiusOf(unit); // Broad phase: only same-owner neighbours in nearby cells can be within sense // range. Falls back to the full unit list when there's no grid (direct tests). const others = state.unitGrid ? queryNeighbors(state.unitGrid, unit.x, unit.y, selfR + MAX_UNIT_RADIUS + AVOID_RANGE) : state.units.values(); let lateral = 0; for (const other of others) { if (other === unit || other.owner !== unit.owner || other.hp <= 0) continue; const dx = other.x - unit.x, dy = other.y - unit.y; const dist = Math.hypot(dx, dy); const detectRange = selfR + radiusOf(other) + AVOID_RANGE; if (dist <= 0 || dist >= detectRange) continue; const ahead = dx * seekX + dy * seekY; if (ahead <= 0) continue; // don't dodge something already behind you let side = dx * perpX + dy * perpY; if (Math.abs(side) < 1e-3) side = tieBreak(unit.id, other.id); const weight = (detectRange - dist) / detectRange; lateral += (side > 0 ? -1 : 1) * weight; } return lateral; } // Stable per-pair left/right pick for a neighbor sitting exactly on the // travel line, so the dodge direction doesn't flicker tick to tick. /** @param {string} idA @param {string} idB @returns {number} */ function tieBreak(idA, idB) { return hashStr(idA + idB) % 2 === 0 ? 1 : -1; }