File size: 15,771 Bytes
4c8f450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6648f62
 
 
16bdd2a
 
 
 
 
 
 
 
 
4c8f450
 
 
 
6648f62
 
 
 
 
 
 
 
 
4c8f450
 
bd7ba17
 
 
 
 
4c8f450
 
 
 
e847942
4c8f450
 
 
 
873d661
bd7ba17
 
4c8f450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6648f62
 
4c8f450
 
6648f62
4c8f450
 
 
 
 
 
 
 
 
bd7ba17
4c8f450
 
 
 
 
 
6648f62
 
 
 
 
4c8f450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6648f62
 
 
 
 
 
 
4c8f450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6648f62
 
 
4c8f450
 
 
b383918
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c8f450
 
 
6648f62
 
 
 
 
 
 
 
4c8f450
 
 
6648f62
4c8f450
 
 
 
 
 
 
 
b383918
 
4c8f450
 
 
 
 
 
6648f62
4c8f450
 
 
 
 
 
 
6648f62
4c8f450
 
 
 
 
6648f62
4c8f450
 
 
 
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
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
// @ts-check
/* ============================================================
   Formation geometry: where each unit in a moving or holding group should
   stand, given a shape and (for shapes that have one) a leader position.
   Pure + deterministic β€” geometry only, no clock/RNG β€” so a move order's
   destinations are exactly reproducible, exactly like the flat grid spread
   this replaces.

   FOUR shapes:
     - "grid"   the original spread (a square-ish block, no leader distinction)
     - "line"   a single row abreast, the leader nudged ahead of it ("front")
                or trailing behind it ("back")
     - "wedge"  a V: the leader at the tip with flanks fanned out BEHIND it
                ("front" β€” a spearhead), or at the rear vertex with flanks
                fanned out AHEAD of it ("back" β€” a protective chevron)
     - "circle" a ring; the leader anchors the CENTER, escorted by the rest
                on the ring around it ("center" β€” the default and the one
                explicitly asked for), or takes a point ON the ring at the
                front/back with everyone else filling the rest evenly

   The "leader" is whichever unit is FIRST in the `units` array β€” i.e.
   whichever unit the player selected first and added the rest to (the
   normal box-select-additive selection order preserves this: an existing
   selection is kept first, newly-added picks are appended after β€” UNLESS
   the pick is a single unit already in the selection, which instead
   promotes it to the front, letting the player hand the lead to an
   arbitrary squad member after the fact β€” see input.js's
   applyBoxSelection). Deliberately NOT a stat-based pick (e.g. highest
   maxHp): the player chooses their leader by building the selection
   around it, the same unit engine/commands.js then keeps giving its own
   real order to while everyone else follows it (issueHoldFormation /
   follow-leader β€” see commands.js and movement.js).

   NESTED formations: a selection that's ALREADY spread into distinct spatial
   clusters (e.g. two armies on opposite sides of the map, box-selected
   together) is laid out as a formation OF formations β€” clusterUnits splits
   it (preserving each unit's relative position in the original array, so
   the cluster CONTAINING the overall leader is always clusters[0] and thus
   still gets the outer leader slot), the SAME shape arranges the clusters'
   centres relative to each other (a wedge of wedges, a line of lines, …),
   spaced by each cluster's own footprint so sub-formations can't overlap,
   and each cluster is then laid out again internally around its assigned
   centre. A single blob of units β€” the ordinary case, everyone selected in
   one place β€” collapses to exactly ONE cluster, so nesting only ever
   engages when the selection already calls for it.

   Spacing is derived from the group's own hull sizes (spacingFor), not a
   flat constant, EXCEPT the plain single-group "grid" path, which uses the
   flat GRID_SPACING instead β€” historically the exact legacy formationSpots
   constant this module replaces (see test/commands.test.js), now scaled by
   COARSENESS like every other shape's spacing (units sat too close together
   at the original 1x spacing).
   ============================================================ */

"use strict";

import { radiusOf } from "./colliders.js";

export const FORMATION_SHAPES = ["grid", "line", "wedge", "circle"];
export const LEADER_POSITIONS = ["front", "back", "center"];

const COARSENESS = 1.38;        // overall formation spacing multiplier β€” 1.15 * 1.2, a further +20% on top of the previous "units sat too close at 1x" pass
const GRID_SPACING = 20 * COARSENESS;   // legacy formationSpots (engine/commands.js) flat spacing, scaled
const SPACING_PAD = 8;          // clearance beyond 2x the largest hull's radius, before COARSENESS
const CLUSTER_RADIUS = 150;     // units within this of each other (transitively) count as one army
const NEST_GAP_MULT = 2.4;      // outer (formation-of-formations) spacing, as a multiple of a cluster's own footprint


/** @param {{x?:number, y?:number}} u @returns {{x:number, y:number}} */
function pos(u) {
  return { x: Number.isFinite(u.x) ? u.x : 0, y: Number.isFinite(u.y) ? u.y : 0 };
}

/** @param {Unit[]} units @returns {{x:number, y:number}} */
function centroid(units) {
  let sx = 0, sy = 0;
  for (const u of units) { const p = pos(u); sx += p.x; sy += p.y; }
  return { x: sx / units.length, y: sy / units.length };
}

// The leader is simply whichever unit is first in the given array β€” see the file header. A
// one-line function so callers (engine/commands.js) don't have to know/duplicate the rule.
/** @param {Unit[]} units @returns {Unit} */
export function pickLeader(units) {
  return units[0];
}

// Radius-aware baseline spacing for a group: room for the two largest hulls present to clear
// each other, plus a little breathing space β€” "sufficient spacing between units," not a flat
// number that a Bulk Freighter (radius 15) would blow straight through.
/** @param {Unit[]} units @returns {number} */
function spacingFor(units) {
  let maxR = 0;
  for (const u of units) maxR = Math.max(maxR, radiusOf(u));
  return (maxR * 2 + SPACING_PAD) * COARSENESS;
}

// Partition a selection into spatial sub-armies: any two units within CLUSTER_RADIUS of each
// other (transitively) land in the same cluster β€” a plain union-find pass over CURRENT
// positions. A single blob of units (the common case) collapses to one cluster. O(n^2) on the
// selection size, which is issue-time work on a small array, not a per-tick cost.
//
// Preserves each unit's relative order from the input array within its cluster, AND across
// clusters (the cluster containing units[0] is always clusters[0]) β€” both `layout` calls below
// rely on member[0] being the leader, so this ordering guarantee is what keeps the overall
// leader's own cluster the "leader cluster" of the outer formation too.
/** @param {Unit[]} units @returns {Unit[][]} */
export function clusterUnits(units) {
  const n = units.length;
  const parent = Array.from({ length: n }, (_, i) => i);
  const find = i => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
  const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
  const P = units.map(pos);
  for (let i = 0; i < n; i++)
    for (let j = i + 1; j < n; j++)
      if (Math.hypot(P[i].x - P[j].x, P[i].y - P[j].y) <= CLUSTER_RADIUS) union(i, j);
  const groups = new Map();
  for (let i = 0; i < n; i++) {
    const r = find(i);
    if (!groups.has(r)) groups.set(r, []);
    groups.get(r).push(units[i]);
  }
  return [...groups.values()];
}

// A row abreast the direction of travel β€” leader (slot 0) nudged ahead of it ("front") or
// trailing behind it ("back"); everyone else fills the line, centred.
function lineOffsets(n, leaderPos, spacing) {
  const rest = n - 1;
  const out = new Array(n);
  for (let k = 0; k < rest; k++) out[k + 1] = { forward: 0, right: (k - (rest - 1) / 2) * spacing };
  out[0] = { forward: rest ? (leaderPos === "back" ? -spacing : spacing) : 0, right: 0 };
  return out;
}

// A V: rank 0 is the tip (the leader, slot 0), each rank after it adds two flanking slots
// further out and further back. "front" leaves it as a spearhead (tip leads, flanks trail);
// "back" mirrors the depth so the tip trails at the rear vertex, protected, while the flanks
// fan out ahead of it.
function wedgeOffsets(n, leaderPos, spacing) {
  const out = new Array(n);
  out[0] = { forward: 0, right: 0 };
  let idx = 1, rank = 1;
  while (idx < n) {
    const depth = rank * spacing * 0.85, lateral = rank * spacing * 0.7;
    out[idx++] = { forward: -depth, right: -lateral };
    if (idx < n) out[idx++] = { forward: -depth, right: lateral };
    rank++;
  }
  if (leaderPos === "back") for (const o of out) o.forward = -o.forward;
  return out;
}

// A ring. "center" seats the leader (slot 0) in the middle, protected, with everyone else
// evenly spaced on a ring around it (the group escorting its own flagship β€” the explicit ask:
// "leader center of circular formation"). "front"/"back" instead puts every unit, leader
// included, ON the ring, with the leader's slot at the front or back of it.
function circleOffsets(n, leaderPos, spacing) {
  const out = new Array(n);
  if (leaderPos !== "front" && leaderPos !== "back") {
    out[0] = { forward: 0, right: 0 };
    const ringN = n - 1;
    if (ringN === 0) return out;
    const R = Math.max(spacing, (ringN * spacing) / (2 * Math.PI));
    for (let k = 0; k < ringN; k++) {
      const a = (k / ringN) * Math.PI * 2;
      out[k + 1] = { forward: Math.cos(a) * R, right: Math.sin(a) * R };
    }
    return out;
  }
  const R = Math.max(spacing, (n * spacing) / (2 * Math.PI));
  const start = leaderPos === "back" ? Math.PI : 0;
  for (let k = 0; k < n; k++) {
    const a = start + (k / n) * Math.PI * 2;
    out[k] = { forward: Math.cos(a) * R, right: Math.sin(a) * R };
  }
  return out;
}

// Re-centre a set of {forward,right} offsets so their own mean is exactly (0,0) β€” guarantees
// every shape's centroid lands precisely on the anchor point once translated there, the same
// invariant the (inherently self-centring) grid formula already has.
function centerOffsets(offsets) {
  let mf = 0, mr = 0;
  for (const o of offsets) { mf += o.forward; mr += o.right; }
  mf /= offsets.length; mr /= offsets.length;
  return offsets.map(o => ({ forward: o.forward - mf, right: o.right - mr }));
}

function shapeOffsets(n, shape, leaderPos, spacing) {
  const raw = shape === "line" ? lineOffsets(n, leaderPos, spacing)
    : shape === "circle" ? circleOffsets(n, leaderPos, spacing)
    : wedgeOffsets(n, leaderPos, spacing);   // "wedge", and the fallback for any unrecognised shape
  return centerOffsets(raw);
}

// Lay out `members` (units OR cluster-arrays) into `shape`, oriented by (headingX,headingY),
// centred on (cx,cy). Returns points in the SAME order as `members` β€” member[0] (the leader,
// see the file header) always takes the shape's own leader slot (raw[0]). `shape==="grid"`
// ignores heading entirely (world-axis-aligned, exactly the legacy behaviour); every other
// shape orients its forward axis along the heading (or a stable default when the group isn't
// actually travelling β€” e.g. re-forming in place).
function layout(members, cx, cy, headingX, headingY, shape, leaderPos, spacingOf) {
  const n = members.length;
  if (n === 1) return [{ x: cx, y: cy }];
  const spacing = spacingOf(members);

  if (shape === "grid") {
    const cols = Math.ceil(Math.sqrt(n)), rows = Math.ceil(n / cols);
    return members.map((_, i) => {
      const col = i % cols, row = Math.floor(i / cols);
      return { x: cx + (col - (cols - 1) / 2) * spacing, y: cy + (row - (rows - 1) / 2) * spacing };
    });
  }

  let hx = headingX, hy = headingY;
  const hlen = Math.hypot(hx, hy);
  if (hlen < 1e-6) { hx = 1; hy = 0; } else { hx /= hlen; hy /= hlen; }

  const raw = shapeOffsets(n, shape, leaderPos, spacing);
  return members.map((_, i) => {
    const { forward, right } = raw[i];
    return { x: cx + forward * hx + right * hy, y: cy + forward * hy - right * hx };
  });
}

// Resolve the SAME travel heading `formationSlots` itself orients a shape around, for `units` ->
// (destX,destY) with `opts` ({originX,originY,headingX,headingY} β€” see formationSlots' own doc
// comment for the precedence): an explicit opts.headingX/headingY always wins; otherwise it's the
// vector from an explicit opts.originX/originY (or, lacking that, the group's own centroid) to the
// destination β€” defaulting to facing +x (the same fallback `layout` applies internally) when that
// vector is degenerate (near-zero, e.g. a stationary hold where origin===dest), so a caller gets
// the shape's REAL orientation, not a meaningless zero vector. Deliberately NOT unit-normalized β€”
// a positive scalar multiple never changes which of two points has the larger dot product against
// it, so a caller that only needs RELATIVE order (dispatchFormation's range-layered ranking,
// engine/commands.js) can use this directly. Exported so ranking never has to duplicate β€” and risk
// drifting from β€” this exact precedence.
/** @param {Unit[]} units @param {number} destX @param {number} destY @param {{originX?:number, originY?:number, headingX?:number, headingY?:number}} [opts] @returns {{x:number, y:number}} */
export function resolveHeading(units, destX, destY, opts = {}) {
  const headingX = opts?.headingX, headingY = opts?.headingY;
  if ((headingX || 0) || (headingY || 0)) return { x: headingX, y: headingY };
  let { originX, originY } = opts || {};
  if (!Number.isFinite(originX) || !Number.isFinite(originY)) {
    const c = centroid(units);
    originX = c.x; originY = c.y;
  }
  const hx = destX - originX, hy = destY - originY;
  return Math.hypot(hx, hy) < 1e-6 ? { x: 1, y: 0 } : { x: hx, y: hy };
}

/**
 * Where each unit in `units` should stand for a move to (destX,destY) β€” or, for a stationary
 * hold, where the group's own current centroid already is (pass it as both the destination AND
 * `originX`/`originY`, or simply omit `originX`/`originY` and let it default there). `units[0]`
 * is always the leader (see the file header) and always lands on the shape's own leader slot.
 * Returns one {x,y} per unit, in the SAME order as `units`.
 *
 * The heading (which way the shape faces) is normally derived from `origin -> dest`, but a
 * player can instead set it explicitly β€” e.g. a click-and-drag command, where the drag vector
 * IS the desired facing regardless of how far the destination actually is β€” via `headingX`/
 * `headingY`; either non-zero overrides the origin-derived heading entirely.
 * @param {Unit[]} units
 * @param {number} destX
 * @param {number} destY
 * @param {{shape?:string, leaderPos?:string, originX?:number, originY?:number, headingX?:number, headingY?:number}} [opts]
 * @returns {{x:number, y:number}[]}
 */
export function formationSlots(units, destX, destY, opts = {}) {
  const n = units.length;
  if (n === 0) return [];
  const { shape = "grid", leaderPos = "front" } = opts || {};
  if (n === 1) return [{ x: destX, y: destY }];

  const heading = resolveHeading(units, destX, destY, opts);
  const headingX = heading.x, headingY = heading.y;

  const clusters = clusterUnits(units);
  if (clusters.length <= 1) {
    // The one path that must stay byte-identical to the old flat grid spread: no shape chosen,
    // one blob of units β€” the legacy formationSpots' exact spacing constant, untouched.
    const spacingOf = shape === "grid" ? () => GRID_SPACING : spacingFor;
    return layout(units, destX, destY, headingX, headingY, shape, leaderPos, spacingOf);
  }

  // Nested: a formation OF formations. Each cluster is one outer slot, arranged by the SAME
  // shape (a wedge of wedges, a line of lines, …) β€” spaced by the largest cluster's own
  // footprint so sub-formations can't overlap ("sufficient spacing… in formation of formations").
  const footprints = clusters.map(c => Math.max(1, Math.ceil(Math.sqrt(c.length))) * spacingFor(c));
  const outerSpacing = Math.max(...footprints) * NEST_GAP_MULT;
  const centers = layout(clusters, destX, destY, headingX, headingY, shape, leaderPos, () => outerSpacing);

  const placed = new Map();
  clusters.forEach((cluster, i) => {
    const { x: ccx, y: ccy } = centers[i];
    const cc = centroid(cluster);
    const inner = layout(cluster, ccx, ccy, ccx - cc.x, ccy - cc.y, shape, leaderPos, spacingFor);
    cluster.forEach((u, k) => placed.set(u, inner[k]));
  });
  return units.map(u => placed.get(u));
}