From 3fc81cb1006bf2ca16e7ebefc7069d67fa6b196d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Feb 2026 15:44:32 +0000 Subject: [PATCH] fix: cap physics push and reduce combat spreading to prevent unit scattering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: physics push had no cumulative force cap. With 20 tightly-packed units, each unit received up to 3.0-4.5 force per neighbor × 8-12 neighbors = 15-36 total force, while separation capped at 2.0 and cohesion was 0.1. When units transitioned from attackmoving to attacking, they lost cohesion/ alignment forces, leaving uncapped physics push as the dominant force. Three fixes: 1. Cap cumulative physics push force (same 2.0 cap as separation) 2. Reduce physics push 75% for combat units (SC2-style tight grouping) 3. Extend cohesion/alignment to attacking units closing to range https://claude.ai/code/session_012Sc49F3UHGXxttLJpBPggB --- .../systems/movement/FlockingBehavior.ts | 22 +++- .../systems/movement/MovementOrchestrator.ts | 28 +++-- .../systems/movement/flockingBehavior.test.ts | 110 +++++++++++++++++- 3 files changed, 149 insertions(+), 11 deletions(-) diff --git a/src/engine/systems/movement/FlockingBehavior.ts b/src/engine/systems/movement/FlockingBehavior.ts index 5b048c49..d7069867 100644 --- a/src/engine/systems/movement/FlockingBehavior.ts +++ b/src/engine/systems/movement/FlockingBehavior.ts @@ -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]; @@ -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 }); diff --git a/src/engine/systems/movement/MovementOrchestrator.ts b/src/engine/systems/movement/MovementOrchestrator.ts index 297d7e99..9f50df20 100644 --- a/src/engine/systems/movement/MovementOrchestrator.ts +++ b/src/engine/systems/movement/MovementOrchestrator.ts @@ -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); } @@ -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; @@ -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) @@ -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); @@ -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; diff --git a/tests/engine/systems/movement/flockingBehavior.test.ts b/tests/engine/systems/movement/flockingBehavior.test.ts index 6a291437..2ebabd71 100644 --- a/tests/engine/systems/movement/flockingBehavior.test.ts +++ b/tests/engine/systems/movement/flockingBehavior.test.ts @@ -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); + const nearCombatUnit = createTestUnit({ + state: 'idle', + isNearFriendlyCombat: true, + } as Partial); const regularIdleUnit = createTestUnit({ state: 'idle' }); const attackingUnit = createTestUnit({ state: 'attacking' }); @@ -260,7 +263,10 @@ describe('FlockingBehavior', () => { }); it('returns combat strength for assault mode units', () => { - const assaultUnit = createTestUnit({ state: 'idle', isInAssaultMode: true } as Partial); + const assaultUnit = createTestUnit({ + state: 'idle', + isInAssaultMode: true, + } as Partial); const attackingUnit = createTestUnit({ state: 'attacking' }); const assaultStrength = flocking.getSeparationStrength(assaultUnit, 10); @@ -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(); + 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(); + 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); + const out = createOutputVector(); + + const neighborUnit = createTestUnit({ state: 'idle', collisionRadius: 0.5 }); + const neighborTransform = createTestTransform(0.5, 0); + + const entities = new Map(); + 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 });