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