Spaces:
Paused
Paused
File size: 12,191 Bytes
e3658fa 9175b1f 17641e3 e847942 9175b1f 6d7e8fa d228ca7 8583c67 9175b1f 8583c67 9175b1f b1fda0e e3658fa b1fda0e 8583c67 e3658fa 8583c67 6648f62 4c8f450 6648f62 0da037f 6648f62 f71d721 6648f62 4c8f450 9175b1f e3658fa 9175b1f fdea120 9175b1f 03f71c7 d228ca7 03f71c7 9175b1f e3658fa 9175b1f 6d7e8fa 9175b1f 6d7e8fa 9175b1f e3658fa 9175b1f 8583c67 17641e3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | // @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;
}
|