Spaces:
Paused
Redefine formation leader as first-selected unit, add persistent follow, speed cap, and drag-to-face
Browse filesReworks the formation system per feedback on PR #9:
- Leader is now whichever unit the player selected first (the one others
get added to), not a stat-based pick. pickLeader() and layout() in
engine/formation.js simply use array order instead of scanning for
highest maxHp.
- Selecting and moving just the leader now drags along everyone
previously grouped with it, with no need to reselect the group. A new
persistent "follow-leader" order (engine/commands.js, engine/movement.js,
engine/sim.js, engine/combat.js) mirrors the existing escort-order
pattern: followers re-seek the leader's live position every tick via
object references (unit.squadLeader / leader.squadFollowers), set up
by dispatchFormation() and torn down by any direct order that bypasses
it (stop, hold, scout, build).
- A formation's travel speed is capped to its slowest member so a fast
leader can't stretch the group out of shape. dispatchFormation()
computes the cap once at issue time and stores it on the leader's
order as order.speedCap; a new orderedSpeed() helper in movement.js
applies it at every move/attack-move/hold-formation stepToward call
site.
- Right-click-and-drag now sets an explicit facing: input.js tracks a
real mousedown/mousemove/mouseup gesture (replacing the old
contextmenu-only handling) and turns a long-enough drag into a
heading passed through to issueMove/issueAttackMove/issueHoldFormation.
applyFacing() stamps unit.facing accordingly. This applies to
formations and to single/multi-unit selections alike (e.g. facing a
choke point while waiting for the enemy). renderShared.js's
updateFacing() prefers the stamped facing while a unit is
near-stationary, but still faces the direction of travel while moving.
Two issues turned up during implementation and were fixed proactively:
- The new leader/follower squad mechanic only applies to player-owned
units (dispatchFormation gates on leader.owner === "player"); AI-issued
multi-unit orders bypass it entirely so every AI unit still gets its
own real order, exactly as before. Full-suite regression caught this
as a real collision with existing AI tactical tests.
- squadLeader/squadFollowers and any live follow-leader order carry
circular object references that can't survive JSON.stringify.
persist.js now strips them (and nulls follow-leader orders) during
save, matching the existing pattern for other transient fields, with
a regression test confirming save/load doesn't throw while a squad
is active.
Rewrote test/formation.test.js (leader-order semantics, persistent
follow, speed cap, facing, AI-owner gating, save/load safety) and
test/input.test.js (drag-vs-click distinction, facing on drag). Full
suite: 814/814 passing. Typecheck clean. Manually verified in a live
browser via Playwright: group drag-move sets facing and speed cap
correctly, and a solo move of just the leader correctly drags the whole
previously-formed squad along.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dmxcvm178TEt2wVXrX5Cj1
- engine/combat.js +10 -6
- engine/commands.js +135 -24
- engine/formation.js +56 -54
- engine/movement.js +39 -7
- engine/persist.js +15 -1
- engine/sim.js +17 -8
- engine/types.js +8 -3
- hudSelection.js +7 -1
- input.js +51 -20
- overlays.js +1 -0
- renderShared.js +11 -3
- test/formation.test.js +259 -70
- test/input.test.js +52 -4
|
@@ -15,7 +15,7 @@
|
|
| 15 |
|
| 16 |
"use strict";
|
| 17 |
|
| 18 |
-
import { stepToward, keepEscortStation, keepFormationStation } from "./movement.js";
|
| 19 |
import { UNITS, BUILDINGS, upgradeMult } from "./entities.js";
|
| 20 |
import { getEntity, removeEntity } from "./state.js";
|
| 21 |
import { queryNeighbors } from "./grid.js";
|
|
@@ -28,7 +28,7 @@ export function updateCombat(state, unit, dt) {
|
|
| 28 |
unit.attackTimer = Math.max(0, unit.attackTimer - dt);
|
| 29 |
|
| 30 |
if (unit.order && unit.order.type === "move") {
|
| 31 |
-
const arrived = stepToward(state, unit, unit.order.x, unit.order.y, def.speed, dt);
|
| 32 |
if (arrived) unit.order = null;
|
| 33 |
return;
|
| 34 |
}
|
|
@@ -77,7 +77,7 @@ export function updateCombat(state, unit, dt) {
|
|
| 77 |
}
|
| 78 |
|
| 79 |
if (unit.order && unit.order.type === "attack-move") {
|
| 80 |
-
const arrived = stepToward(state, unit, unit.order.x, unit.order.y, def.speed, dt);
|
| 81 |
if (arrived) unit.order = null;
|
| 82 |
return;
|
| 83 |
}
|
|
@@ -88,10 +88,14 @@ export function updateCombat(state, unit, dt) {
|
|
| 88 |
if (unit.order && unit.order.type === "escort") {
|
| 89 |
keepEscortStation(state, unit, def.speed, dt);
|
| 90 |
} else if (unit.order && unit.order.type === "hold-formation") {
|
| 91 |
-
// Same idea, but the "target" is
|
| 92 |
-
// a
|
| 93 |
-
//
|
| 94 |
keepFormationStation(state, unit, def.speed, dt);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
}
|
| 96 |
}
|
| 97 |
|
|
|
|
| 15 |
|
| 16 |
"use strict";
|
| 17 |
|
| 18 |
+
import { stepToward, keepEscortStation, keepFormationStation, keepFollowingLeader, orderedSpeed } from "./movement.js";
|
| 19 |
import { UNITS, BUILDINGS, upgradeMult } from "./entities.js";
|
| 20 |
import { getEntity, removeEntity } from "./state.js";
|
| 21 |
import { queryNeighbors } from "./grid.js";
|
|
|
|
| 28 |
unit.attackTimer = Math.max(0, unit.attackTimer - dt);
|
| 29 |
|
| 30 |
if (unit.order && unit.order.type === "move") {
|
| 31 |
+
const arrived = stepToward(state, unit, unit.order.x, unit.order.y, orderedSpeed(def.speed, unit.order), dt);
|
| 32 |
if (arrived) unit.order = null;
|
| 33 |
return;
|
| 34 |
}
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
if (unit.order && unit.order.type === "attack-move") {
|
| 80 |
+
const arrived = stepToward(state, unit, unit.order.x, unit.order.y, orderedSpeed(def.speed, unit.order), dt);
|
| 81 |
if (arrived) unit.order = null;
|
| 82 |
return;
|
| 83 |
}
|
|
|
|
| 88 |
if (unit.order && unit.order.type === "escort") {
|
| 89 |
keepEscortStation(state, unit, def.speed, dt);
|
| 90 |
} else if (unit.order && unit.order.type === "hold-formation") {
|
| 91 |
+
// Same idea, but the "target" is this unit's OWN fixed anchor rather than a moving ship β
|
| 92 |
+
// a formation leader holding its ground, still fully combat-reactive above (this only runs
|
| 93 |
+
// once no threat was acquired/engaged this tick).
|
| 94 |
keepFormationStation(state, unit, def.speed, dt);
|
| 95 |
+
} else if (unit.order && unit.order.type === "follow-leader") {
|
| 96 |
+
// A formation follower: keeps its slot relative to the leader's live position, same
|
| 97 |
+
// combat-reactive treatment as escort/hold-formation above.
|
| 98 |
+
keepFollowingLeader(state, unit, def.speed, dt);
|
| 99 |
}
|
| 100 |
}
|
| 101 |
|
|
@@ -17,7 +17,14 @@ import { formationSlots } from "./formation.js";
|
|
| 17 |
// fully idle unit acts immediately, so the first waypoint of a chain doesn't
|
| 18 |
// sit inert. A plain command always wipes any queued waypoints, so it cancels a
|
| 19 |
// chain cleanly. sim.js pulls the next queued order in as soon as `order` clears.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
function dispatch(unit, order, queue) {
|
|
|
|
| 21 |
if (!unit.orderQueue) unit.orderQueue = [];
|
| 22 |
unit.hold = false; // any positive order (move/attack/gather/waypoint) cancels a Hold stance
|
| 23 |
if (queue && (unit.order || unit.orderQueue.length)) {
|
|
@@ -28,12 +35,116 @@ function dispatch(unit, order, queue) {
|
|
| 28 |
}
|
| 29 |
}
|
| 30 |
|
| 31 |
-
// `
|
| 32 |
-
//
|
| 33 |
-
// that
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
const spots = formationSlots(units, x, y, formation);
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
export function issueGather(units, nodeId, queue = false) {
|
|
@@ -64,8 +175,9 @@ export function issueAttack(units, targetId, queue = false) {
|
|
| 64 |
}
|
| 65 |
|
| 66 |
export function issueAttackMove(units, x, y, queue = false, formation) {
|
| 67 |
-
|
| 68 |
-
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
// Escort a friendly ship: each unit takes a stable slot on a protective ring around the
|
|
@@ -79,25 +191,22 @@ export function issueEscort(units, targetId, queue = false) {
|
|
| 79 |
}
|
| 80 |
|
| 81 |
// Protection of a FORMATION, not one external ship: the group forms up in the chosen shape
|
| 82 |
-
// right where it's standing (its own current centroid
|
| 83 |
-
//
|
| 84 |
-
//
|
| 85 |
-
//
|
| 86 |
-
// Combat units also take the Hold stance (issueHold's existing "stand fast,
|
| 87 |
-
//
|
| 88 |
-
// non-combat units (workers, a Mender) still
|
| 89 |
-
//
|
| 90 |
-
// offset from the anchor at issue time, so holding station never needs to re-scan the group.
|
| 91 |
export function issueHoldFormation(units, shape = "grid", leaderPos = "front") {
|
| 92 |
if (!units.length) return;
|
| 93 |
let sx = 0, sy = 0;
|
| 94 |
for (const u of units) { sx += u.x; sy += u.y; }
|
| 95 |
const anchorX = sx / units.length, anchorY = sy / units.length;
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
if (UNITS[u.type] && UNITS[u.type].role === "combat") u.hold = true;
|
| 100 |
-
});
|
| 101 |
}
|
| 102 |
|
| 103 |
// Pays the cost up front, drops a constructing building on the spot, and
|
|
@@ -121,6 +230,7 @@ export function issueBuild(state, workerId, buildingType, x, y) {
|
|
| 121 |
payCost(player.resources, def.cost);
|
| 122 |
const building = makeBuilding(buildingType, worker.owner, x, y, { constructing: true });
|
| 123 |
state.buildings.set(building.id, building);
|
|
|
|
| 124 |
worker.order = { type: "build", buildingId: building.id };
|
| 125 |
return building.id;
|
| 126 |
}
|
|
@@ -136,8 +246,9 @@ export function issueAssistBuild(units, buildingId, queue = false) {
|
|
| 136 |
// where it stands. A combat unit still defends itself (it re-auto-acquires an
|
| 137 |
// enemy that wanders into range next tick), but it won't chase or resume a
|
| 138 |
// path β the standard RTS "stop" that pulls a unit out of a move or a chain.
|
|
|
|
| 139 |
export function issueStop(units) {
|
| 140 |
-
units.forEach(u => { u.order = null; u.orderQueue = []; u.hold = false; });
|
| 141 |
}
|
| 142 |
|
| 143 |
// Hold position: a combat unit stands its ground and fires on anything that
|
|
@@ -146,7 +257,7 @@ export function issueStop(units) {
|
|
| 146 |
// holding a line or a choke. Any move/attack order, or Stop, clears it.
|
| 147 |
export function issueHold(units) {
|
| 148 |
units.forEach(u => {
|
| 149 |
-
if (UNITS[u.type] && UNITS[u.type].role === "combat") { u.order = null; u.orderQueue = []; u.hold = true; }
|
| 150 |
});
|
| 151 |
}
|
| 152 |
|
|
@@ -157,7 +268,7 @@ export function issueHold(units) {
|
|
| 157 |
// a plain command β the mode is persistent, not something to stack behind.
|
| 158 |
export function issueScout(units) {
|
| 159 |
units.forEach(u => {
|
| 160 |
-
if (UNITS[u.type] && UNITS[u.type].role === "scout") { u.order = { type: "scout" }; u.orderQueue = []; }
|
| 161 |
});
|
| 162 |
}
|
| 163 |
|
|
|
|
| 17 |
// fully idle unit acts immediately, so the first waypoint of a chain doesn't
|
| 18 |
// sit inert. A plain command always wipes any queued waypoints, so it cancels a
|
| 19 |
// chain cleanly. sim.js pulls the next queued order in as soon as `order` clears.
|
| 20 |
+
//
|
| 21 |
+
// A DIRECT order (anything except the follow-leader order dispatchFormation itself hands out)
|
| 22 |
+
// releases the unit from whatever squad it was following β direct control always wins. The
|
| 23 |
+
// squad's leader is untouched by this (a leader never carries squadLeader itself), so giving
|
| 24 |
+
// the LEADER a fresh order here never disbands the squad; only a follower ordered on its own
|
| 25 |
+
// leaves it.
|
| 26 |
function dispatch(unit, order, queue) {
|
| 27 |
+
if (order.type !== "follow-leader") setSquadLeader(unit, null);
|
| 28 |
if (!unit.orderQueue) unit.orderQueue = [];
|
| 29 |
unit.hold = false; // any positive order (move/attack/gather/waypoint) cancels a Hold stance
|
| 30 |
if (queue && (unit.order || unit.orderQueue.length)) {
|
|
|
|
| 35 |
}
|
| 36 |
}
|
| 37 |
|
| 38 |
+
// Attach/detach `unit` to/from `leader`'s squad (or clear it with leader=null). Keeps the
|
| 39 |
+
// relationship bidirectional and consistent: leaving a DIFFERENT leader's squad always trims
|
| 40 |
+
// that leader's own squadFollowers list too, so a leader never keeps dragging along a unit that
|
| 41 |
+
// no longer considers it the leader. Transient, session-only state (never persisted β see
|
| 42 |
+
// persist.js's serPlanet, which strips both fields and drops a live follow-leader order
|
| 43 |
+
// entirely rather than trying to serialize the object reference it carries).
|
| 44 |
+
function setSquadLeader(unit, leader) {
|
| 45 |
+
const old = unit.squadLeader;
|
| 46 |
+
if (old && old !== leader && old.squadFollowers) {
|
| 47 |
+
const i = old.squadFollowers.indexOf(unit);
|
| 48 |
+
if (i >= 0) old.squadFollowers.splice(i, 1);
|
| 49 |
+
}
|
| 50 |
+
unit.squadLeader = leader || undefined;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// The shared machinery behind issueMove/issueAttackMove/issueHoldFormation: lay the group out
|
| 54 |
+
// in `formation`'s shape, give the LEADER (units[0] β see engine/formation.js) its own real
|
| 55 |
+
// order via `makeLeaderOrder(point)`, and give every OTHER unit a persistent "follow-leader"
|
| 56 |
+
// order that keeps it at a fixed offset from the leader's LIVE position from now on
|
| 57 |
+
// (engine/movement.js keepFollowingLeader) β not just for this one command. That's what makes
|
| 58 |
+
// "select the leader alone and move it" drag the rest of the formation along for free: a later
|
| 59 |
+
// solo command to the leader (the n===1 branch below) re-uses its EXISTING squadFollowers
|
| 60 |
+
// (pruned of anything that's died) purely to recompute the group's speed cap; it never touches
|
| 61 |
+
// the followers' own orders, because they're already perpetually chasing the leader regardless
|
| 62 |
+
// of what NEW order the leader receives.
|
| 63 |
+
//
|
| 64 |
+
// The whole squad's travel speed is capped to its SLOWEST member (order.speedCap, read by
|
| 65 |
+
// engine/movement.js's orderedSpeed) so a fast leader can't outrun its own formation β without
|
| 66 |
+
// this, followers would perpetually lag behind a leader moving at its own full speed, stretching
|
| 67 |
+
// the shape out instead of holding it.
|
| 68 |
+
function dispatchFormation(units, x, y, formation, queue, makeLeaderOrder) {
|
| 69 |
+
const leader = units[0];
|
| 70 |
+
|
| 71 |
+
// The leader/follow squad is a PLAYER selection-UX feature β there's no analogous "the unit
|
| 72 |
+
// you built a selection around" concept for the scripted AI (or a test double with no owner
|
| 73 |
+
// set at all), and the existing tactical AI (kiting, retreats, feint response, wave sizingβ¦)
|
| 74 |
+
// reads every one of ITS units' own real move/attack-move order directly. So anything that
|
| 75 |
+
// isn't a confirmed player unit skips the whole leader/follower mechanic and just gets the
|
| 76 |
+
// plain per-unit spread this replaces β one real order per unit, exactly as before this
|
| 77 |
+
// feature existed. (Every player-issued command always passes real player.owner==="player"
|
| 78 |
+
// units β see input.js's selectedUnits().)
|
| 79 |
+
if (leader.owner !== "player") {
|
| 80 |
+
const spots = formationSlots(units, x, y, formation);
|
| 81 |
+
units.forEach((u, i) => dispatch(u, makeLeaderOrder(spots[i]), queue));
|
| 82 |
+
return;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
if (units.length === 1) {
|
| 86 |
+
const order = makeLeaderOrder({ x, y });
|
| 87 |
+
if (leader.squadFollowers && leader.squadFollowers.length) {
|
| 88 |
+
leader.squadFollowers = leader.squadFollowers.filter(f => f.hp > 0);
|
| 89 |
+
const groupSpeed = [leader, ...leader.squadFollowers].reduce((m, u) => Math.min(m, UNITS[u.type]?.speed ?? Infinity), Infinity);
|
| 90 |
+
if (Number.isFinite(groupSpeed)) order.speedCap = groupSpeed;
|
| 91 |
+
}
|
| 92 |
+
dispatch(leader, order, queue);
|
| 93 |
+
return;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
const spots = formationSlots(units, x, y, formation);
|
| 97 |
+
const leaderSpot = spots[0];
|
| 98 |
+
const newFollowers = units.slice(1);
|
| 99 |
+
|
| 100 |
+
// Redefining the squad for exactly this unit set: anyone who WAS following this leader but
|
| 101 |
+
// isn't part of it this time is released β and actually stopped, not just unlinked, so a
|
| 102 |
+
// dropped unit doesn't silently keep chasing a leader that no longer lists it.
|
| 103 |
+
if (leader.squadFollowers) {
|
| 104 |
+
for (const old of leader.squadFollowers) {
|
| 105 |
+
if (!newFollowers.includes(old)) { setSquadLeader(old, null); old.order = null; }
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
leader.squadFollowers = newFollowers;
|
| 109 |
+
|
| 110 |
+
const groupSpeed = units.reduce((m, u) => Math.min(m, UNITS[u.type]?.speed ?? Infinity), Infinity);
|
| 111 |
+
const leaderOrder = makeLeaderOrder(leaderSpot);
|
| 112 |
+
if (Number.isFinite(groupSpeed)) leaderOrder.speedCap = groupSpeed;
|
| 113 |
+
dispatch(leader, leaderOrder, queue);
|
| 114 |
+
|
| 115 |
+
for (let i = 1; i < units.length; i++) {
|
| 116 |
+
setSquadLeader(units[i], leader);
|
| 117 |
+
dispatch(units[i], {
|
| 118 |
+
type: "follow-leader", leader,
|
| 119 |
+
offsetX: spots[i].x - leaderSpot.x, offsetY: spots[i].y - leaderSpot.y,
|
| 120 |
+
}, queue);
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// A click-and-drag command's drag vector (formation.headingX/headingY) is also the direction
|
| 125 |
+
// the player wants the group to FACE β not just how the shape is oriented while travelling.
|
| 126 |
+
// Stamped onto every commanded unit as `facing` (read by renderShared.js's updateFacing once a
|
| 127 |
+
// unit stops moving, overriding the angle it would otherwise just freeze at) β the "give a
|
| 128 |
+
// formation, or a single/multiple selection, a direction to face" ask (e.g. holding a line
|
| 129 |
+
// facing the enemy). A PLAIN command (a click, no drag β no explicit heading) clears any
|
| 130 |
+
// earlier facing instead, so it never lingers stale once the player stops aiming their orders.
|
| 131 |
+
function applyFacing(units, formation) {
|
| 132 |
+
const hx = formation?.headingX || 0, hy = formation?.headingY || 0;
|
| 133 |
+
if (hx || hy) {
|
| 134 |
+
const angle = Math.atan2(hy, hx);
|
| 135 |
+
for (const u of units) u.facing = angle;
|
| 136 |
+
} else {
|
| 137 |
+
for (const u of units) delete u.facing;
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// `formation` ({shape, leaderPos, headingX, headingY}, engine/formation.js) is optional β
|
| 142 |
+
// omitted, it's the original flat grid spread (byte-identical to the old behaviour), unchanged
|
| 143 |
+
// for any caller that doesn't pass one (the AI, and every pre-existing test).
|
| 144 |
+
export function issueMove(units, x, y, queue = false, formation) {
|
| 145 |
+
if (!units.length) return;
|
| 146 |
+
dispatchFormation(units, x, y, formation, queue, pt => ({ type: "move", x: pt.x, y: pt.y }));
|
| 147 |
+
applyFacing(units, formation);
|
| 148 |
}
|
| 149 |
|
| 150 |
export function issueGather(units, nodeId, queue = false) {
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
export function issueAttackMove(units, x, y, queue = false, formation) {
|
| 178 |
+
if (!units.length) return;
|
| 179 |
+
dispatchFormation(units, x, y, formation, queue, pt => ({ type: "attack-move", x: pt.x, y: pt.y }));
|
| 180 |
+
applyFacing(units, formation);
|
| 181 |
}
|
| 182 |
|
| 183 |
// Escort a friendly ship: each unit takes a stable slot on a protective ring around the
|
|
|
|
| 191 |
}
|
| 192 |
|
| 193 |
// Protection of a FORMATION, not one external ship: the group forms up in the chosen shape
|
| 194 |
+
// right where it's standing (its own current centroid) and holds there β the LEADER holds its
|
| 195 |
+
// own fixed anchor (engine/movement.js keepFormationStation), everyone else follows it there and
|
| 196 |
+
// keeps station relative to it (keepFollowingLeader) exactly like a hold-formation-flavoured
|
| 197 |
+
// move, so a later solo command to just the leader picks the whole formation back up too (see
|
| 198 |
+
// dispatchFormation). Combat units also take the Hold stance (issueHold's existing "stand fast,
|
| 199 |
+
// don't chase" rule) β LEADER AND FOLLOWERS alike β so the whole formation keeps its shape
|
| 200 |
+
// instead of scattering to run down a distant target; non-combat units (workers, a Mender) still
|
| 201 |
+
// take a slot β sheltering behind/inside the line β they just have no stance flag to set.
|
|
|
|
| 202 |
export function issueHoldFormation(units, shape = "grid", leaderPos = "front") {
|
| 203 |
if (!units.length) return;
|
| 204 |
let sx = 0, sy = 0;
|
| 205 |
for (const u of units) { sx += u.x; sy += u.y; }
|
| 206 |
const anchorX = sx / units.length, anchorY = sy / units.length;
|
| 207 |
+
dispatchFormation(units, anchorX, anchorY, { shape, leaderPos, originX: anchorX, originY: anchorY }, false,
|
| 208 |
+
pt => ({ type: "hold-formation", anchorX: pt.x, anchorY: pt.y, offsetX: 0, offsetY: 0 }));
|
| 209 |
+
for (const u of units) if (UNITS[u.type] && UNITS[u.type].role === "combat") u.hold = true;
|
|
|
|
|
|
|
| 210 |
}
|
| 211 |
|
| 212 |
// Pays the cost up front, drops a constructing building on the spot, and
|
|
|
|
| 230 |
payCost(player.resources, def.cost);
|
| 231 |
const building = makeBuilding(buildingType, worker.owner, x, y, { constructing: true });
|
| 232 |
state.buildings.set(building.id, building);
|
| 233 |
+
setSquadLeader(worker, null); // a direct order releases the worker from any squad it was following
|
| 234 |
worker.order = { type: "build", buildingId: building.id };
|
| 235 |
return building.id;
|
| 236 |
}
|
|
|
|
| 246 |
// where it stands. A combat unit still defends itself (it re-auto-acquires an
|
| 247 |
// enemy that wanders into range next tick), but it won't chase or resume a
|
| 248 |
// path β the standard RTS "stop" that pulls a unit out of a move or a chain.
|
| 249 |
+
// A direct order like this always releases the unit from a squad it was following.
|
| 250 |
export function issueStop(units) {
|
| 251 |
+
units.forEach(u => { setSquadLeader(u, null); u.order = null; u.orderQueue = []; u.hold = false; });
|
| 252 |
}
|
| 253 |
|
| 254 |
// Hold position: a combat unit stands its ground and fires on anything that
|
|
|
|
| 257 |
// holding a line or a choke. Any move/attack order, or Stop, clears it.
|
| 258 |
export function issueHold(units) {
|
| 259 |
units.forEach(u => {
|
| 260 |
+
if (UNITS[u.type] && UNITS[u.type].role === "combat") { setSquadLeader(u, null); u.order = null; u.orderQueue = []; u.hold = true; }
|
| 261 |
});
|
| 262 |
}
|
| 263 |
|
|
|
|
| 268 |
// a plain command β the mode is persistent, not something to stack behind.
|
| 269 |
export function issueScout(units) {
|
| 270 |
units.forEach(u => {
|
| 271 |
+
if (UNITS[u.type] && UNITS[u.type].role === "scout") { setSquadLeader(u, null); u.order = { type: "scout" }; u.orderQueue = []; }
|
| 272 |
});
|
| 273 |
}
|
| 274 |
|
|
@@ -18,20 +18,28 @@
|
|
| 18 |
explicitly asked for), or takes a point ON the ring at the
|
| 19 |
front/back with everyone else filling the rest evenly
|
| 20 |
|
| 21 |
-
The "leader" is
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
NESTED formations: a selection that's ALREADY spread into distinct spatial
|
| 26 |
clusters (e.g. two armies on opposite sides of the map, box-selected
|
| 27 |
together) is laid out as a formation OF formations β clusterUnits splits
|
| 28 |
-
it
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
|
| 36 |
Spacing is derived from the group's own hull sizes (spacingFor), not a
|
| 37 |
flat constant, EXCEPT the plain single-group "grid" path, which keeps the
|
|
@@ -69,17 +77,11 @@ function centroid(units) {
|
|
| 69 |
return { x: sx / units.length, y: sy / units.length };
|
| 70 |
}
|
| 71 |
|
| 72 |
-
// The
|
| 73 |
-
//
|
| 74 |
-
// player happened to box-select it).
|
| 75 |
/** @param {Unit[]} units @returns {Unit} */
|
| 76 |
export function pickLeader(units) {
|
| 77 |
-
|
| 78 |
-
for (const u of units) {
|
| 79 |
-
const bhp = best.maxHp ?? 0, uhp = u.maxHp ?? 0;
|
| 80 |
-
if (uhp > bhp || (uhp === bhp && u.id < best.id)) best = u;
|
| 81 |
-
}
|
| 82 |
-
return best;
|
| 83 |
}
|
| 84 |
|
| 85 |
// Radius-aware baseline spacing for a group: room for the two largest hulls present to clear
|
|
@@ -96,6 +98,11 @@ function spacingFor(units) {
|
|
| 96 |
// other (transitively) land in the same cluster β a plain union-find pass over CURRENT
|
| 97 |
// positions. A single blob of units (the common case) collapses to one cluster. O(n^2) on the
|
| 98 |
// selection size, which is issue-time work on a small array, not a per-tick cost.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
/** @param {Unit[]} units @returns {Unit[][]} */
|
| 100 |
export function clusterUnits(units) {
|
| 101 |
const n = units.length;
|
|
@@ -186,12 +193,13 @@ function shapeOffsets(n, shape, leaderPos, spacing) {
|
|
| 186 |
return centerOffsets(raw);
|
| 187 |
}
|
| 188 |
|
| 189 |
-
// Lay out `members` (units OR cluster-arrays
|
| 190 |
-
//
|
| 191 |
-
//
|
| 192 |
-
//
|
| 193 |
-
//
|
| 194 |
-
|
|
|
|
| 195 |
const n = members.length;
|
| 196 |
if (n === 1) return [{ x: cx, y: cy }];
|
| 197 |
const spacing = spacingOf(members);
|
|
@@ -208,33 +216,28 @@ function layout(members, cx, cy, headingX, headingY, shape, leaderPos, spacingOf
|
|
| 208 |
const hlen = Math.hypot(hx, hy);
|
| 209 |
if (hlen < 1e-6) { hx = 1; hy = 0; } else { hx /= hlen; hy /= hlen; }
|
| 210 |
|
| 211 |
-
// Leader-first slot order: which member sits in raw[0] (the shape's leader treatment) β most
|
| 212 |
-
// weight wins, ties by id β same rule as pickLeader, generalised to non-Unit members (clusters).
|
| 213 |
-
const order = members.map((_, i) => i).sort((ia, ib) => {
|
| 214 |
-
const wa = weightOf(members[ia]), wb = weightOf(members[ib]);
|
| 215 |
-
if (wa !== wb) return wb - wa;
|
| 216 |
-
const a = idOf(members[ia]), b = idOf(members[ib]);
|
| 217 |
-
return a < b ? -1 : (a > b ? 1 : 0);
|
| 218 |
-
});
|
| 219 |
-
|
| 220 |
const raw = shapeOffsets(n, shape, leaderPos, spacing);
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
out[memberIdx] = { x: cx + forward * hx + right * hy, y: cy + forward * hy - right * hx };
|
| 225 |
});
|
| 226 |
-
return out;
|
| 227 |
}
|
| 228 |
|
| 229 |
/**
|
| 230 |
* Where each unit in `units` should stand for a move to (destX,destY) β or, for a stationary
|
| 231 |
* hold, where the group's own current centroid already is (pass it as both the destination AND
|
| 232 |
-
* `originX`/`originY`, or simply omit `originX`/`originY` and let it default there).
|
| 233 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
* @param {Unit[]} units
|
| 235 |
* @param {number} destX
|
| 236 |
* @param {number} destY
|
| 237 |
-
* @param {{shape?:string, leaderPos?:string, originX?:number, originY?:number}} [opts]
|
| 238 |
* @returns {{x:number, y:number}[]}
|
| 239 |
*/
|
| 240 |
export function formationSlots(units, destX, destY, opts = {}) {
|
|
@@ -243,19 +246,22 @@ export function formationSlots(units, destX, destY, opts = {}) {
|
|
| 243 |
const { shape = "grid", leaderPos = "front" } = opts || {};
|
| 244 |
if (n === 1) return [{ x: destX, y: destY }];
|
| 245 |
|
| 246 |
-
let
|
| 247 |
-
if (!
|
| 248 |
-
|
| 249 |
-
originX
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
}
|
| 251 |
-
const headingX = destX - originX, headingY = destY - originY;
|
| 252 |
|
| 253 |
const clusters = clusterUnits(units);
|
| 254 |
if (clusters.length <= 1) {
|
| 255 |
// The one path that must stay byte-identical to the old flat grid spread: no shape chosen,
|
| 256 |
// one blob of units β the legacy formationSpots' exact spacing constant, untouched.
|
| 257 |
const spacingOf = shape === "grid" ? () => GRID_SPACING : spacingFor;
|
| 258 |
-
return layout(units, destX, destY, headingX, headingY, shape, leaderPos, spacingOf
|
| 259 |
}
|
| 260 |
|
| 261 |
// Nested: a formation OF formations. Each cluster is one outer slot, arranged by the SAME
|
|
@@ -263,17 +269,13 @@ export function formationSlots(units, destX, destY, opts = {}) {
|
|
| 263 |
// footprint so sub-formations can't overlap ("sufficient spacing⦠in formation of formations").
|
| 264 |
const footprints = clusters.map(c => Math.max(1, Math.ceil(Math.sqrt(c.length))) * spacingFor(c));
|
| 265 |
const outerSpacing = Math.max(...footprints) * NEST_GAP_MULT;
|
| 266 |
-
const
|
| 267 |
-
const centers = layout(
|
| 268 |
-
clusters, destX, destY, headingX, headingY, shape, leaderPos,
|
| 269 |
-
() => outerSpacing, clusterWeight, c => c[0].id,
|
| 270 |
-
);
|
| 271 |
|
| 272 |
const placed = new Map();
|
| 273 |
clusters.forEach((cluster, i) => {
|
| 274 |
const { x: ccx, y: ccy } = centers[i];
|
| 275 |
const cc = centroid(cluster);
|
| 276 |
-
const inner = layout(cluster, ccx, ccy, ccx - cc.x, ccy - cc.y, shape, leaderPos, spacingFor
|
| 277 |
cluster.forEach((u, k) => placed.set(u, inner[k]));
|
| 278 |
});
|
| 279 |
return units.map(u => placed.get(u));
|
|
|
|
| 18 |
explicitly asked for), or takes a point ON the ring at the
|
| 19 |
front/back with everyone else filling the rest evenly
|
| 20 |
|
| 21 |
+
The "leader" is whichever unit is FIRST in the `units` array β i.e.
|
| 22 |
+
whichever unit the player selected first and added the rest to (the
|
| 23 |
+
normal box-select-additive selection order preserves this: an existing
|
| 24 |
+
selection is kept first, newly-added picks are appended after β see
|
| 25 |
+
input.js's applyBoxSelection). Deliberately NOT a stat-based pick (e.g.
|
| 26 |
+
highest maxHp): the player chooses their leader by building the
|
| 27 |
+
selection around it, the same unit engine/commands.js then keeps giving
|
| 28 |
+
its own real order to while everyone else follows it (issueHoldFormation
|
| 29 |
+
/ follow-leader β see commands.js and movement.js).
|
| 30 |
|
| 31 |
NESTED formations: a selection that's ALREADY spread into distinct spatial
|
| 32 |
clusters (e.g. two armies on opposite sides of the map, box-selected
|
| 33 |
together) is laid out as a formation OF formations β clusterUnits splits
|
| 34 |
+
it (preserving each unit's relative position in the original array, so
|
| 35 |
+
the cluster CONTAINING the overall leader is always clusters[0] and thus
|
| 36 |
+
still gets the outer leader slot), the SAME shape arranges the clusters'
|
| 37 |
+
centres relative to each other (a wedge of wedges, a line of lines, β¦),
|
| 38 |
+
spaced by each cluster's own footprint so sub-formations can't overlap,
|
| 39 |
+
and each cluster is then laid out again internally around its assigned
|
| 40 |
+
centre. A single blob of units β the ordinary case, everyone selected in
|
| 41 |
+
one place β collapses to exactly ONE cluster, so nesting only ever
|
| 42 |
+
engages when the selection already calls for it.
|
| 43 |
|
| 44 |
Spacing is derived from the group's own hull sizes (spacingFor), not a
|
| 45 |
flat constant, EXCEPT the plain single-group "grid" path, which keeps the
|
|
|
|
| 77 |
return { x: sx / units.length, y: sy / units.length };
|
| 78 |
}
|
| 79 |
|
| 80 |
+
// The leader is simply whichever unit is first in the given array β see the file header. A
|
| 81 |
+
// one-line function so callers (engine/commands.js) don't have to know/duplicate the rule.
|
|
|
|
| 82 |
/** @param {Unit[]} units @returns {Unit} */
|
| 83 |
export function pickLeader(units) {
|
| 84 |
+
return units[0];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
// Radius-aware baseline spacing for a group: room for the two largest hulls present to clear
|
|
|
|
| 98 |
// other (transitively) land in the same cluster β a plain union-find pass over CURRENT
|
| 99 |
// positions. A single blob of units (the common case) collapses to one cluster. O(n^2) on the
|
| 100 |
// selection size, which is issue-time work on a small array, not a per-tick cost.
|
| 101 |
+
//
|
| 102 |
+
// Preserves each unit's relative order from the input array within its cluster, AND across
|
| 103 |
+
// clusters (the cluster containing units[0] is always clusters[0]) β both `layout` calls below
|
| 104 |
+
// rely on member[0] being the leader, so this ordering guarantee is what keeps the overall
|
| 105 |
+
// leader's own cluster the "leader cluster" of the outer formation too.
|
| 106 |
/** @param {Unit[]} units @returns {Unit[][]} */
|
| 107 |
export function clusterUnits(units) {
|
| 108 |
const n = units.length;
|
|
|
|
| 193 |
return centerOffsets(raw);
|
| 194 |
}
|
| 195 |
|
| 196 |
+
// Lay out `members` (units OR cluster-arrays) into `shape`, oriented by (headingX,headingY),
|
| 197 |
+
// centred on (cx,cy). Returns points in the SAME order as `members` β member[0] (the leader,
|
| 198 |
+
// see the file header) always takes the shape's own leader slot (raw[0]). `shape==="grid"`
|
| 199 |
+
// ignores heading entirely (world-axis-aligned, exactly the legacy behaviour); every other
|
| 200 |
+
// shape orients its forward axis along the heading (or a stable default when the group isn't
|
| 201 |
+
// actually travelling β e.g. re-forming in place).
|
| 202 |
+
function layout(members, cx, cy, headingX, headingY, shape, leaderPos, spacingOf) {
|
| 203 |
const n = members.length;
|
| 204 |
if (n === 1) return [{ x: cx, y: cy }];
|
| 205 |
const spacing = spacingOf(members);
|
|
|
|
| 216 |
const hlen = Math.hypot(hx, hy);
|
| 217 |
if (hlen < 1e-6) { hx = 1; hy = 0; } else { hx /= hlen; hy /= hlen; }
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
const raw = shapeOffsets(n, shape, leaderPos, spacing);
|
| 220 |
+
return members.map((_, i) => {
|
| 221 |
+
const { forward, right } = raw[i];
|
| 222 |
+
return { x: cx + forward * hx + right * hy, y: cy + forward * hy - right * hx };
|
|
|
|
| 223 |
});
|
|
|
|
| 224 |
}
|
| 225 |
|
| 226 |
/**
|
| 227 |
* Where each unit in `units` should stand for a move to (destX,destY) β or, for a stationary
|
| 228 |
* hold, where the group's own current centroid already is (pass it as both the destination AND
|
| 229 |
+
* `originX`/`originY`, or simply omit `originX`/`originY` and let it default there). `units[0]`
|
| 230 |
+
* is always the leader (see the file header) and always lands on the shape's own leader slot.
|
| 231 |
+
* Returns one {x,y} per unit, in the SAME order as `units`.
|
| 232 |
+
*
|
| 233 |
+
* The heading (which way the shape faces) is normally derived from `origin -> dest`, but a
|
| 234 |
+
* player can instead set it explicitly β e.g. a click-and-drag command, where the drag vector
|
| 235 |
+
* IS the desired facing regardless of how far the destination actually is β via `headingX`/
|
| 236 |
+
* `headingY`; either non-zero overrides the origin-derived heading entirely.
|
| 237 |
* @param {Unit[]} units
|
| 238 |
* @param {number} destX
|
| 239 |
* @param {number} destY
|
| 240 |
+
* @param {{shape?:string, leaderPos?:string, originX?:number, originY?:number, headingX?:number, headingY?:number}} [opts]
|
| 241 |
* @returns {{x:number, y:number}[]}
|
| 242 |
*/
|
| 243 |
export function formationSlots(units, destX, destY, opts = {}) {
|
|
|
|
| 246 |
const { shape = "grid", leaderPos = "front" } = opts || {};
|
| 247 |
if (n === 1) return [{ x: destX, y: destY }];
|
| 248 |
|
| 249 |
+
let headingX = opts?.headingX, headingY = opts?.headingY;
|
| 250 |
+
if (!((headingX || 0) || (headingY || 0))) {
|
| 251 |
+
let { originX, originY } = opts || {};
|
| 252 |
+
if (!Number.isFinite(originX) || !Number.isFinite(originY)) {
|
| 253 |
+
const c = centroid(units);
|
| 254 |
+
originX = c.x; originY = c.y;
|
| 255 |
+
}
|
| 256 |
+
headingX = destX - originX; headingY = destY - originY;
|
| 257 |
}
|
|
|
|
| 258 |
|
| 259 |
const clusters = clusterUnits(units);
|
| 260 |
if (clusters.length <= 1) {
|
| 261 |
// The one path that must stay byte-identical to the old flat grid spread: no shape chosen,
|
| 262 |
// one blob of units β the legacy formationSpots' exact spacing constant, untouched.
|
| 263 |
const spacingOf = shape === "grid" ? () => GRID_SPACING : spacingFor;
|
| 264 |
+
return layout(units, destX, destY, headingX, headingY, shape, leaderPos, spacingOf);
|
| 265 |
}
|
| 266 |
|
| 267 |
// Nested: a formation OF formations. Each cluster is one outer slot, arranged by the SAME
|
|
|
|
| 269 |
// footprint so sub-formations can't overlap ("sufficient spacing⦠in formation of formations").
|
| 270 |
const footprints = clusters.map(c => Math.max(1, Math.ceil(Math.sqrt(c.length))) * spacingFor(c));
|
| 271 |
const outerSpacing = Math.max(...footprints) * NEST_GAP_MULT;
|
| 272 |
+
const centers = layout(clusters, destX, destY, headingX, headingY, shape, leaderPos, () => outerSpacing);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
const placed = new Map();
|
| 275 |
clusters.forEach((cluster, i) => {
|
| 276 |
const { x: ccx, y: ccy } = centers[i];
|
| 277 |
const cc = centroid(cluster);
|
| 278 |
+
const inner = layout(cluster, ccx, ccy, ccx - cc.x, ccy - cc.y, shape, leaderPos, spacingFor);
|
| 279 |
cluster.forEach((u, k) => placed.set(u, inner[k]));
|
| 280 |
});
|
| 281 |
return units.map(u => placed.get(u));
|
|
@@ -70,16 +70,48 @@ export function keepEscortStation(state, unit, speed, dt) {
|
|
| 70 |
return true;
|
| 71 |
}
|
| 72 |
|
| 73 |
-
// Advance a
|
| 74 |
-
// offsetX, offsetY} β engine/commands.js issueHoldFormation) one tick toward its
|
| 75 |
-
//
|
| 76 |
-
//
|
| 77 |
-
//
|
| 78 |
-
// and drop the order the way an escort's target can; it
|
|
|
|
| 79 |
/** @param {State} state @param {Unit} unit @param {number} speed @param {number} dt */
|
| 80 |
export function keepFormationStation(state, unit, speed, dt) {
|
| 81 |
const o = unit.order;
|
| 82 |
-
stepToward(state, unit, o.anchorX + o.offsetX, o.anchorY + o.offsetY, speed, dt);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
// Moves `unit` at most `speed * dt` toward (tx, ty). Returns true once it
|
|
|
|
| 70 |
return true;
|
| 71 |
}
|
| 72 |
|
| 73 |
+
// Advance a formation LEADER holding station (order {type:"hold-formation", anchorX, anchorY,
|
| 74 |
+
// offsetX, offsetY} β engine/commands.js issueHoldFormation) one tick toward its own fixed
|
| 75 |
+
// anchor point. Unlike escort's ring (which re-seeks a MOVING external target every tick), the
|
| 76 |
+
// anchor here never moves β that's the point: this is a unit (or a formation's leader β see
|
| 77 |
+
// keepFollowingLeader below for everyone else in the group) defending its own position, not
|
| 78 |
+
// following anyone. Nothing can "vanish" and drop the order the way an escort's target can; it
|
| 79 |
+
// persists until a new order replaces it.
|
| 80 |
/** @param {State} state @param {Unit} unit @param {number} speed @param {number} dt */
|
| 81 |
export function keepFormationStation(state, unit, speed, dt) {
|
| 82 |
const o = unit.order;
|
| 83 |
+
stepToward(state, unit, o.anchorX + o.offsetX, o.anchorY + o.offsetY, orderedSpeed(speed, o), dt);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Advance a formation FOLLOWER (order {type:"follow-leader", leader, offsetX, offsetY} β
|
| 87 |
+
// engine/commands.js dispatchFormation) one tick toward a fixed offset from its leader's LIVE
|
| 88 |
+
// position β re-seeked every tick, exactly like escort's ring around its guarded ship, except
|
| 89 |
+
// the "target" is another player unit that itself is under direct orders (move/attack-move/
|
| 90 |
+
// hold-formation) rather than just standing still. This is what makes "select the leader alone
|
| 91 |
+
// and move it" drag the rest of the formation along for free: the leader gets a fresh order,
|
| 92 |
+
// the followers were never re-ordered at all β they're still chasing `leader.pos + offset`
|
| 93 |
+
// every tick, whatever that position now is. Drops the order (returns to independent control)
|
| 94 |
+
// the moment the leader dies, the same as an escort losing its guarded ship.
|
| 95 |
+
/** @param {State} state @param {Unit} unit @param {number} speed @param {number} dt @returns {boolean} */
|
| 96 |
+
export function keepFollowingLeader(state, unit, speed, dt) {
|
| 97 |
+
const o = unit.order;
|
| 98 |
+
const leader = o.leader;
|
| 99 |
+
if (!leader || leader.hp <= 0) { unit.order = null; return false; }
|
| 100 |
+
stepToward(state, unit, leader.x + o.offsetX, leader.y + o.offsetY, speed, dt);
|
| 101 |
+
return true;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// A formation's overall pace is capped to its SLOWEST member (order.speedCap, stamped by
|
| 105 |
+
// engine/commands.js's dispatchFormation) so a fast leader can't outrun the group it's
|
| 106 |
+
// supposedly leading β without this, followers (who each move at their OWN top speed toward the
|
| 107 |
+
// leader's live position) would perpetually lag behind while the leader is in motion, stretching
|
| 108 |
+
// the shape out instead of holding it. Followers never need this themselves: every follower's own
|
| 109 |
+
// speed is, by construction, at least the group minimum, so they can always keep pace with a
|
| 110 |
+
// leader that's capped to it. Identity (returns `speed` unchanged) for any order without a cap β
|
| 111 |
+
// every order type except move/attack-move/hold-formation, and those two before this feature.
|
| 112 |
+
/** @param {number} speed @param {Order} order @returns {number} */
|
| 113 |
+
export function orderedSpeed(speed, order) {
|
| 114 |
+
return Number.isFinite(order?.speedCap) ? Math.min(speed, order.speedCap) : speed;
|
| 115 |
}
|
| 116 |
|
| 117 |
// Moves `unit` at most `speed * dt` toward (tx, ty). Returns true once it
|
|
@@ -159,6 +159,11 @@ function cleanEntity(e, def, map) {
|
|
| 159 |
// value can't win the "most recently used pad" tie-break it's only ever compared with. Only
|
| 160 |
// present once a jump has actually landed there, so this only fires when the field exists.
|
| 161 |
if (e.type === "spaceport" && e.lastLanding !== undefined) e.lastLanding = Math.max(0, num(e.lastLanding, 0));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
// A producer's output buffer (building.store) and a factory's input buffer (building.input) are
|
| 163 |
// untrusted save data β a hand-edited file could smuggle in a bogus commodity, a negative/NaN qty,
|
| 164 |
// or an over-capacity buffer. Keep only real commodities with a positive qty and clamp each buffer's
|
|
@@ -282,7 +287,16 @@ function serPlanet(state) {
|
|
| 282 |
// `_gi` is the grid broad-phase index β a transient stamped fresh onto every unit each tick
|
| 283 |
// by buildUnitGrid, meaningless once saved. Strip it so it doesn't bloat the payload with a
|
| 284 |
// per-unit integer that the next tick overwrites anyway. Shallow copy, only at save time.
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
// `haulers`/`servers` (logistics tallies, engine/haul.js), `powered`/`fuel` (Generator fuel
|
| 287 |
// state, engine/industry.js), `menderClaims` (auto-repair Mender tally, engine/sim.js) and
|
| 288 |
// `lastYield`/`lastTier` (a Plasma Rig's last-strike HUD readout, engine/rig.js β regenerated on
|
|
|
|
| 159 |
// value can't win the "most recently used pad" tie-break it's only ever compared with. Only
|
| 160 |
// present once a jump has actually landed there, so this only fires when the field exists.
|
| 161 |
if (e.type === "spaceport" && e.lastLanding !== undefined) e.lastLanding = Math.max(0, num(e.lastLanding, 0));
|
| 162 |
+
// A unit's player-set `facing` (engine/commands.js applyFacing) is untrusted too β a bogus
|
| 163 |
+
// non-numeric value would flow straight into Math.cos/sin (renderShared.js) as NaN, silently
|
| 164 |
+
// mis-drawing (never crashing) the sprite. Coerce it to a finite number, or drop the field
|
| 165 |
+
// entirely so the unit just falls back to its normal movement-inferred facing.
|
| 166 |
+
if (e.facing !== undefined) { const f = num(e.facing, NaN); if (Number.isFinite(f)) e.facing = f; else delete e.facing; }
|
| 167 |
// A producer's output buffer (building.store) and a factory's input buffer (building.input) are
|
| 168 |
// untrusted save data β a hand-edited file could smuggle in a bogus commodity, a negative/NaN qty,
|
| 169 |
// or an over-capacity buffer. Keep only real commodities with a positive qty and clamp each buffer's
|
|
|
|
| 287 |
// `_gi` is the grid broad-phase index β a transient stamped fresh onto every unit each tick
|
| 288 |
// by buildUnitGrid, meaningless once saved. Strip it so it doesn't bloat the payload with a
|
| 289 |
// per-unit integer that the next tick overwrites anyway. Shallow copy, only at save time.
|
| 290 |
+
// `squadLeader`/`squadFollowers` (engine/commands.js) are live UNIT OBJECT REFERENCES, not
|
| 291 |
+
// ids β session-only, transient bookkeeping like the rest of this denylist, but they ALSO
|
| 292 |
+
// can't survive JSON.stringify at all: a follower points at its leader, the leader's own
|
| 293 |
+
// squadFollowers list points right back, and JSON.stringify throws on a circular structure
|
| 294 |
+
// rather than silently truncating it β a live squad would crash every save. Strip both
|
| 295 |
+
// fields, and null out a live `follow-leader` order too (it embeds the same leader
|
| 296 |
+
// reference) β a reload simply drops squad membership, the same "recomputed/reset, never
|
| 297 |
+
// persisted" treatment repairTargetId already gets.
|
| 298 |
+
units: [...state.units.values()].map(({ _gi, repairTargetId, squadLeader, squadFollowers, ...u }) =>
|
| 299 |
+
u.order && u.order.type === "follow-leader" ? { ...u, order: null } : u),
|
| 300 |
// `haulers`/`servers` (logistics tallies, engine/haul.js), `powered`/`fuel` (Generator fuel
|
| 301 |
// state, engine/industry.js), `menderClaims` (auto-repair Mender tally, engine/sim.js) and
|
| 302 |
// `lastYield`/`lastTier` (a Plasma Rig's last-strike HUD readout, engine/rig.js β regenerated on
|
|
@@ -6,7 +6,7 @@
|
|
| 6 |
|
| 7 |
"use strict";
|
| 8 |
|
| 9 |
-
import { stepToward, keepEscortStation, keepFormationStation } from "./movement.js";
|
| 10 |
import { buildUnitGrid } from "./grid.js";
|
| 11 |
import { updateGather } from "./gather.js";
|
| 12 |
import { updateHaul, assignHaul, updateService, assignService, countLogistics } from "./haul.js";
|
|
@@ -160,7 +160,7 @@ function updateUnit(state, unit, dt) {
|
|
| 160 |
if (!unit.order) return;
|
| 161 |
switch (unit.order.type) {
|
| 162 |
case "move": {
|
| 163 |
-
const arrived = stepToward(state, unit, unit.order.x, unit.order.y, def.speed, dt);
|
| 164 |
if (arrived) unit.order = null;
|
| 165 |
break;
|
| 166 |
}
|
|
@@ -178,9 +178,13 @@ function updateUnit(state, unit, dt) {
|
|
| 178 |
keepEscortStation(state, unit, def.speed, dt);
|
| 179 |
break;
|
| 180 |
case "hold-formation":
|
| 181 |
-
// A non-combat
|
| 182 |
keepFormationStation(state, unit, def.speed, dt);
|
| 183 |
break;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
case "scout":
|
| 185 |
updateScoutMode(state, unit, dt);
|
| 186 |
break;
|
|
@@ -235,23 +239,28 @@ function updateSupport(state, unit, def, dt) {
|
|
| 235 |
keepEscortStation(state, unit, def.speed, dt);
|
| 236 |
return;
|
| 237 |
}
|
| 238 |
-
// Holding a defensive formation: same idea, station-keeping at
|
| 239 |
-
// moving target β the drone mends whatever's near it (the repair pass) right where it stands.
|
| 240 |
if (o.type === "hold-formation") {
|
| 241 |
keepFormationStation(state, unit, def.speed, dt);
|
| 242 |
return;
|
| 243 |
}
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
if (o.type === "attack") {
|
| 246 |
const t = getEntity(state, o.targetId);
|
| 247 |
if (!t || t.hp <= 0) { unit.order = null; return; }
|
| 248 |
tx = t.x; ty = t.y; follow = true; // keep chasing a moving foe rather than stopping on arrival
|
| 249 |
} else if (o.type === "move" || o.type === "attack-move") {
|
| 250 |
-
tx = o.x; ty = o.y;
|
| 251 |
} else {
|
| 252 |
unit.order = null; return; // gather / scout / build are meaningless for a drone
|
| 253 |
}
|
| 254 |
-
const arrived = stepToward(state, unit, tx, ty,
|
| 255 |
if (arrived && !follow) unit.order = null;
|
| 256 |
}
|
| 257 |
|
|
|
|
| 6 |
|
| 7 |
"use strict";
|
| 8 |
|
| 9 |
+
import { stepToward, keepEscortStation, keepFormationStation, keepFollowingLeader, orderedSpeed } from "./movement.js";
|
| 10 |
import { buildUnitGrid } from "./grid.js";
|
| 11 |
import { updateGather } from "./gather.js";
|
| 12 |
import { updateHaul, assignHaul, updateService, assignService, countLogistics } from "./haul.js";
|
|
|
|
| 160 |
if (!unit.order) return;
|
| 161 |
switch (unit.order.type) {
|
| 162 |
case "move": {
|
| 163 |
+
const arrived = stepToward(state, unit, unit.order.x, unit.order.y, orderedSpeed(def.speed, unit.order), dt);
|
| 164 |
if (arrived) unit.order = null;
|
| 165 |
break;
|
| 166 |
}
|
|
|
|
| 178 |
keepEscortStation(state, unit, def.speed, dt);
|
| 179 |
break;
|
| 180 |
case "hold-formation":
|
| 181 |
+
// A non-combat formation LEADER (worker, freighter) holds its own fixed anchor.
|
| 182 |
keepFormationStation(state, unit, def.speed, dt);
|
| 183 |
break;
|
| 184 |
+
case "follow-leader":
|
| 185 |
+
// A non-combat formation FOLLOWER just keeps its slot relative to the leader.
|
| 186 |
+
keepFollowingLeader(state, unit, def.speed, dt);
|
| 187 |
+
break;
|
| 188 |
case "scout":
|
| 189 |
updateScoutMode(state, unit, dt);
|
| 190 |
break;
|
|
|
|
| 239 |
keepEscortStation(state, unit, def.speed, dt);
|
| 240 |
return;
|
| 241 |
}
|
| 242 |
+
// Holding a defensive formation: same idea, station-keeping at its own fixed anchor instead
|
| 243 |
+
// of a moving target β the drone mends whatever's near it (the repair pass) right where it stands.
|
| 244 |
if (o.type === "hold-formation") {
|
| 245 |
keepFormationStation(state, unit, def.speed, dt);
|
| 246 |
return;
|
| 247 |
}
|
| 248 |
+
// Following a formation leader: keeps its slot relative to the leader's LIVE position.
|
| 249 |
+
if (o.type === "follow-leader") {
|
| 250 |
+
keepFollowingLeader(state, unit, def.speed, dt);
|
| 251 |
+
return;
|
| 252 |
+
}
|
| 253 |
+
let tx, ty, follow = false, speed = def.speed;
|
| 254 |
if (o.type === "attack") {
|
| 255 |
const t = getEntity(state, o.targetId);
|
| 256 |
if (!t || t.hp <= 0) { unit.order = null; return; }
|
| 257 |
tx = t.x; ty = t.y; follow = true; // keep chasing a moving foe rather than stopping on arrival
|
| 258 |
} else if (o.type === "move" || o.type === "attack-move") {
|
| 259 |
+
tx = o.x; ty = o.y; speed = orderedSpeed(def.speed, o);
|
| 260 |
} else {
|
| 261 |
unit.order = null; return; // gather / scout / build are meaningless for a drone
|
| 262 |
}
|
| 263 |
+
const arrived = stepToward(state, unit, tx, ty, speed, dt);
|
| 264 |
if (arrived && !follow) unit.order = null;
|
| 265 |
}
|
| 266 |
|
|
@@ -35,7 +35,7 @@
|
|
| 35 |
|
| 36 |
/**
|
| 37 |
* A unit order. `type` is always present; the rest depend on the order kind
|
| 38 |
-
* (move/gather/attack/attack-move/build/escort/scout/hold-formation).
|
| 39 |
* @typedef {Object} Order
|
| 40 |
* @property {string} type
|
| 41 |
* @property {number} [x]
|
|
@@ -48,10 +48,12 @@
|
|
| 48 |
* @property {number} [slots]
|
| 49 |
* @property {string} [phase]
|
| 50 |
* @property {boolean} [manual] a player-assigned service order sticks to its building (engine/haul.js)
|
| 51 |
-
* @property {number} [anchorX] hold-formation:
|
| 52 |
* @property {number} [anchorY]
|
| 53 |
-
* @property {number} [offsetX] hold-formation: this unit's fixed offset from the anchor (engine/formation.js)
|
| 54 |
* @property {number} [offsetY]
|
|
|
|
|
|
|
| 55 |
*/
|
| 56 |
|
| 57 |
/**
|
|
@@ -94,6 +96,9 @@
|
|
| 94 |
* @property {string|null} [targetId] aim target (combat.js / render.js)
|
| 95 |
* @property {Object.<string, number>} [freight] a freighter's player-managed cargo hold, commodity β qty (engine/galaxy.js)
|
| 96 |
* @property {boolean} [armed] a Helium Bomb set to detonate on attack/enemy presence/command (engine/bomb.js)
|
|
|
|
|
|
|
|
|
|
| 97 |
*/
|
| 98 |
|
| 99 |
/**
|
|
|
|
| 35 |
|
| 36 |
/**
|
| 37 |
* A unit order. `type` is always present; the rest depend on the order kind
|
| 38 |
+
* (move/gather/attack/attack-move/build/escort/scout/hold-formation/follow-leader).
|
| 39 |
* @typedef {Object} Order
|
| 40 |
* @property {string} type
|
| 41 |
* @property {number} [x]
|
|
|
|
| 48 |
* @property {number} [slots]
|
| 49 |
* @property {string} [phase]
|
| 50 |
* @property {boolean} [manual] a player-assigned service order sticks to its building (engine/haul.js)
|
| 51 |
+
* @property {number} [anchorX] hold-formation: this unit's own fixed anchor point (engine/commands.js issueHoldFormation)
|
| 52 |
* @property {number} [anchorY]
|
| 53 |
+
* @property {number} [offsetX] hold-formation/follow-leader: this unit's fixed offset from the anchor/leader (engine/formation.js)
|
| 54 |
* @property {number} [offsetY]
|
| 55 |
+
* @property {Unit} [leader] follow-leader: the formation leader this unit is chasing (engine/movement.js keepFollowingLeader) β a live object reference, NEVER persisted (engine/persist.js drops a follow-leader order entirely on save)
|
| 56 |
+
* @property {number} [speedCap] move/attack-move/hold-formation: a formation leader's travel speed, capped to its slowest member (engine/movement.js orderedSpeed)
|
| 57 |
*/
|
| 58 |
|
| 59 |
/**
|
|
|
|
| 96 |
* @property {string|null} [targetId] aim target (combat.js / render.js)
|
| 97 |
* @property {Object.<string, number>} [freight] a freighter's player-managed cargo hold, commodity β qty (engine/galaxy.js)
|
| 98 |
* @property {boolean} [armed] a Helium Bomb set to detonate on attack/enemy presence/command (engine/bomb.js)
|
| 99 |
+
* @property {Unit} [squadLeader] transient, NEVER persisted: the leader this unit is following, if any (engine/commands.js setSquadLeader) β a live object reference
|
| 100 |
+
* @property {Unit[]} [squadFollowers] transient, NEVER persisted: the units following THIS unit as their leader (engine/commands.js dispatchFormation)
|
| 101 |
+
* @property {number} [facing] a player-set facing angle (radians), from a click-and-drag move/attack-move (engine/commands.js applyFacing) β overrides the movement-inferred angle a STATIONARY unit would otherwise freeze at (renderShared.js updateFacing); a plain (non-drag) move/attack-move clears it, so it never lingers stale after a later un-aimed order
|
| 102 |
*/
|
| 103 |
|
| 104 |
/**
|
|
@@ -1122,6 +1122,11 @@ function rebuildSelectionPanel(sel) {
|
|
| 1122 |
head.textContent = "Formation";
|
| 1123 |
panelEl.appendChild(head);
|
| 1124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1125 |
const shapeRow = document.createElement("div");
|
| 1126 |
shapeRow.className = "formation-row";
|
| 1127 |
for (const shape of FORMATION_SHAPES) {
|
|
@@ -1206,7 +1211,7 @@ function rebuildSelectionPanel(sel) {
|
|
| 1206 |
const hint = document.createElement("p");
|
| 1207 |
hint.className = "hint";
|
| 1208 |
hint.textContent = touch ? "Tap the map to move. Tap a foe to attack. Attack-Move advances and engages."
|
| 1209 |
-
: "A + click to attack-move. Right-click moves (ignores enemies). Ctrl+right-click queues a waypoint.";
|
| 1210 |
panelEl.appendChild(hint);
|
| 1211 |
} else if (sel.length === 1 && (cc || barracks)) {
|
| 1212 |
const hint = document.createElement("p");
|
|
@@ -1241,6 +1246,7 @@ function controlsLegend() {
|
|
| 1241 |
] : [
|
| 1242 |
["Left-drag", "select Β· Ctrl+drag adds"],
|
| 1243 |
["Right-click", "move / attack / gather"],
|
|
|
|
| 1244 |
["A + click", "attack-move"],
|
| 1245 |
["Ctrl+right", "queue a waypoint"],
|
| 1246 |
["Shift+1β9", "set group Β· 1β9 recall"],
|
|
|
|
| 1122 |
head.textContent = "Formation";
|
| 1123 |
panelEl.appendChild(head);
|
| 1124 |
|
| 1125 |
+
const leaderHint = document.createElement("p");
|
| 1126 |
+
leaderHint.className = "hint";
|
| 1127 |
+
leaderHint.textContent = "The first unit you selected leads β the rest follow it, even on a later solo move.";
|
| 1128 |
+
panelEl.appendChild(leaderHint);
|
| 1129 |
+
|
| 1130 |
const shapeRow = document.createElement("div");
|
| 1131 |
shapeRow.className = "formation-row";
|
| 1132 |
for (const shape of FORMATION_SHAPES) {
|
|
|
|
| 1211 |
const hint = document.createElement("p");
|
| 1212 |
hint.className = "hint";
|
| 1213 |
hint.textContent = touch ? "Tap the map to move. Tap a foe to attack. Attack-Move advances and engages."
|
| 1214 |
+
: "A + click to attack-move. Right-click moves (ignores enemies). Right-drag also sets a facing. Ctrl+right-click queues a waypoint.";
|
| 1215 |
panelEl.appendChild(hint);
|
| 1216 |
} else if (sel.length === 1 && (cc || barracks)) {
|
| 1217 |
const hint = document.createElement("p");
|
|
|
|
| 1246 |
] : [
|
| 1247 |
["Left-drag", "select Β· Ctrl+drag adds"],
|
| 1248 |
["Right-click", "move / attack / gather"],
|
| 1249 |
+
["Right-drag", "β¦and face that direction"],
|
| 1250 |
["A + click", "attack-move"],
|
| 1251 |
["Ctrl+right", "queue a waypoint"],
|
| 1252 |
["Shift+1β9", "set group Β· 1β9 recall"],
|
|
@@ -38,6 +38,7 @@ export function attachInput(canvas, state, onChange) {
|
|
| 38 |
const heldKeys = new Set();
|
| 39 |
|
| 40 |
let dragBox = null;
|
|
|
|
| 41 |
let buildMode = null;
|
| 42 |
let attackMoveArmed = false; // set by the A key; the next left-click issues an attack-move
|
| 43 |
let lastWorldPos = { x: state.map.width / 2, y: state.map.height / 2 };
|
|
@@ -86,17 +87,25 @@ export function attachInput(canvas, state, onChange) {
|
|
| 86 |
|
| 87 |
// The player's current formation choice (session.js), read fresh on every order β so
|
| 88 |
// switching shapes in the HUD mid-game takes effect on the very next command issued.
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
|
| 93 |
// Attack-move to (x,y): combat units advance-and-engage anything met on the
|
| 94 |
// way; non-combat units (workers, the Ranger) can't attack-move, so they just
|
| 95 |
// move. Same split the Ctrl-queue and minimap-command paths use.
|
| 96 |
-
function aggressiveMove(units, x, y, queue = false) {
|
| 97 |
const combatants = units.filter(u => UNITS[u.type].role === "combat");
|
| 98 |
const others = units.filter(u => UNITS[u.type].role !== "combat");
|
| 99 |
-
const formation = currentFormation();
|
| 100 |
if (combatants.length) issueAttackMove(combatants, x, y, queue, formation);
|
| 101 |
if (others.length) issueMove(others, x, y, queue, formation);
|
| 102 |
}
|
|
@@ -173,7 +182,12 @@ export function attachInput(canvas, state, onChange) {
|
|
| 173 |
// a single selected production building sets its rally; otherwise the current
|
| 174 |
// unit selection assists-builds, attacks, gathers, or moves as the target
|
| 175 |
// warrants. `queue` chains it as a waypoint instead of replacing the order.
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
if (state.selection.length === 1) {
|
| 178 |
const building = state.buildings.get(state.selection[0]);
|
| 179 |
if (building && building.owner === "player" && BUILDINGS[building.type].produces) {
|
|
@@ -220,13 +234,19 @@ export function attachInput(canvas, state, onChange) {
|
|
| 220 |
if (workers.length) { issueGather(workers, node.id, queue); sound.playOrder(); onChange(); }
|
| 221 |
return;
|
| 222 |
}
|
| 223 |
-
if (queue) aggressiveMove(selected, p.x, p.y, true);
|
| 224 |
-
else issueMove(selected, p.x, p.y, false, currentFormation());
|
| 225 |
sound.playOrder();
|
| 226 |
onChange();
|
| 227 |
}
|
| 228 |
|
| 229 |
canvas.addEventListener("mousedown", e => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
if (e.button !== 0) return;
|
| 231 |
const p = toWorld(e.clientX, e.clientY);
|
| 232 |
if (buildMode) { placeBuildingAt(p); return; }
|
|
@@ -258,6 +278,23 @@ export function attachInput(canvas, state, onChange) {
|
|
| 258 |
canvas.addEventListener("mouseleave", () => { edgePan = [0, 0]; }, { signal });
|
| 259 |
|
| 260 |
window.addEventListener("mouseup", e => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
if (e.button !== 0 || !dragBox) return;
|
| 262 |
const box = dragBox;
|
| 263 |
dragBox = null;
|
|
@@ -278,19 +315,13 @@ export function attachInput(canvas, state, onChange) {
|
|
| 278 |
selectSameTypeAt(toWorld(e.clientX, e.clientY));
|
| 279 |
}, { signal });
|
| 280 |
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
// (move/attack/gather steps) the units run through in order. A plain
|
| 289 |
-
// right-click issues immediately and clears any queued waypoints. NOT Shift:
|
| 290 |
-
// Firefox force-shows the native context menu on Shift+right-click and
|
| 291 |
-
// bypasses preventDefault, so a Shift-queued order could never be captured.
|
| 292 |
-
commandAt(toWorld(e.clientX, e.clientY), e.ctrlKey);
|
| 293 |
-
}, { signal });
|
| 294 |
|
| 295 |
canvas.addEventListener("wheel", e => {
|
| 296 |
e.preventDefault();
|
|
|
|
| 38 |
const heldKeys = new Set();
|
| 39 |
|
| 40 |
let dragBox = null;
|
| 41 |
+
let rightDragStart = null; // world-space {x,y} where a right-button drag began, or null between drags
|
| 42 |
let buildMode = null;
|
| 43 |
let attackMoveArmed = false; // set by the A key; the next left-click issues an attack-move
|
| 44 |
let lastWorldPos = { x: state.map.width / 2, y: state.map.height / 2 };
|
|
|
|
| 87 |
|
| 88 |
// The player's current formation choice (session.js), read fresh on every order β so
|
| 89 |
// switching shapes in the HUD mid-game takes effect on the very next command issued.
|
| 90 |
+
// `heading` ({x,y}, a world-space vector β NOT normalized, engine/formation.js does that) is
|
| 91 |
+
// set only by a right-click-DRAG (see rightDragStart below): the drag direction becomes both
|
| 92 |
+
// the formation's facing while it travels and the persisted `unit.facing` it holds once
|
| 93 |
+
// stopped (engine/commands.js applyFacing). A plain click carries no heading, so the shape
|
| 94 |
+
// orients itself off the group's own travel direction as before, and any earlier facing is
|
| 95 |
+
// cleared.
|
| 96 |
+
function currentFormation(heading) {
|
| 97 |
+
const f = { shape: game.formation.shape, leaderPos: game.formation.leaderPos };
|
| 98 |
+
if (heading) { f.headingX = heading.x; f.headingY = heading.y; }
|
| 99 |
+
return f;
|
| 100 |
}
|
| 101 |
|
| 102 |
// Attack-move to (x,y): combat units advance-and-engage anything met on the
|
| 103 |
// way; non-combat units (workers, the Ranger) can't attack-move, so they just
|
| 104 |
// move. Same split the Ctrl-queue and minimap-command paths use.
|
| 105 |
+
function aggressiveMove(units, x, y, queue = false, heading) {
|
| 106 |
const combatants = units.filter(u => UNITS[u.type].role === "combat");
|
| 107 |
const others = units.filter(u => UNITS[u.type].role !== "combat");
|
| 108 |
+
const formation = currentFormation(heading);
|
| 109 |
if (combatants.length) issueAttackMove(combatants, x, y, queue, formation);
|
| 110 |
if (others.length) issueMove(others, x, y, queue, formation);
|
| 111 |
}
|
|
|
|
| 182 |
// a single selected production building sets its rally; otherwise the current
|
| 183 |
// unit selection assists-builds, attacks, gathers, or moves as the target
|
| 184 |
// warrants. `queue` chains it as a waypoint instead of replacing the order.
|
| 185 |
+
// `heading` (world-space {x,y}, only ever set by a right-click-drag β see
|
| 186 |
+
// rightDragStart) only matters to the final plain-move fallback: gathering,
|
| 187 |
+
// attacking, escorting, and assisting/servicing a building all target a SPECIFIC
|
| 188 |
+
// entity/node, where "which way to face while getting there" isn't a choice β
|
| 189 |
+
// only an undirected move onto open ground is.
|
| 190 |
+
function commandAt(p, queue, heading) {
|
| 191 |
if (state.selection.length === 1) {
|
| 192 |
const building = state.buildings.get(state.selection[0]);
|
| 193 |
if (building && building.owner === "player" && BUILDINGS[building.type].produces) {
|
|
|
|
| 234 |
if (workers.length) { issueGather(workers, node.id, queue); sound.playOrder(); onChange(); }
|
| 235 |
return;
|
| 236 |
}
|
| 237 |
+
if (queue) aggressiveMove(selected, p.x, p.y, true, heading);
|
| 238 |
+
else issueMove(selected, p.x, p.y, false, currentFormation(heading));
|
| 239 |
sound.playOrder();
|
| 240 |
onChange();
|
| 241 |
}
|
| 242 |
|
| 243 |
canvas.addEventListener("mousedown", e => {
|
| 244 |
+
if (e.button === 2) {
|
| 245 |
+
// The actual command (or a build/attack-move cancel) fires on mouseup below, once we
|
| 246 |
+
// know whether this stayed a click or became a drag β just mark where it started.
|
| 247 |
+
rightDragStart = toWorld(e.clientX, e.clientY);
|
| 248 |
+
return;
|
| 249 |
+
}
|
| 250 |
if (e.button !== 0) return;
|
| 251 |
const p = toWorld(e.clientX, e.clientY);
|
| 252 |
if (buildMode) { placeBuildingAt(p); return; }
|
|
|
|
| 278 |
canvas.addEventListener("mouseleave", () => { edgePan = [0, 0]; }, { signal });
|
| 279 |
|
| 280 |
window.addEventListener("mouseup", e => {
|
| 281 |
+
if (e.button === 2) {
|
| 282 |
+
const start = rightDragStart;
|
| 283 |
+
rightDragStart = null;
|
| 284 |
+
if (!start) return; // the button went down somewhere this listener never saw (e.g. off-canvas)
|
| 285 |
+
if (buildMode) { buildMode = null; onChange(); return; }
|
| 286 |
+
if (attackMoveArmed) { setArmed(false); return; } // right-click cancels a pending attack-move
|
| 287 |
+
const end = toWorld(e.clientX, e.clientY);
|
| 288 |
+
const dx = end.x - start.x, dy = end.y - start.y;
|
| 289 |
+
// A plain click carries no heading (the shape/facing fall back to the group's own travel
|
| 290 |
+
// direction, exactly as before); a real drag's direction becomes the explicit heading β
|
| 291 |
+
// see currentFormation/applyFacing. Holding Ctrl queues the order as a waypoint instead of
|
| 292 |
+
// replacing what the units are doing, so a sequence of Ctrl+right-drags lays down a
|
| 293 |
+
// multi-leg, individually-aimed path.
|
| 294 |
+
const heading = Math.hypot(dx, dy) >= CLICK_THRESHOLD ? { x: dx, y: dy } : null;
|
| 295 |
+
commandAt(start, e.ctrlKey, heading);
|
| 296 |
+
return;
|
| 297 |
+
}
|
| 298 |
if (e.button !== 0 || !dragBox) return;
|
| 299 |
const box = dragBox;
|
| 300 |
dragBox = null;
|
|
|
|
| 315 |
selectSameTypeAt(toWorld(e.clientX, e.clientY));
|
| 316 |
}, { signal });
|
| 317 |
|
| 318 |
+
// The actual right-click handling (build/attack-move cancel, or commandAt with whatever
|
| 319 |
+
// heading the drag implies) lives on mouseup above, which fires before this β contextmenu's
|
| 320 |
+
// only remaining job is suppressing the browser's native menu. NOT Shift+right-click: Firefox
|
| 321 |
+
// force-shows the native context menu on it and bypasses preventDefault, so a Shift-modified
|
| 322 |
+
// right-drag could never be captured β Ctrl is the queue modifier here instead, for exactly
|
| 323 |
+
// that reason.
|
| 324 |
+
canvas.addEventListener("contextmenu", e => { e.preventDefault(); }, { signal });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
canvas.addEventListener("wheel", e => {
|
| 327 |
e.preventDefault();
|
|
@@ -120,6 +120,7 @@ export function flashHint(msg) {
|
|
| 120 |
const HELP_ROWS = [
|
| 121 |
["Left-drag", "Select units Β· Ctrl+drag adds to selection"],
|
| 122 |
["Right-click", "Move Β· attack an enemy Β· gather a node"],
|
|
|
|
| 123 |
["A, then click", "Attack-move β advance and engage on the way"],
|
| 124 |
["Ctrl+right-click", "Queue a waypoint (chain a path)"],
|
| 125 |
["1β9 / Shift+1β9", "Recall / bind a control group"],
|
|
|
|
| 120 |
const HELP_ROWS = [
|
| 121 |
["Left-drag", "Select units Β· Ctrl+drag adds to selection"],
|
| 122 |
["Right-click", "Move Β· attack an enemy Β· gather a node"],
|
| 123 |
+
["Right-click and drag", "β¦the drag direction also becomes the group's (or a lone unit's) facing"],
|
| 124 |
["A, then click", "Attack-move β advance and engage on the way"],
|
| 125 |
["Ctrl+right-click", "Queue a waypoint (chain a path)"],
|
| 126 |
["1β9 / Shift+1β9", "Recall / bind a control group"],
|
|
@@ -81,14 +81,22 @@ export function resetFacing() {
|
|
| 81 |
|
| 82 |
// Infer and store a unit's facing angle from its frame-to-frame movement; returns the angle so
|
| 83 |
// an oriented hull can draw pointing the way it's actually moving. A near-stationary unit keeps
|
| 84 |
-
// its previous angle
|
| 85 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
export function updateFacing(unit) {
|
| 87 |
const prev = facing.get(unit.id);
|
| 88 |
-
|
|
|
|
| 89 |
if (prev) {
|
| 90 |
const dx = unit.x - prev.x, dy = unit.y - prev.y;
|
| 91 |
if (Math.hypot(dx, dy) > 0.5) angle = Math.atan2(dy, dx);
|
|
|
|
| 92 |
}
|
| 93 |
facing.set(unit.id, { x: unit.x, y: unit.y, angle });
|
| 94 |
return angle;
|
|
|
|
| 81 |
|
| 82 |
// Infer and store a unit's facing angle from its frame-to-frame movement; returns the angle so
|
| 83 |
// an oriented hull can draw pointing the way it's actually moving. A near-stationary unit keeps
|
| 84 |
+
// its previous angle EXCEPT when the player has set an explicit one (unit.facing β a real,
|
| 85 |
+
// engine-side field set by a click-and-drag move/attack-move, engine/commands.js applyFacing):
|
| 86 |
+
// then it snaps to/holds THAT instead, so a formation told to face the enemy actually does once
|
| 87 |
+
// it stops, rather than freezing at whatever direction its last step of pathing happened to
|
| 88 |
+
// leave it pointing. While actually moving, travel direction still wins β a unit sliding
|
| 89 |
+
// sideways while nominally "facing" a fixed angle would read as broken, not deliberate. Buildings
|
| 90 |
+
// call it too (a turret barrel, which never sets unit.facing) β the "b*"/"u*" id spaces can't
|
| 91 |
+
// collide in the shared map.
|
| 92 |
export function updateFacing(unit) {
|
| 93 |
const prev = facing.get(unit.id);
|
| 94 |
+
const explicit = Number.isFinite(unit.facing);
|
| 95 |
+
let angle = prev ? prev.angle : (explicit ? unit.facing : -Math.PI / 2);
|
| 96 |
if (prev) {
|
| 97 |
const dx = unit.x - prev.x, dy = unit.y - prev.y;
|
| 98 |
if (Math.hypot(dx, dy) > 0.5) angle = Math.atan2(dy, dx);
|
| 99 |
+
else if (explicit) angle = unit.facing;
|
| 100 |
}
|
| 101 |
facing.set(unit.id, { x: unit.x, y: unit.y, angle });
|
| 102 |
return angle;
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { test } from "node:test";
|
| 2 |
import assert from "node:assert/strict";
|
| 3 |
import { formationSlots, clusterUnits, pickLeader, FORMATION_SHAPES } from "../engine/formation.js";
|
| 4 |
-
import { issueHoldFormation } from "../engine/commands.js";
|
| 5 |
import { UNITS } from "../engine/entities.js";
|
| 6 |
import { createGameState, makeUnit } from "../engine/state.js";
|
| 7 |
import { tick } from "../engine/sim.js";
|
|
@@ -9,9 +9,14 @@ import { updateCombat } from "../engine/combat.js";
|
|
| 9 |
import { serializeGame, deserializeGame } from "../engine/persist.js";
|
| 10 |
|
| 11 |
// A bare positioned "unit" β enough for formation.js (which only reads id/type/x/y/maxHp),
|
| 12 |
-
// without going through makeUnit/a real game state.
|
|
|
|
|
|
|
| 13 |
function fakeUnit(id, type, x, y, maxHp) {
|
| 14 |
-
return { id, type, x, y, maxHp: maxHp ?? UNITS[type].hp };
|
|
|
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
// A minimal state (as the movement/combat tests use): just a unit Map + players, so we can drive
|
|
@@ -25,20 +30,13 @@ function miniState(...units) {
|
|
| 25 |
};
|
| 26 |
}
|
| 27 |
|
| 28 |
-
// ---- pickLeader -----------
|
| 29 |
-
|
| 30 |
-
test("pickLeader picks the unit with the most maxHp", () => {
|
| 31 |
-
const a = fakeUnit("u2", "skiff", 0, 0); // maxHp 72
|
| 32 |
-
const b = fakeUnit("u1", "dreadnought", 0, 0); // maxHp 340
|
| 33 |
-
const c = fakeUnit("u3", "worker", 0, 0); // maxHp 40
|
| 34 |
-
assert.equal(pickLeader([a, b, c]).id, "u1");
|
| 35 |
-
});
|
| 36 |
|
| 37 |
-
test("pickLeader
|
| 38 |
-
const
|
| 39 |
-
const
|
| 40 |
-
assert.equal(pickLeader([
|
| 41 |
-
assert.equal(pickLeader([
|
| 42 |
});
|
| 43 |
|
| 44 |
// ---- clusterUnits: the nesting trigger ---------------------------------------
|
|
@@ -56,6 +54,13 @@ test("clusterUnits splits a selection spread across the map into separate armies
|
|
| 56 |
assert.deepEqual(clusters.map(c => c.length).sort(), [3, 3]);
|
| 57 |
});
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
// ---- formationSlots: the default (grid, single cluster) path stays legacy-exact ----
|
| 60 |
|
| 61 |
test("formationSlots grid (no shape chosen) matches the exact legacy spacing formula", () => {
|
|
@@ -89,65 +94,74 @@ test("line/wedge/circle center their group's centroid exactly on the destination
|
|
| 89 |
}
|
| 90 |
});
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
// ---- line ---------------------------------------------------------------------
|
|
|
|
| 93 |
|
| 94 |
test("line formation: leaderPos front pushes the leader ahead of the rest of the line", () => {
|
| 95 |
-
const leader = fakeUnit("L", "
|
| 96 |
const followers = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
|
| 97 |
-
const units = [...followers
|
| 98 |
const spots = formationSlots(units, 1000, 0, { shape: "line", leaderPos: "front", originX: 0, originY: 0 });
|
| 99 |
-
const leaderX = spots[
|
| 100 |
-
const avgFollowerX =
|
| 101 |
assert.ok(leaderX > avgFollowerX, "the leader sits ahead of the line toward the heading");
|
| 102 |
});
|
| 103 |
|
| 104 |
test("line formation: leaderPos back tucks the leader behind the line", () => {
|
| 105 |
-
const leader = fakeUnit("L", "
|
| 106 |
const followers = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
|
| 107 |
-
const units = [...followers
|
| 108 |
const spots = formationSlots(units, 1000, 0, { shape: "line", leaderPos: "back", originX: 0, originY: 0 });
|
| 109 |
-
const leaderX = spots[
|
| 110 |
-
const avgFollowerX =
|
| 111 |
assert.ok(leaderX < avgFollowerX, "the leader trails behind the line");
|
| 112 |
});
|
| 113 |
|
| 114 |
// ---- wedge ----------------------------------------------------------------------
|
| 115 |
|
| 116 |
test("wedge formation: leaderPos front puts the leader at the tip, most forward of everyone", () => {
|
| 117 |
-
const leader = fakeUnit("L", "
|
| 118 |
const others = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
|
| 119 |
const units = [leader, ...others];
|
| 120 |
const spots = formationSlots(units, 1000, 0, { shape: "wedge", leaderPos: "front", originX: 0, originY: 0 });
|
| 121 |
-
const leaderX = spots[
|
| 122 |
-
for (const
|
| 123 |
});
|
| 124 |
|
| 125 |
test("wedge formation: leaderPos back shields the leader at the rear vertex, flanks leading", () => {
|
| 126 |
-
const leader = fakeUnit("L", "
|
| 127 |
const others = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
|
| 128 |
const units = [leader, ...others];
|
| 129 |
const spots = formationSlots(units, 1000, 0, { shape: "wedge", leaderPos: "back", originX: 0, originY: 0 });
|
| 130 |
-
const leaderX = spots[
|
| 131 |
-
for (const
|
| 132 |
});
|
| 133 |
|
| 134 |
// ---- circle -----------------------------------------------------------------
|
| 135 |
|
| 136 |
test("circle formation: leaderPos center seats the leader at the anchor, escorted by a ring", () => {
|
| 137 |
-
const leader = fakeUnit("L", "
|
| 138 |
const others = [1, 2, 3, 4, 5].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
|
| 139 |
const units = [leader, ...others];
|
| 140 |
const spots = formationSlots(units, 900, 700, { shape: "circle", leaderPos: "center", originX: 0, originY: 0 });
|
| 141 |
-
|
| 142 |
-
assert.ok(Math.hypot(
|
| 143 |
-
for (const o of others) {
|
| 144 |
-
const p = spots[units.indexOf(o)];
|
| 145 |
-
assert.ok(Math.hypot(p.x - 900, p.y - 700) > 10, `${o.id} stands out on the protective ring`);
|
| 146 |
-
}
|
| 147 |
});
|
| 148 |
|
| 149 |
test("circle formation: leaderPos front/back puts every unit, leader included, on the ring", () => {
|
| 150 |
-
const leader = fakeUnit("L", "
|
| 151 |
const others = [1, 2, 3].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
|
| 152 |
const units = [leader, ...others];
|
| 153 |
const spots = formationSlots(units, 900, 700, { shape: "circle", leaderPos: "front", originX: 0, originY: 0 });
|
|
@@ -204,24 +218,52 @@ test("formationSlots lays out a nested formation of formations β tight within
|
|
| 204 |
assert.ok(Math.abs(avgX - 1500) < 5 && Math.abs(avgY - 0) < 5, "the nested layout as a whole is still centered on the destination");
|
| 205 |
});
|
| 206 |
|
| 207 |
-
// ----
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
-
test("
|
| 210 |
-
const units = [
|
| 211 |
-
|
| 212 |
-
{ id: "u2", type: "skiff", x: 120, y: 100, maxHp: 72, order: null },
|
| 213 |
-
];
|
| 214 |
-
issueHoldFormation(units, "grid", "front");
|
| 215 |
for (const u of units) {
|
| 216 |
-
assert.equal(u.order.type, "
|
| 217 |
-
assert.
|
| 218 |
-
assert.equal(u.order.anchorY, 100);
|
| 219 |
-
assert.equal(u.hold, true, "a combat unit also takes the Hold stance");
|
| 220 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
});
|
| 222 |
|
| 223 |
test("issueHoldFormation gives a non-combat unit a slot but no Hold stance to set", () => {
|
| 224 |
-
const worker =
|
| 225 |
issueHoldFormation([worker]);
|
| 226 |
assert.equal(worker.order.type, "hold-formation");
|
| 227 |
assert.ok(!worker.hold);
|
|
@@ -231,18 +273,99 @@ test("issueHoldFormation on an empty selection is a safe no-op", () => {
|
|
| 231 |
assert.doesNotThrow(() => issueHoldFormation([]));
|
| 232 |
});
|
| 233 |
|
| 234 |
-
// ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
-
test("a
|
| 237 |
-
const
|
|
|
|
|
|
|
| 238 |
const s = miniState(...units);
|
| 239 |
issueHoldFormation(units, "line", "front");
|
| 240 |
for (let i = 0; i < 400; i++) for (const u of units) updateCombat(s, u, 0.1);
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
| 246 |
});
|
| 247 |
|
| 248 |
test("a unit holding formation still fires on a threat that wanders into weapon range", () => {
|
|
@@ -268,31 +391,78 @@ test("the Hold stance keeps a formation unit from chasing a target out of weapon
|
|
| 268 |
assert.ok(Math.hypot(guard.x - startX, guard.y - startY) < 2, "held ground instead of closing on an out-of-range target");
|
| 269 |
});
|
| 270 |
|
| 271 |
-
test("a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
const s = createGameState({ planetId: "ferros", seed: 2 });
|
| 273 |
const w1 = makeUnit("worker", "player", 500, 500);
|
| 274 |
const w2 = makeUnit("worker", "player", 700, 500);
|
|
|
|
| 275 |
s.units.set(w1.id, w1); s.units.set(w2.id, w2);
|
| 276 |
-
issueHoldFormation(
|
| 277 |
for (let i = 0; i < 200; i++) tick(s, 0.1);
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
});
|
| 284 |
|
| 285 |
-
test("a support unit (Mender)
|
| 286 |
const s = createGameState({ planetId: "ferros", seed: 3 });
|
| 287 |
const m1 = makeUnit("mender", "player", 500, 500);
|
| 288 |
const m2 = makeUnit("mender", "player", 700, 500);
|
| 289 |
s.units.set(m1.id, m1); s.units.set(m2.id, m2);
|
| 290 |
issueHoldFormation([m1, m2], "circle", "front");
|
| 291 |
for (let i = 0; i < 200; i++) tick(s, 0.1);
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
});
|
| 297 |
|
| 298 |
// ---- persistence --------------------------------------------------------------
|
|
@@ -318,3 +488,22 @@ test("a hold-formation order survives save/load, and a tampered anchor/offset ca
|
|
| 318 |
tick(restored, 0.1);
|
| 319 |
assert.ok(Number.isFinite(ru1.x) && Number.isFinite(ru1.y), "position stays finite even with a garbage anchor/offset");
|
| 320 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { test } from "node:test";
|
| 2 |
import assert from "node:assert/strict";
|
| 3 |
import { formationSlots, clusterUnits, pickLeader, FORMATION_SHAPES } from "../engine/formation.js";
|
| 4 |
+
import { issueHoldFormation, issueMove, issueAttackMove, issueStop } from "../engine/commands.js";
|
| 5 |
import { UNITS } from "../engine/entities.js";
|
| 6 |
import { createGameState, makeUnit } from "../engine/state.js";
|
| 7 |
import { tick } from "../engine/sim.js";
|
|
|
|
| 9 |
import { serializeGame, deserializeGame } from "../engine/persist.js";
|
| 10 |
|
| 11 |
// A bare positioned "unit" β enough for formation.js (which only reads id/type/x/y/maxHp),
|
| 12 |
+
// without going through makeUnit/a real game state. No `owner` on purpose for the pure-geometry
|
| 13 |
+
// tests below (formation.js itself doesn't care); tests that exercise the PLAYER leader/follow
|
| 14 |
+
// squad mechanic (engine/commands.js) need a real owner:"player" β see fakePlayerUnit.
|
| 15 |
function fakeUnit(id, type, x, y, maxHp) {
|
| 16 |
+
return { id, type, x, y, maxHp: maxHp ?? UNITS[type].hp, order: null };
|
| 17 |
+
}
|
| 18 |
+
function fakePlayerUnit(id, type, x, y) {
|
| 19 |
+
return { ...fakeUnit(id, type, x, y), owner: "player" };
|
| 20 |
}
|
| 21 |
|
| 22 |
// A minimal state (as the movement/combat tests use): just a unit Map + players, so we can drive
|
|
|
|
| 30 |
};
|
| 31 |
}
|
| 32 |
|
| 33 |
+
// ---- pickLeader: the unit FIRST in the array, not a stat-based pick ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
test("pickLeader is simply the first unit in the array β the one the rest were added to", () => {
|
| 36 |
+
const a = fakeUnit("u2", "dreadnought", 0, 0); // highest maxHp, but NOT first
|
| 37 |
+
const b = fakeUnit("u1", "skiff", 0, 0); // listed first
|
| 38 |
+
assert.equal(pickLeader([b, a]).id, "u1");
|
| 39 |
+
assert.equal(pickLeader([a, b]).id, "u2", "array order alone decides it β not maxHp");
|
| 40 |
});
|
| 41 |
|
| 42 |
// ---- clusterUnits: the nesting trigger ---------------------------------------
|
|
|
|
| 54 |
assert.deepEqual(clusters.map(c => c.length).sort(), [3, 3]);
|
| 55 |
});
|
| 56 |
|
| 57 |
+
test("clusterUnits keeps the cluster containing units[0] first, so it stays the outer leader cluster", () => {
|
| 58 |
+
const leaderSide = [0, 1].map(i => fakeUnit(`L${i}`, "skiff", i * 10, 0));
|
| 59 |
+
const otherSide = [0, 1].map(i => fakeUnit(`O${i}`, "skiff", 3000 + i * 10, 0));
|
| 60 |
+
const clusters = clusterUnits([...leaderSide, ...otherSide]);
|
| 61 |
+
assert.equal(clusters[0][0].id, "L0", "units[0]'s cluster is clusters[0]");
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
// ---- formationSlots: the default (grid, single cluster) path stays legacy-exact ----
|
| 65 |
|
| 66 |
test("formationSlots grid (no shape chosen) matches the exact legacy spacing formula", () => {
|
|
|
|
| 94 |
}
|
| 95 |
});
|
| 96 |
|
| 97 |
+
// ---- explicit heading override ------------------------------------------------
|
| 98 |
+
|
| 99 |
+
test("an explicit headingX/headingY overrides the origin-derived heading", () => {
|
| 100 |
+
const units = [0, 1, 2, 3].map(i => fakeUnit(`u${i}`, "skiff", 0, 0));
|
| 101 |
+
// origin==dest (no travel at all) defaults to a stable heading (see the "line formation"
|
| 102 |
+
// tests below, which rely on exactly this default) β an explicit override must still change
|
| 103 |
+
// the resulting layout rather than being silently ignored.
|
| 104 |
+
const withoutOverride = formationSlots(units, 500, 500, { shape: "line", originX: 500, originY: 500 });
|
| 105 |
+
const withOverride = formationSlots(units, 500, 500, { shape: "line", originX: 500, originY: 500, headingX: 0, headingY: -1 });
|
| 106 |
+
assert.notDeepEqual(withOverride, withoutOverride, "an explicit heading produces a different layout than the origin-derived default");
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
// ---- line ---------------------------------------------------------------------
|
| 110 |
+
// The leader is always units[0] β these tests put the intended leader FIRST in the array.
|
| 111 |
|
| 112 |
test("line formation: leaderPos front pushes the leader ahead of the rest of the line", () => {
|
| 113 |
+
const leader = fakeUnit("L", "skiff", 0, 0);
|
| 114 |
const followers = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
|
| 115 |
+
const units = [leader, ...followers];
|
| 116 |
const spots = formationSlots(units, 1000, 0, { shape: "line", leaderPos: "front", originX: 0, originY: 0 });
|
| 117 |
+
const leaderX = spots[0].x;
|
| 118 |
+
const avgFollowerX = spots.slice(1).reduce((s, p) => s + p.x, 0) / followers.length;
|
| 119 |
assert.ok(leaderX > avgFollowerX, "the leader sits ahead of the line toward the heading");
|
| 120 |
});
|
| 121 |
|
| 122 |
test("line formation: leaderPos back tucks the leader behind the line", () => {
|
| 123 |
+
const leader = fakeUnit("L", "skiff", 0, 0);
|
| 124 |
const followers = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
|
| 125 |
+
const units = [leader, ...followers];
|
| 126 |
const spots = formationSlots(units, 1000, 0, { shape: "line", leaderPos: "back", originX: 0, originY: 0 });
|
| 127 |
+
const leaderX = spots[0].x;
|
| 128 |
+
const avgFollowerX = spots.slice(1).reduce((s, p) => s + p.x, 0) / followers.length;
|
| 129 |
assert.ok(leaderX < avgFollowerX, "the leader trails behind the line");
|
| 130 |
});
|
| 131 |
|
| 132 |
// ---- wedge ----------------------------------------------------------------------
|
| 133 |
|
| 134 |
test("wedge formation: leaderPos front puts the leader at the tip, most forward of everyone", () => {
|
| 135 |
+
const leader = fakeUnit("L", "skiff", 0, 0);
|
| 136 |
const others = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
|
| 137 |
const units = [leader, ...others];
|
| 138 |
const spots = formationSlots(units, 1000, 0, { shape: "wedge", leaderPos: "front", originX: 0, originY: 0 });
|
| 139 |
+
const leaderX = spots[0].x;
|
| 140 |
+
for (const p of spots.slice(1)) assert.ok(leaderX >= p.x - 1e-6, "every flank trails the tip");
|
| 141 |
});
|
| 142 |
|
| 143 |
test("wedge formation: leaderPos back shields the leader at the rear vertex, flanks leading", () => {
|
| 144 |
+
const leader = fakeUnit("L", "skiff", 0, 0);
|
| 145 |
const others = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
|
| 146 |
const units = [leader, ...others];
|
| 147 |
const spots = formationSlots(units, 1000, 0, { shape: "wedge", leaderPos: "back", originX: 0, originY: 0 });
|
| 148 |
+
const leaderX = spots[0].x;
|
| 149 |
+
for (const p of spots.slice(1)) assert.ok(leaderX <= p.x + 1e-6, "every flank leads ahead of the shielded leader");
|
| 150 |
});
|
| 151 |
|
| 152 |
// ---- circle -----------------------------------------------------------------
|
| 153 |
|
| 154 |
test("circle formation: leaderPos center seats the leader at the anchor, escorted by a ring", () => {
|
| 155 |
+
const leader = fakeUnit("L", "skiff", 0, 0);
|
| 156 |
const others = [1, 2, 3, 4, 5].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
|
| 157 |
const units = [leader, ...others];
|
| 158 |
const spots = formationSlots(units, 900, 700, { shape: "circle", leaderPos: "center", originX: 0, originY: 0 });
|
| 159 |
+
assert.ok(Math.hypot(spots[0].x - 900, spots[0].y - 700) < 0.5, "leader sits at the anchor");
|
| 160 |
+
for (const p of spots.slice(1)) assert.ok(Math.hypot(p.x - 900, p.y - 700) > 10, "an escort stands out on the protective ring");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
});
|
| 162 |
|
| 163 |
test("circle formation: leaderPos front/back puts every unit, leader included, on the ring", () => {
|
| 164 |
+
const leader = fakeUnit("L", "skiff", 0, 0);
|
| 165 |
const others = [1, 2, 3].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
|
| 166 |
const units = [leader, ...others];
|
| 167 |
const spots = formationSlots(units, 900, 700, { shape: "circle", leaderPos: "front", originX: 0, originY: 0 });
|
|
|
|
| 218 |
assert.ok(Math.abs(avgX - 1500) < 5 && Math.abs(avgY - 0) < 5, "the nested layout as a whole is still centered on the destination");
|
| 219 |
});
|
| 220 |
|
| 221 |
+
// ---- AI-owned (and owner-less) units never form a squad ----------------------
|
| 222 |
+
// The leader/follow mechanic is a PLAYER selection-UX feature: the scripted AI (and any test
|
| 223 |
+
// double with no owner set) always gets the plain, pre-existing per-unit spread β every unit its
|
| 224 |
+
// own real order, no leader, no follow-leader, no speed cap.
|
| 225 |
|
| 226 |
+
test("issueMove on AI-owned units gives every unit its own real move order β no squad", () => {
|
| 227 |
+
const units = [fakeUnit("a1", "skiff", 0, 0), fakeUnit("a2", "skiff", 0, 0)].map(u => ({ ...u, owner: "ai" }));
|
| 228 |
+
issueMove(units, 900, 700);
|
|
|
|
|
|
|
|
|
|
| 229 |
for (const u of units) {
|
| 230 |
+
assert.equal(u.order.type, "move");
|
| 231 |
+
assert.ok(Number.isFinite(u.order.x) && Number.isFinite(u.order.y));
|
|
|
|
|
|
|
| 232 |
}
|
| 233 |
+
assert.equal(units[1].order.type, "move", "the second unit is NOT a follow-leader");
|
| 234 |
+
assert.equal(units.some(u => u.squadLeader), false);
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
test("issueMove on owner-less units (a bare test double) also skips the squad mechanic", () => {
|
| 238 |
+
const units = [fakeUnit("u1", "skiff", 0, 0), fakeUnit("u2", "skiff", 0, 0)]; // no owner field at all
|
| 239 |
+
issueMove(units, 900, 700);
|
| 240 |
+
units.forEach(u => assert.equal(u.order.type, "move"));
|
| 241 |
+
});
|
| 242 |
+
|
| 243 |
+
// ---- issueHoldFormation / the leader-follow squad (engine/commands.js) -------
|
| 244 |
+
|
| 245 |
+
test("issueHoldFormation makes units[0] the leader (anchored near the group's own centroid) and everyone else a follower", () => {
|
| 246 |
+
const units = [fakePlayerUnit("u1", "skiff", 100, 100), fakePlayerUnit("u2", "skiff", 120, 100)];
|
| 247 |
+
issueHoldFormation(units, "grid", "front");
|
| 248 |
+
const [leader, follower] = units;
|
| 249 |
+
assert.equal(leader.order.type, "hold-formation");
|
| 250 |
+
// The grid shape gives the leader its own slot in the grid (not necessarily the exact
|
| 251 |
+
// centroid β see the "matches the exact legacy spacing formula" test), but it's always
|
| 252 |
+
// WITHIN the group's own footprint, close to where the two units actually are.
|
| 253 |
+
assert.ok(Math.hypot(leader.order.anchorX - 110, leader.order.anchorY - 100) < 20);
|
| 254 |
+
assert.equal(follower.order.type, "follow-leader");
|
| 255 |
+
assert.equal(follower.order.leader, leader);
|
| 256 |
+
assert.equal(follower.squadLeader, leader);
|
| 257 |
+
assert.deepEqual(leader.squadFollowers, [follower]);
|
| 258 |
+
assert.equal(leader.hold, true, "a combat leader also takes the Hold stance");
|
| 259 |
+
assert.equal(follower.hold, true, "β¦and so does a combat follower");
|
| 260 |
+
// Leader and follower end up at distinct points (the formation's own spacing), not stacked.
|
| 261 |
+
const followerTarget = { x: leader.order.anchorX + follower.order.offsetX, y: leader.order.anchorY + follower.order.offsetY };
|
| 262 |
+
assert.ok(Math.hypot(followerTarget.x - leader.order.anchorX, followerTarget.y - leader.order.anchorY) > 5);
|
| 263 |
});
|
| 264 |
|
| 265 |
test("issueHoldFormation gives a non-combat unit a slot but no Hold stance to set", () => {
|
| 266 |
+
const worker = fakePlayerUnit("u1", "worker", 0, 0);
|
| 267 |
issueHoldFormation([worker]);
|
| 268 |
assert.equal(worker.order.type, "hold-formation");
|
| 269 |
assert.ok(!worker.hold);
|
|
|
|
| 273 |
assert.doesNotThrow(() => issueHoldFormation([]));
|
| 274 |
});
|
| 275 |
|
| 276 |
+
// ---- persistent leader-follow: select the leader alone, move it, others trail --
|
| 277 |
+
|
| 278 |
+
test("a solo move on the leader alone drags its existing followers along, capped to the group's slowest speed", () => {
|
| 279 |
+
const leader = fakePlayerUnit("L", "bulkfreighter", 100, 100); // slow (speed 32)
|
| 280 |
+
const follower = fakePlayerUnit("F", "ranger", 120, 100); // fast (speed 115)
|
| 281 |
+
issueMove([leader, follower], 1000, 100);
|
| 282 |
+
assert.equal(leader.order.speedCap, UNITS.bulkfreighter.speed, "capped to the slowest member");
|
| 283 |
+
assert.equal(follower.order.type, "follow-leader");
|
| 284 |
+
assert.equal(follower.order.leader, leader);
|
| 285 |
+
|
| 286 |
+
// Now command the LEADER alone β the follower's order is untouched (still follow-leader,
|
| 287 |
+
// still pointing at the same live leader object), so it keeps trailing automatically.
|
| 288 |
+
issueMove([leader], 50, 100);
|
| 289 |
+
assert.equal(leader.order.type, "move");
|
| 290 |
+
assert.equal(leader.order.speedCap, UNITS.bulkfreighter.speed, "the solo command still re-derives the group's speed cap");
|
| 291 |
+
assert.equal(follower.order.type, "follow-leader", "never re-issued β it was already chasing the leader");
|
| 292 |
+
assert.equal(follower.order.leader, leader);
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
test("a follower given a DIRECT order leaves the squad β the leader's own list drops it", () => {
|
| 296 |
+
const leader = fakePlayerUnit("L", "skiff", 0, 0);
|
| 297 |
+
const follower = fakePlayerUnit("F", "skiff", 0, 0);
|
| 298 |
+
issueHoldFormation([leader, follower]);
|
| 299 |
+
assert.equal(follower.squadLeader, leader);
|
| 300 |
+
|
| 301 |
+
issueMove([follower], 500, 500); // a direct command to the follower alone
|
| 302 |
+
|
| 303 |
+
assert.equal(follower.order.type, "move", "direct control wins");
|
| 304 |
+
assert.equal(follower.squadLeader, undefined, "no longer tracked as this leader's follower");
|
| 305 |
+
assert.deepEqual(leader.squadFollowers, [], "the leader's own list was trimmed to match");
|
| 306 |
+
});
|
| 307 |
+
|
| 308 |
+
test("issueStop on a follower releases it from the squad too", () => {
|
| 309 |
+
const leader = fakePlayerUnit("L", "skiff", 0, 0);
|
| 310 |
+
const follower = fakePlayerUnit("F", "skiff", 0, 0);
|
| 311 |
+
issueHoldFormation([leader, follower]);
|
| 312 |
+
issueStop([follower]);
|
| 313 |
+
assert.equal(follower.order, null);
|
| 314 |
+
assert.equal(follower.squadLeader, undefined);
|
| 315 |
+
assert.deepEqual(leader.squadFollowers, []);
|
| 316 |
+
});
|
| 317 |
+
|
| 318 |
+
test("redefining a leader's squad with a smaller group stops (not just unlinks) the dropped-out unit", () => {
|
| 319 |
+
const leader = fakePlayerUnit("L", "skiff", 0, 0);
|
| 320 |
+
const a = fakePlayerUnit("A", "skiff", 0, 0);
|
| 321 |
+
const b = fakePlayerUnit("B", "skiff", 0, 0);
|
| 322 |
+
issueHoldFormation([leader, a, b]);
|
| 323 |
+
assert.deepEqual(leader.squadFollowers, [a, b]);
|
| 324 |
+
|
| 325 |
+
issueMove([leader, a], 500, 0); // a fresh command that only re-selects the leader + A
|
| 326 |
+
|
| 327 |
+
assert.deepEqual(leader.squadFollowers, [a], "B fell out of the squad");
|
| 328 |
+
assert.equal(b.squadLeader, undefined);
|
| 329 |
+
assert.equal(b.order, null, "B is actually stopped, not left silently chasing a leader that dropped it");
|
| 330 |
+
assert.equal(a.order.type, "follow-leader", "A is still following");
|
| 331 |
+
});
|
| 332 |
+
|
| 333 |
+
// ---- click-and-drag facing (engine/commands.js applyFacing) ------------------
|
| 334 |
+
|
| 335 |
+
test("issueMove with an explicit heading stamps every commanded unit's facing; a plain move clears it", () => {
|
| 336 |
+
const units = [fakePlayerUnit("u1", "skiff", 0, 0), fakePlayerUnit("u2", "skiff", 0, 0)];
|
| 337 |
+
issueMove(units, 100, 0, false, { headingX: 0, headingY: 1 }); // facing straight down (+y)
|
| 338 |
+
for (const u of units) assert.ok(Math.abs(u.facing - Math.PI / 2) < 1e-6);
|
| 339 |
+
|
| 340 |
+
issueMove(units, 100, 0); // a later plain move, no heading
|
| 341 |
+
for (const u of units) assert.ok(!Number.isFinite(u.facing), "no explicit heading clears the earlier facing");
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
test("issueAttackMove also stamps and clears facing the same way", () => {
|
| 345 |
+
const units = [fakePlayerUnit("u1", "skiff", 0, 0)];
|
| 346 |
+
issueAttackMove(units, 100, 0, false, { headingX: -1, headingY: 0 }); // facing left
|
| 347 |
+
assert.ok(Math.abs(units[0].facing - Math.PI) < 1e-6);
|
| 348 |
+
issueAttackMove(units, 100, 0);
|
| 349 |
+
assert.ok(!Number.isFinite(units[0].facing));
|
| 350 |
+
});
|
| 351 |
+
|
| 352 |
+
// ---- sim/combat integration: keepFormationStation / keepFollowingLeader -----
|
| 353 |
|
| 354 |
+
test("a leader holding formation converges on its own anchor; a follower converges on leader+offset β arrival never clears either", () => {
|
| 355 |
+
const leader = makeUnit("skiff", "player", 100, 100);
|
| 356 |
+
const follower = makeUnit("skiff", "player", 300, 300);
|
| 357 |
+
const units = [leader, follower];
|
| 358 |
const s = miniState(...units);
|
| 359 |
issueHoldFormation(units, "line", "front");
|
| 360 |
for (let i = 0; i < 400; i++) for (const u of units) updateCombat(s, u, 0.1);
|
| 361 |
+
|
| 362 |
+
const leaderTarget = { x: leader.order.anchorX + leader.order.offsetX, y: leader.order.anchorY + leader.order.offsetY };
|
| 363 |
+
assert.ok(Math.hypot(leader.x - leaderTarget.x, leader.y - leaderTarget.y) < 5, "leader reached its own anchor");
|
| 364 |
+
assert.equal(leader.order.type, "hold-formation");
|
| 365 |
+
|
| 366 |
+
const followerTarget = { x: leader.x + follower.order.offsetX, y: leader.y + follower.order.offsetY };
|
| 367 |
+
assert.ok(Math.hypot(follower.x - followerTarget.x, follower.y - followerTarget.y) < 5, "follower reached leader+offset");
|
| 368 |
+
assert.equal(follower.order.type, "follow-leader");
|
| 369 |
});
|
| 370 |
|
| 371 |
test("a unit holding formation still fires on a threat that wanders into weapon range", () => {
|
|
|
|
| 391 |
assert.ok(Math.hypot(guard.x - startX, guard.y - startY) < 2, "held ground instead of closing on an out-of-range target");
|
| 392 |
});
|
| 393 |
|
| 394 |
+
test("a combat follower still auto-engages a threat, then resumes following", () => {
|
| 395 |
+
const leader = makeUnit("skiff", "player", 500, 500);
|
| 396 |
+
const follower = makeUnit("skiff", "player", 540, 500);
|
| 397 |
+
const units = [leader, follower];
|
| 398 |
+
const s = miniState(...units);
|
| 399 |
+
issueHoldFormation(units);
|
| 400 |
+
for (let i = 0; i < 100; i++) for (const u of units) updateCombat(s, u, 0.1); // settle into formation
|
| 401 |
+
|
| 402 |
+
const enemy = makeUnit("skiff", "ai", follower.x + 20, follower.y); // right on the follower
|
| 403 |
+
enemy.hp = enemy.maxHp = 100000;
|
| 404 |
+
s.units.set(enemy.id, enemy);
|
| 405 |
+
for (let i = 0; i < 80; i++) updateCombat(s, follower, 0.1);
|
| 406 |
+
assert.ok(enemy.hp < enemy.maxHp, "the follower broke to engage a threat at its post");
|
| 407 |
+
assert.equal(follower.order.type, "follow-leader", "still tracking its leader throughout");
|
| 408 |
+
});
|
| 409 |
+
|
| 410 |
+
test("a follower drops its order (free to be re-ordered) the moment its leader dies", () => {
|
| 411 |
+
const leader = makeUnit("skiff", "player", 500, 500);
|
| 412 |
+
const follower = makeUnit("skiff", "player", 540, 500);
|
| 413 |
+
const s = miniState(leader, follower);
|
| 414 |
+
issueHoldFormation([leader, follower]);
|
| 415 |
+
leader.hp = 0; // the leader has died (removeEntity already dropped it from state.units in real play)
|
| 416 |
+
updateCombat(s, follower, 0.1);
|
| 417 |
+
assert.equal(follower.order, null, "no live leader to follow β free for a new order");
|
| 418 |
+
});
|
| 419 |
+
|
| 420 |
+
test("a worker (non-combat) leader+follower pair reaches its formation via the full sim tick path", () => {
|
| 421 |
const s = createGameState({ planetId: "ferros", seed: 2 });
|
| 422 |
const w1 = makeUnit("worker", "player", 500, 500);
|
| 423 |
const w2 = makeUnit("worker", "player", 700, 500);
|
| 424 |
+
const units = [w1, w2];
|
| 425 |
s.units.set(w1.id, w1); s.units.set(w2.id, w2);
|
| 426 |
+
issueHoldFormation(units, "line", "front");
|
| 427 |
for (let i = 0; i < 200; i++) tick(s, 0.1);
|
| 428 |
+
|
| 429 |
+
const leaderTarget = { x: w1.order.anchorX + w1.order.offsetX, y: w1.order.anchorY + w1.order.offsetY };
|
| 430 |
+
assert.ok(Math.hypot(w1.x - leaderTarget.x, w1.y - leaderTarget.y) < 10, "leader reached its anchor");
|
| 431 |
+
const followerTarget = { x: w1.x + w2.order.offsetX, y: w1.y + w2.order.offsetY };
|
| 432 |
+
assert.ok(Math.hypot(w2.x - followerTarget.x, w2.y - followerTarget.y) < 10, "follower reached leader+offset");
|
| 433 |
});
|
| 434 |
|
| 435 |
+
test("a support unit (Mender) leader+follower pair keeps station via updateSupport", () => {
|
| 436 |
const s = createGameState({ planetId: "ferros", seed: 3 });
|
| 437 |
const m1 = makeUnit("mender", "player", 500, 500);
|
| 438 |
const m2 = makeUnit("mender", "player", 700, 500);
|
| 439 |
s.units.set(m1.id, m1); s.units.set(m2.id, m2);
|
| 440 |
issueHoldFormation([m1, m2], "circle", "front");
|
| 441 |
for (let i = 0; i < 200; i++) tick(s, 0.1);
|
| 442 |
+
const leaderTarget = { x: m1.order.anchorX + m1.order.offsetX, y: m1.order.anchorY + m1.order.offsetY };
|
| 443 |
+
assert.ok(Math.hypot(m1.x - leaderTarget.x, m1.y - leaderTarget.y) < 10);
|
| 444 |
+
const followerTarget = { x: m1.x + m2.order.offsetX, y: m1.y + m2.order.offsetY };
|
| 445 |
+
assert.ok(Math.hypot(m2.x - followerTarget.x, m2.y - followerTarget.y) < 10);
|
| 446 |
+
});
|
| 447 |
+
|
| 448 |
+
test("a formation's travel speed never outruns its slowest member, via the real tick loop", () => {
|
| 449 |
+
const s = createGameState({ planetId: "ferros", seed: 5 });
|
| 450 |
+
const leader = makeUnit("bulkfreighter", "player", 500, 500); // speed 32 β the slowest
|
| 451 |
+
// Placed ALONGSIDE the leader (perpendicular to the +x travel direction), not directly ahead
|
| 452 |
+
// of it on the path β so movement.js's lateral-avoidance sensing (same-owner neighbours AHEAD
|
| 453 |
+
// in the seek direction) never engages and curves either unit's path, keeping this a clean
|
| 454 |
+
// measurement of the speed cap alone.
|
| 455 |
+
const follower = makeUnit("ranger", "player", 500, 530); // speed 115 β much faster
|
| 456 |
+
s.units.set(leader.id, leader); s.units.set(follower.id, follower);
|
| 457 |
+
issueMove([leader, follower], 1500, 500);
|
| 458 |
+
for (let i = 0; i < 50; i++) tick(s, 0.1); // 5 sim seconds
|
| 459 |
+
const traveled = leader.x - 500;
|
| 460 |
+
assert.ok(traveled < UNITS.ranger.speed * 5 * 0.5,
|
| 461 |
+
`the leader (${traveled.toFixed(0)} travelled) moves far slower than the follower's own top speed would allow`);
|
| 462 |
+
// The formation's grid layout for 2 units puts the follower's slot 20 units off the leader's
|
| 463 |
+
// own β see the "matches the exact legacy spacing formula" test above (spacing 20, cols 2).
|
| 464 |
+
const gap = Math.hypot(follower.x - (leader.x + 20), follower.y - leader.y);
|
| 465 |
+
assert.ok(gap < 10, `the fast follower stays tight on the slow leader (gap ${gap.toFixed(1)}), not racing ahead and idling`);
|
| 466 |
});
|
| 467 |
|
| 468 |
// ---- persistence --------------------------------------------------------------
|
|
|
|
| 488 |
tick(restored, 0.1);
|
| 489 |
assert.ok(Number.isFinite(ru1.x) && Number.isFinite(ru1.y), "position stays finite even with a garbage anchor/offset");
|
| 490 |
});
|
| 491 |
+
|
| 492 |
+
test("a follow-leader order (and squadLeader/squadFollowers) never survives save/load β no circular reference crash", () => {
|
| 493 |
+
const s = createGameState({ planetId: "ferros", seed: 6 });
|
| 494 |
+
const leader = makeUnit("skiff", "player", 500, 500);
|
| 495 |
+
const follower = makeUnit("skiff", "player", 540, 500);
|
| 496 |
+
s.units.set(leader.id, leader); s.units.set(follower.id, follower);
|
| 497 |
+
issueHoldFormation([leader, follower]);
|
| 498 |
+
|
| 499 |
+
let payload;
|
| 500 |
+
assert.doesNotThrow(() => { payload = JSON.parse(JSON.stringify(serializeGame(s))); },
|
| 501 |
+
"serializing a live squad (a circular leader<->follower reference) must not throw");
|
| 502 |
+
|
| 503 |
+
const restored = deserializeGame(payload);
|
| 504 |
+
const rLeader = [...restored.units.values()].find(u => u.id === leader.id);
|
| 505 |
+
const rFollower = [...restored.units.values()].find(u => u.id === follower.id);
|
| 506 |
+
assert.equal(rFollower.order, null, "the follow-leader order is dropped, not reconstructed");
|
| 507 |
+
assert.equal(rLeader.squadFollowers, undefined);
|
| 508 |
+
assert.equal(rFollower.squadLeader, undefined);
|
| 509 |
+
});
|
|
@@ -87,6 +87,16 @@ function clientFor(controller, wx, wy) {
|
|
| 87 |
return { clientX: VW / 2 + (wx - cam.x) * cam.zoom, clientY: VH / 2 + (wy - cam.y) * cam.zoom };
|
| 88 |
}
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
// ============================================================================
|
| 91 |
// Bug 1 β alt-tab camera drift: a backgrounded tab doesn't reliably get a keyup, so a
|
| 92 |
// pan key held at the moment of alt-tab must not stay "held" forever. A window blur is
|
|
@@ -148,7 +158,7 @@ test("commandAt still notifies on a rally order (regression: the branch that alr
|
|
| 148 |
state.selection = [cc.id];
|
| 149 |
|
| 150 |
const { clientX, clientY } = clientFor(controller, 2500, 2600);
|
| 151 |
-
|
| 152 |
|
| 153 |
assert.deepEqual(cc.rally, { x: 2500, y: 2600, nodeId: null });
|
| 154 |
assert.equal(calls(), 1, "onChange fires exactly once for the order");
|
|
@@ -164,7 +174,7 @@ test("commandAt notifies on an attack order (the headline branch the review flag
|
|
| 164 |
reveal(state.fog, enemy.x, enemy.y); // entityAt only returns a non-player unit if it's visible
|
| 165 |
|
| 166 |
const { clientX, clientY } = clientFor(controller, enemy.x, enemy.y);
|
| 167 |
-
|
| 168 |
|
| 169 |
assert.deepEqual(attacker.order, { type: "attack", targetId: enemy.id });
|
| 170 |
assert.equal(calls(), 1, "the HUD's Attack-Move button (and everything else onChange refreshes) must hear about this immediately, not on a stray HUD tick");
|
|
@@ -179,7 +189,7 @@ test("commandAt notifies on a gather order", () => {
|
|
| 179 |
state.map.nodes.push(node);
|
| 180 |
|
| 181 |
const { clientX, clientY } = clientFor(controller, node.x, node.y);
|
| 182 |
-
|
| 183 |
|
| 184 |
assert.deepEqual(worker.order, { type: "gather", nodeId: "node-1" });
|
| 185 |
assert.equal(calls(), 1);
|
|
@@ -192,12 +202,50 @@ test("commandAt notifies on the plain move fallback (nothing at the click point)
|
|
| 192 |
state.selection = [worker.id];
|
| 193 |
|
| 194 |
const { clientX, clientY } = clientFor(controller, 2800, 1400); // empty ground, no unit/building/node
|
| 195 |
-
|
| 196 |
|
| 197 |
assert.deepEqual(worker.order, { type: "move", x: 2800, y: 1400 });
|
| 198 |
assert.equal(calls(), 1, "the fallback move branch must notify too, not just the rally special case");
|
| 199 |
});
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
// ============================================================================
|
| 202 |
// Bug 3 β dblclick ignored buildMode/attackMoveArmed and ran its normal
|
| 203 |
// reselect-fleet-of-this-type logic regardless, silently overriding the click that had
|
|
|
|
| 87 |
return { clientX: VW / 2 + (wx - cam.x) * cam.zoom, clientY: VH / 2 + (wy - cam.y) * cam.zoom };
|
| 88 |
}
|
| 89 |
|
| 90 |
+
// A real right-click (as of the click-and-drag heading gesture) is a button-2 mousedown on the
|
| 91 |
+
// canvas followed by a button-2 mouseup on window β commandAt now fires from mouseup, not a bare
|
| 92 |
+
// `contextmenu` event (contextmenu's only remaining job is suppressing the browser's native
|
| 93 |
+
// menu). Mouse down and up at the SAME point is a plain click: no drag, so no explicit heading β
|
| 94 |
+
// byte-identical to the old single-event behaviour these tests were written against.
|
| 95 |
+
function rightClick(canvas, window, clientX, clientY, ctrlKey = false) {
|
| 96 |
+
canvas.dispatchEvent(ev("mousedown", { button: 2, clientX, clientY }));
|
| 97 |
+
window.dispatchEvent(ev("mouseup", { button: 2, clientX, clientY, ctrlKey }));
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
// ============================================================================
|
| 101 |
// Bug 1 β alt-tab camera drift: a backgrounded tab doesn't reliably get a keyup, so a
|
| 102 |
// pan key held at the moment of alt-tab must not stay "held" forever. A window blur is
|
|
|
|
| 158 |
state.selection = [cc.id];
|
| 159 |
|
| 160 |
const { clientX, clientY } = clientFor(controller, 2500, 2600);
|
| 161 |
+
rightClick(canvas, window, clientX, clientY);
|
| 162 |
|
| 163 |
assert.deepEqual(cc.rally, { x: 2500, y: 2600, nodeId: null });
|
| 164 |
assert.equal(calls(), 1, "onChange fires exactly once for the order");
|
|
|
|
| 174 |
reveal(state.fog, enemy.x, enemy.y); // entityAt only returns a non-player unit if it's visible
|
| 175 |
|
| 176 |
const { clientX, clientY } = clientFor(controller, enemy.x, enemy.y);
|
| 177 |
+
rightClick(canvas, window, clientX, clientY);
|
| 178 |
|
| 179 |
assert.deepEqual(attacker.order, { type: "attack", targetId: enemy.id });
|
| 180 |
assert.equal(calls(), 1, "the HUD's Attack-Move button (and everything else onChange refreshes) must hear about this immediately, not on a stray HUD tick");
|
|
|
|
| 189 |
state.map.nodes.push(node);
|
| 190 |
|
| 191 |
const { clientX, clientY } = clientFor(controller, node.x, node.y);
|
| 192 |
+
rightClick(canvas, window, clientX, clientY);
|
| 193 |
|
| 194 |
assert.deepEqual(worker.order, { type: "gather", nodeId: "node-1" });
|
| 195 |
assert.equal(calls(), 1);
|
|
|
|
| 202 |
state.selection = [worker.id];
|
| 203 |
|
| 204 |
const { clientX, clientY } = clientFor(controller, 2800, 1400); // empty ground, no unit/building/node
|
| 205 |
+
rightClick(canvas, window, clientX, clientY);
|
| 206 |
|
| 207 |
assert.deepEqual(worker.order, { type: "move", x: 2800, y: 1400 });
|
| 208 |
assert.equal(calls(), 1, "the fallback move branch must notify too, not just the rally special case");
|
| 209 |
});
|
| 210 |
|
| 211 |
+
// ============================================================================
|
| 212 |
+
// Click-and-drag heading: a right-DRAG (mousedown then mouseup at a different point) sets the
|
| 213 |
+
// destination at the DRAG'S START and stamps the drag direction as an explicit facing (a plain
|
| 214 |
+
// click carries none β the two commandAt tests above already cover that byte-identical case).
|
| 215 |
+
// ============================================================================
|
| 216 |
+
|
| 217 |
+
test("a right-drag moves to where the drag STARTED, not where it ended", () => {
|
| 218 |
+
const { state, canvas, controller } = setup();
|
| 219 |
+
const worker = makeUnit("worker", "player", 500, 500);
|
| 220 |
+
state.units.set(worker.id, worker);
|
| 221 |
+
state.selection = [worker.id];
|
| 222 |
+
|
| 223 |
+
const start = clientFor(controller, 2800, 1400);
|
| 224 |
+
const end = clientFor(controller, 3200, 1400); // dragged further along +x
|
| 225 |
+
canvas.dispatchEvent(ev("mousedown", { button: 2, clientX: start.clientX, clientY: start.clientY }));
|
| 226 |
+
window.dispatchEvent(ev("mouseup", { button: 2, clientX: end.clientX, clientY: end.clientY }));
|
| 227 |
+
|
| 228 |
+
assert.deepEqual(worker.order, { type: "move", x: 2800, y: 1400 }, "destination is the drag's START point");
|
| 229 |
+
});
|
| 230 |
+
|
| 231 |
+
test("a right-drag stamps the drag direction as the unit's facing; a plain click clears it", () => {
|
| 232 |
+
const { state, canvas, controller } = setup();
|
| 233 |
+
const worker = makeUnit("worker", "player", 500, 500);
|
| 234 |
+
state.units.set(worker.id, worker);
|
| 235 |
+
state.selection = [worker.id];
|
| 236 |
+
|
| 237 |
+
const start = clientFor(controller, 2800, 1400);
|
| 238 |
+
const end = clientFor(controller, 2800, 1800); // dragged straight down (+y) from the start point
|
| 239 |
+
canvas.dispatchEvent(ev("mousedown", { button: 2, clientX: start.clientX, clientY: start.clientY }));
|
| 240 |
+
window.dispatchEvent(ev("mouseup", { button: 2, clientX: end.clientX, clientY: end.clientY }));
|
| 241 |
+
|
| 242 |
+
assert.ok(Number.isFinite(worker.facing), "the drag set an explicit facing");
|
| 243 |
+
assert.ok(Math.abs(worker.facing - Math.PI / 2) < 1e-6, "facing straight down (+y) is angle PI/2");
|
| 244 |
+
|
| 245 |
+
rightClick(canvas, window, start.clientX, start.clientY); // a later PLAIN click, no drag
|
| 246 |
+
assert.ok(!Number.isFinite(worker.facing), "a plain command with no heading clears the earlier facing");
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
// ============================================================================
|
| 250 |
// Bug 3 β dblclick ignored buildMode/attackMoveArmed and ran its normal
|
| 251 |
// reselect-fleet-of-this-type logic regardless, silently overriding the click that had
|