Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions src/engine/systems/movement/FlockingBehavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,8 +567,7 @@ export class FlockingBehavior {
selfUnit.state === 'attacking';

// Units near friendly combat or with active attack commands don't yield
const selfIsNearCombat =
selfUnit.isNearFriendlyCombat || selfHasAttackCommand;
const selfIsNearCombat = selfUnit.isNearFriendlyCombat || selfHasAttackCommand;

for (let i = 0; i < nearbyData.length; i++) {
const other = nearbyData[i];
Expand Down Expand Up @@ -639,6 +638,25 @@ export class FlockingBehavior {
}
}

// Cap cumulative physics push to prevent explosive spreading in large groups.
// Without this, 20 tightly-packed units generate massive uncapped forces (easily 15-30+)
// while separation caps at 2.0 and cohesion is only 0.1.
const maxPushForce = collisionConfig.separationMaxForce;
const pushMagSq = forceX * forceX + forceY * forceY;
if (pushMagSq > maxPushForce * maxPushForce) {
const pushMag = deterministicMagnitude(forceX, forceY);
const scale = maxPushForce / pushMag;
forceX *= scale;
forceY *= scale;
}

// SC2-style: combat units overlap freely during engagements.
// Reduces physics push so armies stay compact instead of scattering.
if (selfIsNearCombat) {
forceX *= 0.25;
forceY *= 0.25;
}

// PERF: Cache the result
this.physicsPushCache.set(selfId, { x: forceX, y: forceY, tick: this.currentTick });

Expand Down
28 changes: 21 additions & 7 deletions src/engine/systems/movement/MovementOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,8 +600,10 @@ export class MovementOrchestrator {
const directDistanceSq = directDx * directDx + directDy * directDy;

const needsPath =
unit.state === 'moving' || unit.state === 'attackmoving' ||
unit.state === 'gathering' || unit.state === 'building';
unit.state === 'moving' ||
unit.state === 'attackmoving' ||
unit.state === 'gathering' ||
unit.state === 'building';
if (directDistanceSq > 9 && needsPath) {
this.pathfinding.requestPathWithCooldown(entity.id, unit.targetX, unit.targetY);
}
Expand Down Expand Up @@ -690,7 +692,12 @@ export class MovementOrchestrator {
// IDLE REPULSION: Apply separation forces to idle units
// Skip units near friendly combat or in assault mode - they should hold position
// and fight, not drift away from the engagement due to separation forces
if (unit.state === 'idle' && !unit.isFlying && !unit.isNearFriendlyCombat && !unit.isInAssaultMode) {
if (
unit.state === 'idle' &&
!unit.isFlying &&
!unit.isNearFriendlyCombat &&
!unit.isInAssaultMode
) {
const lastPos = this.lastIdlePosition.get(entityId);
const currentIdleTicks = this.trulyIdleTicks.get(entityId) || 0;

Expand Down Expand Up @@ -1116,8 +1123,11 @@ export class MovementOrchestrator {
}

// Add arrival spreading - skip for combat units who should converge, not spread
const isInCombatState = unit.state === 'attackmoving' || unit.state === 'attacking' ||
unit.isInAssaultMode || unit.isNearFriendlyCombat;
const isInCombatState =
unit.state === 'attackmoving' ||
unit.state === 'attacking' ||
unit.isInAssaultMode ||
unit.isNearFriendlyCombat;
const distToFinalTarget =
unit.targetX !== null && unit.targetY !== null
? deterministicMagnitude(unit.targetX - transform.x, unit.targetY - transform.y)
Expand Down Expand Up @@ -1154,10 +1164,13 @@ export class MovementOrchestrator {
}

// Add cohesion and alignment
// Include 'attacking' so out-of-range units maintain group cohesion while closing.
// In-range attacking units never reach here (processAttackingUnit returns skipMovement=true).
if (
unit.state === 'moving' ||
unit.state === 'attackmoving' ||
unit.state === 'patrolling'
unit.state === 'patrolling' ||
unit.state === 'attacking'
) {
if (useWasmThisFrame) {
const wasmForces = this.wasmBoids!.getForces(entityId);
Expand Down Expand Up @@ -1281,7 +1294,8 @@ export class MovementOrchestrator {
if (
unit.state === 'moving' ||
unit.state === 'attackmoving' ||
unit.state === 'patrolling'
unit.state === 'patrolling' ||
unit.state === 'attacking'
) {
finalVx += tempCohesion.x;
finalVy += tempCohesion.y;
Expand Down
110 changes: 108 additions & 2 deletions tests/engine/systems/movement/flockingBehavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,10 @@ describe('FlockingBehavior', () => {
});

it('returns combat strength for idle units near friendly combat', () => {
const nearCombatUnit = createTestUnit({ state: 'idle', isNearFriendlyCombat: true } as Partial<UnitData>);
const nearCombatUnit = createTestUnit({
state: 'idle',
isNearFriendlyCombat: true,
} as Partial<UnitData>);
const regularIdleUnit = createTestUnit({ state: 'idle' });
const attackingUnit = createTestUnit({ state: 'attacking' });

Expand All @@ -260,7 +263,10 @@ describe('FlockingBehavior', () => {
});

it('returns combat strength for assault mode units', () => {
const assaultUnit = createTestUnit({ state: 'idle', isInAssaultMode: true } as Partial<UnitData>);
const assaultUnit = createTestUnit({
state: 'idle',
isInAssaultMode: true,
} as Partial<UnitData>);
const attackingUnit = createTestUnit({ state: 'attacking' });

const assaultStrength = flocking.getSeparationStrength(assaultUnit, 10);
Expand Down Expand Up @@ -762,6 +768,106 @@ describe('FlockingBehavior', () => {
expect(out.y).toBe(0);
});

it('caps cumulative physics push force from many neighbors', () => {
const selfTransform = createTestTransform(0, 0);
const selfUnit = createTestUnit({ state: 'idle', collisionRadius: 0.5 });
const out = createOutputVector();

// Surround the unit with many overlapping neighbors to generate large cumulative force
const entities = new Map<number, { x: number; y: number; data: SpatialEntityData }>();
entities.set(1, { x: 0, y: 0, data: createSpatialData(1, selfTransform, selfUnit) });

// Place 10 neighbors tightly packed around self
for (let i = 0; i < 10; i++) {
const angle = (i / 10) * Math.PI * 2;
const nx = Math.cos(angle) * 0.3; // Very close - overlapping
const ny = Math.sin(angle) * 0.3;
const neighborUnit = createTestUnit({ state: 'idle', collisionRadius: 0.5 });
const neighborTransform = createTestTransform(nx, ny);
entities.set(100 + i, {
x: nx,
y: ny,
data: createSpatialData(100 + i, neighborTransform, neighborUnit),
});
}

const grid = createMockSpatialGrid(entities);
flocking.calculatePhysicsPush(1, selfTransform, selfUnit, out, grid);

// Cumulative push should be capped (maxForce = 2.0 from separation config)
const forceMag = Math.sqrt(out.x * out.x + out.y * out.y);
expect(forceMag).toBeLessThanOrEqual(2.1); // Small tolerance for floating point
});

it('reduces physics push for combat units (attacking state)', () => {
const selfTransform = createTestTransform(0, 0);
const idleUnit = createTestUnit({ state: 'idle', collisionRadius: 0.5 });
const attackingUnit = createTestUnit({ state: 'attacking', collisionRadius: 0.5 });
const idleOut = createOutputVector();
const attackingOut = createOutputVector();

// Same neighbor for both
const neighborUnit = createTestUnit({ state: 'idle', collisionRadius: 0.5 });
const neighborTransform = createTestTransform(0.5, 0);

const entities = new Map<number, { x: number; y: number; data: SpatialEntityData }>();
entities.set(1, { x: 0, y: 0, data: createSpatialData(1, selfTransform, idleUnit) });
entities.set(2, {
x: 0.5,
y: 0,
data: createSpatialData(2, neighborTransform, neighborUnit),
});

const grid = createMockSpatialGrid(entities);

// Calculate push for idle unit
flocking.calculatePhysicsPush(1, selfTransform, idleUnit, idleOut, grid);

// Need a new flocking instance or advance tick to avoid cache
flocking.setCurrentTick(100);

// Calculate push for attacking unit
flocking.calculatePhysicsPush(1, selfTransform, attackingUnit, attackingOut, grid);

const idleForceMag = Math.sqrt(idleOut.x * idleOut.x + idleOut.y * idleOut.y);
const attackingForceMag = Math.sqrt(
attackingOut.x * attackingOut.x + attackingOut.y * attackingOut.y
);

// Attacking unit should receive significantly less physics push
expect(attackingForceMag).toBeLessThan(idleForceMag);
});

it('reduces physics push for assault mode units', () => {
const selfTransform = createTestTransform(0, 0);
const assaultUnit = createTestUnit({
state: 'idle',
collisionRadius: 0.5,
isInAssaultMode: true,
} as Partial<UnitData>);
const out = createOutputVector();

const neighborUnit = createTestUnit({ state: 'idle', collisionRadius: 0.5 });
const neighborTransform = createTestTransform(0.5, 0);

const entities = new Map<number, { x: number; y: number; data: SpatialEntityData }>();
entities.set(1, { x: 0, y: 0, data: createSpatialData(1, selfTransform, assaultUnit) });
entities.set(2, {
x: 0.5,
y: 0,
data: createSpatialData(2, neighborTransform, neighborUnit),
});

const grid = createMockSpatialGrid(entities);
flocking.calculatePhysicsPush(1, selfTransform, assaultUnit, out, grid);

// Assault mode units get combat reduction (0.25x), so force should be small
const forceMag = Math.sqrt(out.x * out.x + out.y * out.y);
// Without combat reduction, overlapping push would be > 1.0
// With 0.25 reduction, it should be <= 0.5 for a single neighbor
expect(forceMag).toBeLessThan(1.0);
});

it('applies priority-based pushing (moving pushes idle)', () => {
const idleTransform = createTestTransform(0, 0);
const idleUnit = createTestUnit({ state: 'idle', collisionRadius: 0.5 });
Expand Down
Loading