diff --git a/src/engine/components/Unit.ts b/src/engine/components/Unit.ts index 2d236d04..522b3b5e 100644 --- a/src/engine/components/Unit.ts +++ b/src/engine/components/Unit.ts @@ -201,6 +201,12 @@ export class Unit extends Component { public isBiological: boolean; public isMechanical: boolean; + // SC2-style Assault Mode: Persistent attack-move that doesn't go idle + // When set, unit stays aggressive even after reaching destination + public assaultDestination: { x: number; y: number } | null; + public isInAssaultMode: boolean; // True when unit is executing attack-move command + public assaultIdleTicks: number; // How long unit has been idle at assault destination + // Targeting restrictions - which types of units this unit can attack public canAttackGround: boolean; public canAttackAir: boolean; @@ -319,6 +325,11 @@ export class Unit extends Component { this.isBiological = definition.isBiological ?? !definition.isMechanical; this.isMechanical = definition.isMechanical ?? false; + // SC2-style assault mode - persistent attack-move + this.assaultDestination = null; + this.isInAssaultMode = false; + this.assaultIdleTicks = 0; + // Targeting restrictions - default: can attack ground if has damage, can't attack air by default const hasDamage = definition.attackDamage > 0; this.canAttackGround = definition.canAttackGround ?? hasDamage; @@ -400,6 +411,10 @@ export class Unit extends Component { this.state = 'moving'; } this.targetEntityId = null; + // SC2-style: Regular move clears assault mode (explicit move command overrides attack-move) + this.assaultDestination = null; + this.isInAssaultMode = false; + this.assaultIdleTicks = 0; } // Move to position while preserving current state (for gathering, etc.) @@ -427,11 +442,16 @@ export class Unit extends Component { } // Attack-move: move toward a position while engaging enemies along the way + // SC2-style: Sets assault mode so unit stays aggressive even after arriving public setAttackMoveTarget(x: number, y: number): void { this.targetX = x; this.targetY = y; this.state = 'attackmoving'; this.targetEntityId = null; + // SC2-style: Enable assault mode - unit will keep scanning for targets + this.assaultDestination = { x, y }; + this.isInAssaultMode = true; + this.assaultIdleTicks = 0; } public setPath(path: Array<{ x: number; y: number }>): void { @@ -455,6 +475,10 @@ export class Unit extends Component { this.patrolPoints = []; this.isHoldingPosition = false; this.currentSpeed = 0; + // SC2-style: Explicit stop clears assault mode + this.assaultDestination = null; + this.isInAssaultMode = false; + this.assaultIdleTicks = 0; } public holdPosition(): void { @@ -463,6 +487,10 @@ export class Unit extends Component { this.patrolPoints = []; this.isHoldingPosition = true; this.currentSpeed = 0; + // SC2-style: Hold position clears assault mode + this.assaultDestination = null; + this.isInAssaultMode = false; + this.assaultIdleTicks = 0; } public canAttack(gameTime: number): boolean { diff --git a/src/engine/systems/AIMicroSystem.ts b/src/engine/systems/AIMicroSystem.ts index 17969752..f55579da 100644 --- a/src/engine/systems/AIMicroSystem.ts +++ b/src/engine/systems/AIMicroSystem.ts @@ -334,8 +334,10 @@ export class AIMicroSystem extends System { if (unit.isWorker) continue; // Process units in combat-related states + // SC2-STYLE: Also process idle units in assault mode - they need micro to find targets const canProcessUnit = unit.state === 'attacking' || unit.state === 'moving' || - unit.state === 'attackmoving' || (unit.canTransform && unit.state === 'idle'); + unit.state === 'attackmoving' || (unit.canTransform && unit.state === 'idle') || + (unit.isInAssaultMode && unit.state === 'idle'); if (!canProcessUnit) continue; // Get or create micro state @@ -384,8 +386,10 @@ export class AIMicroSystem extends System { // Process units in combat-related states // attackmoving = moving to position while engaging enemies along the way + // SC2-STYLE: Also process idle units in assault mode - they need micro to find targets const canProcessUnit = unit.state === 'attacking' || unit.state === 'moving' || - unit.state === 'attackmoving' || (unit.canTransform && unit.state === 'idle'); + unit.state === 'attackmoving' || (unit.canTransform && unit.state === 'idle') || + (unit.isInAssaultMode && unit.state === 'idle'); if (!canProcessUnit) continue; // Get or create micro state diff --git a/src/engine/systems/CombatSystem.ts b/src/engine/systems/CombatSystem.ts index 0733fdd3..ac4c5ab2 100644 --- a/src/engine/systems/CombatSystem.ts +++ b/src/engine/systems/CombatSystem.ts @@ -184,6 +184,9 @@ export class CombatSystem extends System { /** * PERF OPTIMIZATION: Rebuild the combat-active unit list * Only units in hot cells or with active targets are tracked + * + * SC2-STYLE: Units in assault mode are ALWAYS combat-active and never throttled. + * They continue scanning for enemies even when idle at their destination. */ private updateCombatActiveUnits(currentTick: number): void { if (currentTick - this.combatActiveLastUpdate < this.COMBAT_ACTIVE_UPDATE_INTERVAL) { @@ -205,6 +208,13 @@ export class CombatSystem extends System { continue; } + // SC2-STYLE: Always include assault mode units - they never stop scanning + // This is the key fix for idle units in enemy bases + if (unit.isInAssaultMode) { + this.combatActiveUnits.add(entity.id); + continue; + } + // Include units in attack-move or patrolling states if (unit.state === 'attackmoving' || unit.state === 'patrolling') { this.combatActiveUnits.add(entity.id); @@ -477,18 +487,21 @@ export class CombatSystem extends System { // Auto-acquire targets for units that need them // canAttackWhileMoving units also acquire targets while moving + // SC2-STYLE: Assault mode units ALWAYS need to acquire targets when idle const needsTarget = unit.targetEntityId === null && ( unit.state === 'idle' || unit.state === 'patrolling' || unit.state === 'attackmoving' || unit.state === 'attacking' || unit.isHoldingPosition || + unit.isInAssaultMode || // SC2-STYLE: Assault mode units always scan (unit.canAttackWhileMoving && unit.state === 'moving') ); if (needsTarget) { // PERF: Use hot cell check instead of expensive spatial query for idle units - if (unit.state === 'idle' && !unit.isHoldingPosition) { + // SC2-STYLE: Skip this optimization for assault mode units - they always search + if (unit.state === 'idle' && !unit.isHoldingPosition && !unit.isInAssaultMode) { // Fast check: is this unit in a hot cell? const inHotCell = this.world.unitGrid.isInHotCell(transform.x, transform.y, this.hotCells); if (!inHotCell) { @@ -503,9 +516,15 @@ export class CombatSystem extends System { let target: number | null = null; - // For idle units, do a fast check for enemies within ATTACK range - // Uses light throttle (1 tick = ~50ms) for performance while staying responsive - if (unit.state === 'idle' || unit.isHoldingPosition) { + // SC2-STYLE: Assault mode units always search at sight range, no throttling + if (unit.isInAssaultMode && unit.state === 'idle') { + // Aggressive sight-range search for assault mode units + target = this.findBestTargetSpatial(attacker.id, transform, unit); + // Track idle time for assault mode units + unit.assaultIdleTicks++; + } 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 target = this.findImmediateAttackTarget(attacker.id, transform, unit, currentTick); } @@ -523,6 +542,9 @@ export class CombatSystem extends System { const savedTargetX = unit.targetX; const savedTargetY = unit.targetY; const wasAttackMoving = unit.state === 'attackmoving'; + // SC2-STYLE: Remember assault destination + const savedAssaultDest = unit.assaultDestination; + const wasInAssaultMode = unit.isInAssaultMode; unit.setAttackTarget(target); @@ -531,6 +553,13 @@ export class CombatSystem extends System { unit.targetX = savedTargetX; unit.targetY = savedTargetY; } + + // SC2-STYLE: Preserve assault mode through target acquisition + if (wasInAssaultMode && savedAssaultDest) { + unit.assaultDestination = savedAssaultDest; + unit.isInAssaultMode = true; + unit.assaultIdleTicks = 0; // Reset idle counter - we found a target + } } } else if (target && unit.isHoldingPosition) { // Holding position units only attack if in range (already confirmed by findImmediateAttackTarget) @@ -558,6 +587,12 @@ export class CombatSystem extends System { // Resume attack-move to destination unit.state = 'attackmoving'; unit.targetEntityId = null; + } else if (unit.isInAssaultMode) { + // SC2-STYLE: Assault mode units stay in assault mode, ready to scan for new targets + // They go "idle" but with assault mode flag still set, so they keep scanning + unit.targetEntityId = null; + unit.state = 'idle'; + // Don't clear assault mode - unit will immediately scan for new targets next tick } else if (!unit.executeNextCommand()) { unit.clearTarget(); } @@ -578,6 +613,11 @@ export class CombatSystem extends System { // Resume attack-move to destination unit.state = 'attackmoving'; unit.targetEntityId = null; + } else if (unit.isInAssaultMode) { + // SC2-STYLE: Assault mode units stay aggressive and keep scanning + unit.targetEntityId = null; + unit.state = 'idle'; + // Assault mode preserved - unit will scan for new targets } else if (!unit.executeNextCommand()) { unit.clearTarget(); } @@ -599,6 +639,10 @@ export class CombatSystem extends System { } else if (unit.targetX !== null && unit.targetY !== null) { unit.state = 'attackmoving'; unit.targetEntityId = null; + } else if (unit.isInAssaultMode) { + // SC2-STYLE: Stay aggressive, find a target we CAN attack + unit.targetEntityId = null; + unit.state = 'idle'; } else if (!unit.executeNextCommand()) { unit.clearTarget(); } diff --git a/src/engine/systems/ai/AITacticsManager.ts b/src/engine/systems/ai/AITacticsManager.ts index 9e57b4b4..e66d4ed6 100644 --- a/src/engine/systems/ai/AITacticsManager.ts +++ b/src/engine/systems/ai/AITacticsManager.ts @@ -3,13 +3,20 @@ * * Handles: * - Tactical state determination (attacking, defending, harassing, etc.) - * - Attack phase execution + * - Attack phase execution with SC2-style engagement persistence * - Defense phase execution * - Harass phase execution * - Expand phase execution * - Army rallying and unit coordination + * - Hunt mode for finishing off enemies (victory pursuit) * * Works with AIMicroSystem for unit-level micro (kiting, focus fire). + * + * SC2-STYLE IMPROVEMENTS: + * - Army stays in attacking state while engaged + * - Idle assault units get re-commanded to continue fighting + * - Hunt mode activates when enemy has few buildings left + * - Actively seeks out remaining enemy buildings to trigger victory */ import { Transform } from '../../components/Transform'; @@ -24,10 +31,21 @@ import type { AICoordinator, AIPlayer, AIState } from './AICoordinator'; // Threat assessment constants const THREAT_WINDOW_TICKS = 200; // ~10 seconds at 20 ticks/sec +// SC2-style engagement tracking constants +const ENGAGEMENT_CHECK_INTERVAL = 10; // Check engagement every 10 ticks (~500ms) +const RE_COMMAND_IDLE_INTERVAL = 40; // Re-command idle units every 40 ticks (~2 sec) +const HUNT_MODE_BUILDING_THRESHOLD = 3; // Enter hunt mode when enemy has <= 3 buildings +const ASSAULT_IDLE_TIMEOUT_TICKS = 100; // If assault unit idle for 5 sec, consider it "stuck" + export class AITacticsManager { private game: Game; private coordinator: AICoordinator; + // SC2-style engagement tracking per AI player + private lastEngagementCheck: Map = new Map(); + private lastReCommandTick: Map = new Map(); + private isEngaged: Map = new Map(); + constructor(game: Game, coordinator: AICoordinator) { this.game = game; this.coordinator = coordinator; @@ -282,38 +300,126 @@ export class AITacticsManager { /** * Execute the attacking phase. + * + * SC2-STYLE: This is a persistent engagement system, not fire-and-forget. + * - Tracks whether army is currently engaged in combat + * - Re-commands idle assault units to continue fighting + * - Enters hunt mode when enemy has few buildings left + * - Only returns to building state when battle is over */ public executeAttackingPhase(ai: AIPlayer, currentTick: number): void { - ai.lastAttackTick = currentTick; - const armyUnits = this.getArmyUnits(ai.playerId); if (armyUnits.length === 0) { ai.state = 'building'; + this.isEngaged.set(ai.playerId, false); return; } - // Find target - const enemyBase = this.findEnemyBase(ai); - if (!enemyBase) { - ai.state = 'building'; - return; + // Count enemy buildings to determine if we should enter hunt mode + const enemyBuildingCount = this.countEnemyBuildings(ai); + + // SC2-STYLE: Check if we should enter hunt mode + const inHuntMode = enemyBuildingCount > 0 && enemyBuildingCount <= HUNT_MODE_BUILDING_THRESHOLD; + + // Check engagement status periodically + const lastCheck = this.lastEngagementCheck.get(ai.playerId) || 0; + if (currentTick - lastCheck >= ENGAGEMENT_CHECK_INTERVAL) { + const engaged = this.checkEngagementStatus(ai, armyUnits); + this.isEngaged.set(ai.playerId, engaged); + this.lastEngagementCheck.set(ai.playerId, currentTick); } - // Issue attack command - const command: GameCommand = { - tick: currentTick, - playerId: ai.playerId, - type: 'ATTACK', - entityIds: armyUnits, - targetPosition: enemyBase, - }; + const engaged = this.isEngaged.get(ai.playerId) || false; - this.game.processCommand(command); + // Find target - use different strategy based on mode + let attackTarget: { x: number; y: number } | null = null; - debugAI.log(`[AITactics] ${ai.playerId}: Attacking with ${armyUnits.length} units`); + if (inHuntMode) { + // SC2-STYLE HUNT MODE: Find ANY enemy building, spread units to hunt + attackTarget = this.findAnyEnemyBuilding(ai); + if (!attackTarget) { + // No buildings found - enemy defeated or buildings are hidden + ai.state = 'building'; + this.isEngaged.set(ai.playerId, false); + debugAI.log(`[AITactics] ${ai.playerId}: Hunt mode - no enemy buildings found, returning to build`); + return; + } + debugAI.log(`[AITactics] ${ai.playerId}: HUNT MODE - targeting enemy building at (${attackTarget.x.toFixed(0)}, ${attackTarget.y.toFixed(0)})`); + } else { + // Normal attack - target enemy base + attackTarget = this.findEnemyBase(ai); + if (!attackTarget) { + // Try finding ANY enemy building as fallback + attackTarget = this.findAnyEnemyBuilding(ai); + if (!attackTarget) { + ai.state = 'building'; + this.isEngaged.set(ai.playerId, false); + return; + } + } + } - // Return to building state after issuing attack - ai.state = 'building'; + // SC2-STYLE: Re-command idle assault units periodically + const lastReCommand = this.lastReCommandTick.get(ai.playerId) || 0; + const shouldReCommand = currentTick - lastReCommand >= RE_COMMAND_IDLE_INTERVAL; + + if (shouldReCommand) { + // Find units that are idle in assault mode or have been stuck + const idleAssaultUnits = this.getIdleAssaultUnits(ai.playerId, armyUnits); + + if (idleAssaultUnits.length > 0) { + // Re-command idle assault units to attack + const command: GameCommand = { + tick: currentTick, + playerId: ai.playerId, + type: 'ATTACK', + entityIds: idleAssaultUnits, + targetPosition: attackTarget, + }; + this.game.processCommand(command); + debugAI.log(`[AITactics] ${ai.playerId}: Re-commanding ${idleAssaultUnits.length} idle assault units`); + } + + this.lastReCommandTick.set(ai.playerId, currentTick); + } + + // Initial attack command (only when first entering attacking state) + if (ai.lastAttackTick === 0 || currentTick - ai.lastAttackTick >= ai.attackCooldown) { + ai.lastAttackTick = currentTick; + + // Issue attack command to all army units + const command: GameCommand = { + tick: currentTick, + playerId: ai.playerId, + type: 'ATTACK', + entityIds: armyUnits, + targetPosition: attackTarget, + }; + this.game.processCommand(command); + + debugAI.log(`[AITactics] ${ai.playerId}: Attacking with ${armyUnits.length} units${inHuntMode ? ' (HUNT MODE)' : ''}`); + } + + // SC2-STYLE: Stay in attacking state while engaged or in hunt mode + // Only return to building when: + // 1. No enemy buildings exist (victory imminent) + // 2. Not engaged and no enemy buildings nearby + if (enemyBuildingCount === 0) { + // Victory pursuit complete + ai.state = 'building'; + this.isEngaged.set(ai.playerId, false); + debugAI.log(`[AITactics] ${ai.playerId}: No enemy buildings remaining, returning to build`); + } else if (!engaged && !inHuntMode) { + // Not engaged and not in hunt mode - check if we should retreat + // Only retreat if we've been disengaged for a while + const disengagedDuration = currentTick - (this.lastEngagementCheck.get(ai.playerId) || 0); + if (disengagedDuration > 100) { // 5 seconds of no combat + ai.state = 'building'; + this.isEngaged.set(ai.playerId, false); + debugAI.log(`[AITactics] ${ai.playerId}: Disengaged for ${disengagedDuration} ticks, returning to build`); + } + } + // Otherwise stay in attacking state - SC2-style persistence } /** @@ -424,4 +530,162 @@ export class AITacticsManager { ai.state = 'building'; } + + // ==================== SC2-STYLE ENGAGEMENT TRACKING ==================== + + /** + * Check if the AI's army is currently engaged in combat. + * Returns true if units are attacking or being attacked. + */ + private checkEngagementStatus(ai: AIPlayer, armyUnits: number[]): boolean { + let engagedCount = 0; + const entities = this.coordinator.getCachedUnitsWithTransform(); + + for (const entityId of armyUnits) { + const entity = this.world.getEntity(entityId); + if (!entity) continue; + + const unit = entity.get('Unit'); + const health = entity.get('Health'); + if (!unit || !health) continue; + + // Unit is engaged if: + // 1. It has an active target + // 2. It's in attacking state + // 3. It recently took damage + if (unit.targetEntityId !== null || unit.state === 'attacking') { + engagedCount++; + } + } + + // Consider engaged if >= 20% of army is in combat + return engagedCount >= Math.max(1, armyUnits.length * 0.2); + } + + /** + * Find army units that are idle in assault mode (need re-commanding). + * These are units that arrived at their destination and are scanning but found nothing. + */ + private getIdleAssaultUnits(playerId: string, armyUnits: number[]): number[] { + const idleUnits: number[] = []; + + for (const entityId of armyUnits) { + const entity = this.world.getEntity(entityId); + if (!entity) continue; + + const unit = entity.get('Unit'); + const health = entity.get('Health'); + if (!unit || !health) continue; + if (health.isDead()) continue; + + // Unit needs re-commanding if: + // 1. It's in assault mode AND + // 2. It's idle (no target, not moving) AND + // 3. It's been idle for a while (stuck or no enemies found) + const isIdleAssault = unit.isInAssaultMode && + unit.state === 'idle' && + unit.targetEntityId === null; + + // Also include units that are completely idle (not in assault mode but should be attacking) + const isCompletelyIdle = unit.state === 'idle' && + unit.targetEntityId === null && + !unit.isInAssaultMode && + !unit.isHoldingPosition; + + if (isIdleAssault || isCompletelyIdle) { + idleUnits.push(entityId); + } + } + + return idleUnits; + } + + /** + * Count all enemy buildings (for hunt mode determination). + */ + private countEnemyBuildings(ai: AIPlayer): number { + let count = 0; + const buildings = this.world.getEntitiesWith('Building', 'Selectable', 'Health'); + + for (const entity of buildings) { + const selectable = entity.get('Selectable')!; + const health = entity.get('Health')!; + const building = entity.get('Building')!; + + if (selectable.playerId === ai.playerId) continue; + if (health.isDead()) continue; + if (!building.isOperational()) continue; // Don't count blueprints + + count++; + } + + return count; + } + + /** + * Find ANY enemy building (not just base buildings). + * Used for hunt mode to track down remaining structures. + */ + private findAnyEnemyBuilding(ai: AIPlayer): { x: number; y: number } | null { + const buildings = this.world.getEntitiesWith('Building', 'Transform', 'Selectable', 'Health'); + + // First try to find a base building (higher priority) + const config = ai.config!; + const baseTypes = config.roles.baseTypes; + + for (const entity of buildings) { + const selectable = entity.get('Selectable')!; + const building = entity.get('Building')!; + const transform = entity.get('Transform')!; + const health = entity.get('Health')!; + + if (selectable.playerId === ai.playerId) continue; + if (health.isDead()) continue; + if (!building.isOperational()) continue; + + if (baseTypes.includes(building.buildingId)) { + return { x: transform.x, y: transform.y }; + } + } + + // If no base found, return ANY enemy building + for (const entity of buildings) { + const selectable = entity.get('Selectable')!; + const transform = entity.get('Transform')!; + const health = entity.get('Health')!; + const building = entity.get('Building')!; + + if (selectable.playerId === ai.playerId) continue; + if (health.isDead()) continue; + if (!building.isOperational()) continue; + + return { x: transform.x, y: transform.y }; + } + + return null; + } + + /** + * Find all enemy buildings and return them for map sweeping. + * Used when in hunt mode to spread units across multiple targets. + */ + public findAllEnemyBuildings(ai: AIPlayer): Array<{ x: number; y: number; entityId: number }> { + const results: Array<{ x: number; y: number; entityId: number }> = []; + const buildings = this.world.getEntitiesWith('Building', 'Transform', 'Selectable', 'Health'); + + for (const entity of buildings) { + const selectable = entity.get('Selectable')!; + const transform = entity.get('Transform')!; + const health = entity.get('Health')!; + const building = entity.get('Building')!; + + if (selectable.playerId === ai.playerId) continue; + if (health.isDead()) continue; + if (!building.isOperational()) continue; + + results.push({ x: transform.x, y: transform.y, entityId: entity.id }); + } + + return results; + } } diff --git a/src/engine/systems/movement/MovementOrchestrator.ts b/src/engine/systems/movement/MovementOrchestrator.ts index 94f68eb3..60226f58 100644 --- a/src/engine/systems/movement/MovementOrchestrator.ts +++ b/src/engine/systems/movement/MovementOrchestrator.ts @@ -864,6 +864,10 @@ export class MovementOrchestrator { /** * Handle unit arrival at destination + * + * SC2-STYLE: Attack-move units that arrive at their destination don't go fully idle. + * Instead they stay in "assault mode" - idle but aggressively scanning for enemies. + * This prevents units from standing around in enemy bases doing nothing. */ private handleArrival( entityId: number, @@ -883,6 +887,18 @@ export class MovementOrchestrator { this.pathfinding.requestPathWithCooldown(entityId, unit.targetX, unit.targetY, true); } return false; + } else if (unit.state === 'attackmoving') { + // SC2-STYLE: Attack-move arrived at destination + // Don't go fully idle - stay in assault mode, keep scanning for enemies + unit.targetX = null; + unit.targetY = null; + unit.path = []; + unit.pathIndex = 0; + unit.state = 'idle'; + // Assault mode is preserved! Unit will keep scanning for targets in CombatSystem + // The isInAssaultMode flag was set when setAttackMoveTarget() was called + velocity.zero(); + return true; } else { if (unit.state === 'gathering') { const isCarryingResources = unit.carryingMinerals > 0 || unit.carryingVespene > 0;