diff --git a/src/engine/components/unit/UnitCore.ts b/src/engine/components/unit/UnitCore.ts index 4a308d51..6d7b2878 100644 --- a/src/engine/components/unit/UnitCore.ts +++ b/src/engine/components/unit/UnitCore.ts @@ -47,6 +47,10 @@ export class UnitCore extends Component { public isBiological: boolean; public isMechanical: boolean; + // RTS-STYLE: Flag set by CombatSystem when friendly allies are fighting nearby + // Used by FlockingBehavior to reduce separation and enable cohesion toward battle + public isNearFriendlyCombat: boolean = false; + // Vision public sightRange: number; diff --git a/src/engine/systems/AIMicroSystem.ts b/src/engine/systems/AIMicroSystem.ts index 11e8c790..86f41170 100644 --- a/src/engine/systems/AIMicroSystem.ts +++ b/src/engine/systems/AIMicroSystem.ts @@ -338,9 +338,10 @@ export class AIMicroSystem extends System { // Process units in combat-related states // RTS-STYLE: Also process idle units in assault mode - they need micro to find targets + // NOTE: 'moving' state is NOT included - pure move commands should not trigger auto-attack + // This matches SC2 behavior where move commands ignore enemies const canProcessUnit = unit.state === 'attacking' || - unit.state === 'moving' || unit.state === 'attackmoving' || (unit.canTransform && unit.state === 'idle') || (unit.isInAssaultMode && unit.state === 'idle'); @@ -393,9 +394,10 @@ export class AIMicroSystem extends System { // Process units in combat-related states // attackmoving = moving to position while engaging enemies along the way // RTS-STYLE: Also process idle units in assault mode - they need micro to find targets + // NOTE: 'moving' state is NOT included - pure move commands should not trigger auto-attack + // This matches SC2 behavior where move commands ignore enemies const canProcessUnit = unit.state === 'attacking' || - unit.state === 'moving' || unit.state === 'attackmoving' || (unit.canTransform && unit.state === 'idle') || (unit.isInAssaultMode && unit.state === 'idle'); diff --git a/src/engine/systems/CombatSystem.ts b/src/engine/systems/CombatSystem.ts index cf494d45..9a0d8af4 100644 --- a/src/engine/systems/CombatSystem.ts +++ b/src/engine/systems/CombatSystem.ts @@ -108,6 +108,12 @@ export class CombatSystem extends System { // RTS-STYLE: Reduced from 15 to 5 ticks for more responsive combat detection private readonly COMBAT_ZONE_CHECK_INTERVAL = 5; // Re-check zone every 5 ticks (~250ms) + // RTS-STYLE: Track units near friendly combat - these units should engage aggressively + // This enables SC2-style "all units engage" behavior when allies are fighting nearby + private unitsNearFriendlyCombat: Set = new Set(); + private friendlyCombatCheckTick: number = 0; + private readonly FRIENDLY_COMBAT_CHECK_INTERVAL = 5; // Re-check every 5 ticks + // PERF OPTIMIZATION: Combat-active entity list // Only units in this set are processed for target acquisition private combatActiveUnits: Set = new Set(); @@ -184,6 +190,92 @@ export class CombatSystem extends System { this.hotCellsLastUpdate = currentTick; } + /** + * RTS-STYLE: Update the set of units that have friendly allies fighting nearby. + * These units should be "combat aware" and use aggressive sight-range targeting. + * This creates SC2-style "join nearby combat" behavior where all units engage. + * Also sets the isNearFriendlyCombat flag on Unit components for FlockingBehavior. + */ + private updateUnitsNearFriendlyCombat(currentTick: number): void { + if (currentTick - this.friendlyCombatCheckTick < this.FRIENDLY_COMBAT_CHECK_INTERVAL) { + return; + } + this.friendlyCombatCheckTick = currentTick; + + // Clear the flag on all units first (will be re-set below for those near combat) + const allUnits = this.world.getEntitiesWith('Unit'); + for (const entity of allUnits) { + const unit = entity.get('Unit'); + if (unit) { + unit.isNearFriendlyCombat = false; + } + } + + this.unitsNearFriendlyCombat.clear(); + + // First, collect all units currently in attacking state by player + const attackingUnitsByPlayer: Map> = + new Map(); + + const units = this.world.getEntitiesWith('Transform', 'Unit', 'Health', 'Selectable'); + for (const entity of units) { + const unit = entity.get('Unit'); + const health = entity.get('Health'); + const selectable = entity.get('Selectable'); + const transform = entity.get('Transform'); + + if (!unit || !health || !selectable || !transform) continue; + if (health.isDead()) continue; + + // Track attacking units by player + if (unit.state === 'attacking' && unit.targetEntityId !== null) { + let playerAttackers = attackingUnitsByPlayer.get(selectable.playerId); + if (!playerAttackers) { + playerAttackers = []; + attackingUnitsByPlayer.set(selectable.playerId, playerAttackers); + } + playerAttackers.push({ x: transform.x, y: transform.y, sightRange: unit.sightRange }); + } + } + + // Now, for each idle unit, check if friendly units are fighting nearby + for (const entity of units) { + const unit = entity.get('Unit'); + const health = entity.get('Health'); + const selectable = entity.get('Selectable'); + const transform = entity.get('Transform'); + + if (!unit || !health || !selectable || !transform) continue; + if (health.isDead()) continue; + + // Skip workers + if (unit.isWorker) continue; + // Skip units that can't attack + if (unit.attackRange <= 0) continue; + + // Check if any friendly units are fighting within this unit's sight range + const playerAttackers = attackingUnitsByPlayer.get(selectable.playerId); + if (!playerAttackers || playerAttackers.length === 0) continue; + + for (const attacker of playerAttackers) { + const dx = transform.x - attacker.x; + const dy = transform.y - attacker.y; + const distSq = dx * dx + dy * dy; + // Use the larger of the two sight ranges to determine "nearby" + const combatAwarenessRange = Math.max(unit.sightRange, attacker.sightRange); + const rangeSq = combatAwarenessRange * combatAwarenessRange; + + if (distSq <= rangeSq) { + // This unit is near friendly combat - mark it as combat aware + this.unitsNearFriendlyCombat.add(entity.id); + // Set the flag on the Unit component for FlockingBehavior to use + unit.isNearFriendlyCombat = true; + break; + } + } + } + } + /** * PERF OPTIMIZATION: Rebuild the combat-active unit list * Only units in hot cells or with active targets are tracked @@ -261,6 +353,13 @@ export class CombatSystem extends System { } } } + + // RTS-STYLE: Include idle units that are near friendly combat + // This enables SC2-style "join nearby combat" behavior + if (this.unitsNearFriendlyCombat.has(entity.id)) { + this.combatActiveUnits.add(entity.id); + continue; + } } this.combatActiveLastUpdate = currentTick; @@ -476,6 +575,8 @@ export class CombatSystem extends System { // PERF OPTIMIZATION: Update hot cells and combat-active unit list this.updateHotCells(currentTick); + // RTS-STYLE: Track units near friendly combat before updating combat-active list + this.updateUnitsNearFriendlyCombat(currentTick); this.updateCombatActiveUnits(currentTick); // PERF OPTIMIZATION: Rebuild attack queue if dirty @@ -571,6 +672,10 @@ export class CombatSystem extends System { `[CombatSystem] Unit ${attacker.id} cleared assault mode after ${ASSAULT_IDLE_TIMEOUT} ticks idle` ); } + } else if (this.unitsNearFriendlyCombat.has(attacker.id)) { + // RTS-STYLE: Units near friendly combat use aggressive sight-range search + // This enables SC2-style "join nearby combat" - all units engage, not just front line + target = this.findBestTargetSpatial(attacker.id, transform, unit); } else if (unit.state === 'idle' || unit.isHoldingPosition) { // For regular idle units, do a fast check for enemies within ATTACK range // Uses light throttle (1 tick = ~50ms) for performance while staying responsive diff --git a/src/engine/systems/movement/FlockingBehavior.ts b/src/engine/systems/movement/FlockingBehavior.ts index 9bce5348..62051809 100644 --- a/src/engine/systems/movement/FlockingBehavior.ts +++ b/src/engine/systems/movement/FlockingBehavior.ts @@ -150,6 +150,9 @@ export class FlockingBehavior { * Get state-dependent separation strength. * RTS style: weak while moving (allow clumping), strong when idle/attacking (spread out). * Attacking units actively spread apart while firing. + * + * RTS-STYLE: Units near friendly combat use combat separation (low) instead of idle (high). + * This prevents back units from spreading away from the fight. */ public getSeparationStrength(unit: Unit, distanceToTarget: number): number { // Workers gathering/building have no separation @@ -172,6 +175,12 @@ export class FlockingBehavior { return collisionConfig.separationStrengthMoving; } + // RTS-STYLE: Idle units near friendly combat use combat-level separation + // This prevents back units from spreading away while front units fight + if (unit.isNearFriendlyCombat) { + return collisionConfig.separationStrengthCombat; + } + // Idle: gentle spreading over time return collisionConfig.separationStrengthIdle; } @@ -283,6 +292,9 @@ export class FlockingBehavior { * Calculate cohesion force - steers toward the average position of nearby units. * Keeps groups together but with very weak force (RTS style). * PERF: Results are cached and only recalculated every COHESION_THROTTLE_TICKS ticks + * + * RTS-STYLE: Units near friendly combat DO get cohesion toward attacking allies. + * This pulls back units toward the fight instead of letting them drift away. */ public calculateCohesionForce( selfId: number, @@ -294,8 +306,14 @@ export class FlockingBehavior { out.x = 0; out.y = 0; - // No cohesion for workers or idle units - if (selfUnit.isWorker || selfUnit.state === 'idle' || selfUnit.state === 'gathering') { + // No cohesion for workers or gathering units + if (selfUnit.isWorker || selfUnit.state === 'gathering') { + return; + } + + // Regular idle units have no cohesion, BUT units near friendly combat DO + // This creates a pull toward the battle for back-line units + if (selfUnit.state === 'idle' && !selfUnit.isNearFriendlyCombat) { return; } @@ -327,7 +345,20 @@ export class FlockingBehavior { // Use inline data - no entity lookups needed! if (other.state === SpatialUnitState.Dead) continue; if (selfUnit.isFlying !== other.isFlying) continue; - // Only cohere with units in same state + + // RTS-STYLE: Units near friendly combat cohere toward attacking allies + // This pulls idle back units toward the front line + if (selfUnit.isNearFriendlyCombat) { + // Cohere toward attacking units (the battle) + if (other.state === SpatialUnitState.Attacking) { + sumX += other.x; + sumY += other.y; + count++; + } + continue; + } + + // Normal cohesion: Only cohere with units in same state if (other.state !== selfState) continue; sumX += other.x; @@ -510,6 +541,10 @@ export class FlockingBehavior { selfUnit.state === 'gathering' || selfUnit.state === 'building'; + // RTS-STYLE: Units near friendly combat should not yield to moving units + // This prevents back units from being pushed away from the fight + const selfIsNearCombat = selfUnit.isNearFriendlyCombat || selfUnit.state === 'attacking'; + for (let i = 0; i < nearbyData.length; i++) { const other = nearbyData[i]; if (other.id === selfId) continue; @@ -537,11 +572,19 @@ export class FlockingBehavior { other.state === SpatialUnitState.AttackMoving || other.state === SpatialUnitState.Gathering; + // RTS-STYLE: Check if the other unit is also in combat/near combat + // We need to check the attacking state from inline data + const otherIsInCombat = other.state === SpatialUnitState.Attacking; + // Priority multiplier: if I'm idle and they're moving, I get pushed more (yield) // If I'm moving and they're idle, I push them but they don't push me much + // RTS-STYLE EXCEPTION: Units near combat don't yield - they hold their ground let priorityMultiplier = 1.0; - if (!selfIsMoving && otherIsMoving) { - // I'm idle, they're moving - I yield (get pushed more) + if (selfIsNearCombat) { + // I'm in combat or near combat - I don't yield, equal push with everyone + priorityMultiplier = 1.0; + } else if (!selfIsMoving && otherIsMoving && !otherIsInCombat) { + // I'm idle, they're moving (not in combat) - I yield (get pushed more) priorityMultiplier = 1.5; } else if (selfIsMoving && !otherIsMoving) { // I'm moving, they're idle - push through them (they should yield, not me)