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
4 changes: 4 additions & 0 deletions src/engine/components/unit/UnitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
6 changes: 4 additions & 2 deletions src/engine/systems/AIMicroSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
105 changes: 105 additions & 0 deletions src/engine/systems/CombatSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> = 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<number> = new Set();
Expand Down Expand Up @@ -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>('Unit');
if (unit) {
unit.isNearFriendlyCombat = false;
}
}

this.unitsNearFriendlyCombat.clear();

// First, collect all units currently in attacking state by player
const attackingUnitsByPlayer: Map<string, Array<{ x: number; y: number; sightRange: number }>> =
new Map();

const units = this.world.getEntitiesWith('Transform', 'Unit', 'Health', 'Selectable');
for (const entity of units) {
const unit = entity.get<Unit>('Unit');
const health = entity.get<Health>('Health');
const selectable = entity.get<Selectable>('Selectable');
const transform = entity.get<Transform>('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>('Unit');
const health = entity.get<Health>('Health');
const selectable = entity.get<Selectable>('Selectable');
Comment on lines +241 to +245

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict near-combat marking to idle units

The loop is labeled “for each idle unit,” but there’s no state filter here, so moving/attackmoving units are also marked isNearFriendlyCombat. That flag is later used by FlockingBehavior to change cohesion/priority, which can pull pure move-command units toward nearby combat even though the commit’s goal is to keep move commands from being influenced by enemies. This only happens when a moving unit is within friendly-attacker sight range, but it reintroduces combat drift for those move commands. Consider gating this block with unit.state === 'idle' (or whatever states you intend to be affected).

Useful? React with 👍 / 👎.

const transform = entity.get<Transform>('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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 48 additions & 5 deletions src/engine/systems/movement/FlockingBehavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Loading