File size: 7,485 Bytes
e3658fa
a8d58c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d7e8fa
8583c67
 
a8d58c2
 
873d661
 
 
 
 
 
 
 
 
a8d58c2
ee2be58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3658fa
a8d58c2
6d7e8fa
 
 
 
 
 
 
 
 
 
 
 
ee2be58
 
6d7e8fa
 
ee2be58
 
 
 
6d7e8fa
a8d58c2
 
 
 
873d661
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8d58c2
 
873d661
 
 
 
 
 
a8d58c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8583c67
a8d58c2
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
// @ts-check
/* ============================================================
   General collision separation: any two same-owner units β€” any type,
   however they got where they are (a shared rally point, a group order,
   two workers converging on one node, whatever) β€” get gently pushed
   apart the instant their bodies overlap. This runs every tick as a
   correction pass, independent of movement/orders, so it catches
   stacking from any source instead of needing a fix at each place an
   order gets assigned.

   Deliberately skips different-owner pairs: opposing units are meant to
   close to weapon range and stand there, not get shoved off it.
   ============================================================ */

"use strict";

import { UNITS } from "./entities.js";
import { queryNeighbors } from "./grid.js";
import { hashStr } from "./rng.js";
import { MAX_UNIT_RADIUS } from "./movement.js";

const PUSH_SPEED = 60;   // units/sec of separation at full overlap
// A gap beyond bare hull-to-hull contact: idle/converging same-owner units used to rest exactly
// at their combined radii (zero padding), sitting shoulder to shoulder. A standalone multiplier
// on minDist below β€” not a change to entities.js's own radius fields β€” so it stays independent of
// formation.js's own COARSENESS spacing multiplier, which reads that same radius field for a
// units-in-formation's target slots; the two widen idle rest-spacing and in-formation spacing by
// their own, separately-tunable amounts instead of one feeding into (and compounding with) the
// other.
const SEPARATION_PAD_MULT = 1.2;
const SEP_RADIUS = 2 * MAX_UNIT_RADIUS * SEPARATION_PAD_MULT;   // two largest hulls at the padded distance β€” the widest possible minDist; derived, never stale

// A background world left to idle can pile up a same-owner army numbering in the
// hundreds, all crammed onto roughly the same rally point (nothing consumes an idle
// standing army). queryNeighbors' candidate list for a query planted inside that pile
// scales with local density, not with army size β€” ordinary battles never come close to
// this many bodies in one broad-phase cell, so bounding it here changes nothing for them,
// but it keeps a genuinely pathological pile from turning one applySeparation call into
// a near-O(pile^2) pass. MAX_SEPARATION_NEIGHBORS is well above any realistic legitimate
// local cluster (verified by the full test/balance/determinism suites staying green).
export const MAX_SEPARATION_NEIGHBORS = 40;

// The [start, take) window into a length-`n` candidate list that unit `gi` scans this
// call. Identity ([0, n)) under the cap β€” byte-identical to the old plain for-of. Past
// the cap it takes a fixed-size slice that starts at (gi + tick) % n: offset by gi so a
// huge pile isn't split into an "always resolved" low-_gi group and a "never resolved"
// high-_gi remainder within a single tick, and slid forward by the simulation's own tick
// counter so a pile sitting at a stable local density doesn't get stuck scanning the same
// window forever β€” every pair comes into some unit's window at least once every n ticks.
// state.tick is a plain deterministic counter (engine/state.js), so this stays exactly as
// reproducible as everything else in a seeded replay. Pure arithmetic, no allocation β€”
// safe to call once per unit on the hot path.
/** @param {number} n @param {number} gi @param {number} tick @returns {[number, number]} */
export function separationWindow(n, gi, tick) {
  if (n <= MAX_SEPARATION_NEIGHBORS) return [0, n];
  return [(gi + tick) % n, MAX_SEPARATION_NEIGHBORS];
}

/** @param {State} state @param {number} dt */
export function applySeparation(state, dt) {
  const grid = state.unitGrid;
  if (!grid) {
    // No broad-phase index (a direct unit test, not a full tick): exact original
    // all-pairs pass, so those tests stay byte-for-byte unchanged.
    const units = [...state.units.values()];
    for (let i = 0; i < units.length; i++) {
      for (let j = i + 1; j < units.length; j++) separatePair(units[i], units[j], dt);
    }
    return;
  }
  // Grid broad phase: for each unit, only test the handful of units in nearby
  // cells, and only the higher-indexed one of each pair so every pair resolves
  // exactly once (matching the i<j semantics above) β€” bounded by separationWindow
  // so a pathologically dense cell can't blow up a single tick's cost.
  for (const a of state.units.values()) {
    const near = queryNeighbors(grid, a.x, a.y, SEP_RADIUS);
    const n = near.length;
    const [start, take] = separationWindow(n, a._gi, state.tick);
    for (let k = 0; k < take; k++) {
      const b = near[(start + k) % n];
      if (b._gi > a._gi && b.hp > 0) separatePair(a, b, dt);
    }
  }
}

// Whether `u` is in "combat mode": an explicit attack or attack-move order (engine/combat.js
// engages either the instant an enemy comes into range, so an army still CLOSING the distance
// counts too β€” not just a unit that's already found a target), or one of combat.js's two other
// live-target registers (unit.autoTarget's sticky auto-acquire, unit.focusId's AI tactical
// focus-fire). Deliberately NOT combat mode for a plain 'move' order, 'gather'/'build', a formation
// move, or no order at all (idle) β€” exactly the "idle or in formation" cases SEPARATION_PAD_MULT is
// meant to widen. Excluding the whole approach, not just the moment contact is made, matters: two
// still-converging attackers held further apart from EACH OTHER during the approach arrive
// staggered rather than together, so an already-borderline matchup (a swarm's mass-fire timing vs.
// a slow, heavy single target) can quietly tip β€” the exact class of regression
// test/balance.test.js's auto-battle harness exists to catch. A stale autoTarget/focusId (its
// target just died this same tick, not yet cleared) reads as still-combat-mode for one tick too
// long β€” harmless: it only means a unit keeps its tighter just-fought spacing a beat longer before
// relaxing outward, never the other way round.
function isCombatMode(u) {
  return (u.order && (u.order.type === "attack" || u.order.type === "attack-move")) || u.autoTarget != null || u.focusId != null;
}

function separatePair(a, b, dt) {
  if (a.owner !== b.owner) return;
  // Combat packing is a balance-tuned invariant (test/balance.test.js's whole auto-battle
  // regression harness) β€” SEPARATION_PAD_MULT must never loosen how tightly a squad can mass fire
  // on one target (or close the distance to do so), only how far apart units rest when neither
  // side of the pair is in combat mode at all. See isCombatMode just above.
  const pad = (isCombatMode(a) || isCombatMode(b)) ? 1 : SEPARATION_PAD_MULT;
  const minDist = (UNITS[a.type].radius + UNITS[b.type].radius) * pad;

  let dx = b.x - a.x, dy = b.y - a.y;
  let dist = Math.hypot(dx, dy);
  if (dist >= minDist) return;

  if (dist < 1e-4) {
    const angle = hashAngle(a.id, b.id);
    dx = Math.cos(angle); dy = Math.sin(angle); dist = 1;
  }

  const overlap = minDist - dist;
  const nx = dx / dist, ny = dy / dist;
  const push = Math.min(overlap, PUSH_SPEED * dt) / 2;
  a.x -= nx * push; a.y -= ny * push;
  b.x += nx * push; b.y += ny * push;
}

// Deterministic per-pair direction for the (rare) exactly-coincident case,
// so two units spawned on the same point don't jitter frame to frame.
function hashAngle(idA, idB) {
  return (hashStr(idA + idB) % 360) * (Math.PI / 180);
}