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: 2 additions & 2 deletions public/config/collision.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"strengthMoving": 0.15,
"_strengthMoving_description": "Separation while moving. Very weak - allows extreme clumping during movement.",

"strengthIdle": 0.8,
"_strengthIdle_description": "Separation when idle. Gentle spreading - not jerky adjustments.",
"strengthIdle": 1.2,
"_strengthIdle_description": "Separation when idle. Increased to prevent clustering after combat.",

"strengthArriving": 1.0,
"_strengthArriving_description": "Separation at destination. Gentle spread, not sudden explosion.",
Expand Down
2 changes: 1 addition & 1 deletion src/data/collisionConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const DEFAULT_CONFIG: CollisionConfig = {
multiplier: 1.0,
queryRadiusMultiplier: 2.0,
strengthMoving: 0.15,
strengthIdle: 0.8,
strengthIdle: 1.2,
strengthArriving: 1.0,
strengthCombat: 0.3,
maxForce: 2.0,
Expand Down
22 changes: 20 additions & 2 deletions src/engine/pathfinding/RecastNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1474,7 +1474,16 @@ export class RecastNavigation {
height: number,
_agentRadius: number = DEFAULT_AGENT_RADIUS
): void {
if (!this.tileCache || !this.navMesh) return;
if (!this.tileCache || !this.navMesh) {
// TileCache not available - buildings won't block pathfinding!
// This can happen if solo navmesh was generated instead of tilecache navmesh
debugPathfinding.warn(
`[RecastNavigation] Cannot add obstacle for building ${buildingEntityId}: ` +
`TileCache=${!!this.tileCache}, NavMesh=${!!this.navMesh}. ` +
`Building will NOT block unit pathfinding!`
);
return;
}

// Remove existing obstacle if present
if (this.obstacleRefs.has(buildingEntityId)) {
Expand Down Expand Up @@ -1523,7 +1532,16 @@ export class RecastNavigation {
height: number,
_agentRadius: number = DEFAULT_AGENT_RADIUS
): void {
if (!this.tileCache || !this.navMesh) return;
if (!this.tileCache || !this.navMesh) {
// TileCache not available - fall back to cylinder obstacle via addObstacle
// which will log its own warning
debugPathfinding.warn(
`[RecastNavigation] Cannot add box obstacle for building ${buildingEntityId}: ` +
`TileCache=${!!this.tileCache}, NavMesh=${!!this.navMesh}. ` +
`Building will NOT block unit pathfinding!`
);
return;
}

if (this.obstacleRefs.has(buildingEntityId)) {
this.removeObstacle(buildingEntityId);
Expand Down
15 changes: 15 additions & 0 deletions src/engine/systems/CombatSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ function _getTargetPriority(unitId: string, _unit?: Unit): number {
return getDefaultTargetPriority(unitId);
}

// Assault mode timeout - clear assault mode after this many ticks of being idle with no targets
// 60 ticks = ~3 seconds at 20 ticks/sec - enough time to scan for new targets before giving up
const ASSAULT_IDLE_TIMEOUT = 60;

export class CombatSystem extends System {
public readonly name = 'CombatSystem';
// Priority is set by SystemRegistry based on dependencies (runs after MovementSystem, VisionSystem)
Expand Down Expand Up @@ -540,6 +544,17 @@ export class CombatSystem extends System {
target = this.findBestTargetSpatial(attacker.id, transform, unit);
// Track idle time for assault mode units
unit.assaultIdleTicks++;

// Clear assault mode after timeout if no target found
// This allows the AI tactical system to reclaim and re-command these units
if (target === null && unit.assaultIdleTicks > ASSAULT_IDLE_TIMEOUT) {
unit.isInAssaultMode = false;
unit.assaultDestination = null;
unit.assaultIdleTicks = 0;
debugCombat.log(
`[CombatSystem] Unit ${attacker.id} cleared assault mode after ${ASSAULT_IDLE_TIMEOUT} ticks idle`
);
}
} 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
4 changes: 4 additions & 0 deletions src/engine/systems/ai/AICoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1017,5 +1017,9 @@ export class AICoordinator extends System {
this.tacticsManager.executeHarassingPhase(ai, currentTick);
break;
}

// Always recover orphaned assault units regardless of AI state
// This prevents units from sitting forever after assault mode timeout
this.tacticsManager.recoverStuckAssaultUnits(ai, currentTick);
}
}
272 changes: 250 additions & 22 deletions src/engine/systems/ai/AITacticsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,15 +700,25 @@
const idleAssaultUnits = this.getIdleAssaultUnits(ai.playerId, armyUnits);

if (idleAssaultUnits.length > 0) {
const command: GameCommand = {
tick: currentTick,
playerId: ai.playerId,
type: 'ATTACK',
entityIds: idleAssaultUnits,
targetPosition: attackTarget,
};
this.game.issueAICommand(command);
debugAI.log(`[AITactics] ${ai.playerId}: Re-commanding ${idleAssaultUnits.length} idle assault units`);
// Spread units around the target to prevent clumping
// Each unit gets a slightly different target position in a circle
const spreadRadius = 12;
for (let i = 0; i < idleAssaultUnits.length; i++) {
const angle = (i / idleAssaultUnits.length) * Math.PI * 2;
const spreadTarget = {
x: attackTarget.x + Math.cos(angle) * spreadRadius,
y: attackTarget.y + Math.sin(angle) * spreadRadius,
};
const command: GameCommand = {
tick: currentTick,
playerId: ai.playerId,
type: 'ATTACK',
entityIds: [idleAssaultUnits[i]],
targetPosition: spreadTarget,
};
this.game.issueAICommand(command);
}
debugAI.log(`[AITactics] ${ai.playerId}: Re-commanding ${idleAssaultUnits.length} idle assault units with spread positions`);
}

this.lastReCommandTick.set(ai.playerId, currentTick);
Expand All @@ -718,23 +728,68 @@
if (ai.lastAttackTick === 0 || currentTick - ai.lastAttackTick >= ai.attackCooldown) {
ai.lastAttackTick = currentTick;

const command: GameCommand = {
tick: currentTick,
playerId: ai.playerId,
type: 'ATTACK',
entityIds: armyUnits,
targetPosition: attackTarget,
};
this.game.issueAICommand(command);

debugAI.log(`[AITactics] ${ai.playerId}: Attacking with ${armyUnits.length} units${inHuntMode ? ' (HUNT MODE)' : ''}`);
if (inHuntMode && armyUnits.length > 1) {
// In hunt mode, spread units to surround the target and prevent clumping
const spreadRadius = 15;
for (let i = 0; i < armyUnits.length; i++) {
const angle = (i / armyUnits.length) * Math.PI * 2;
const spreadTarget = {
x: attackTarget.x + Math.cos(angle) * spreadRadius,
y: attackTarget.y + Math.sin(angle) * spreadRadius,
};
const command: GameCommand = {
tick: currentTick,
playerId: ai.playerId,
type: 'ATTACK',
entityIds: [armyUnits[i]],
targetPosition: spreadTarget,
};
this.game.issueAICommand(command);
}
debugAI.log(`[AITactics] ${ai.playerId}: HUNT MODE - spreading ${armyUnits.length} units around target`);
} else {
// Regular attack - send all units to same target
const command: GameCommand = {
tick: currentTick,
playerId: ai.playerId,
type: 'ATTACK',
entityIds: armyUnits,
targetPosition: attackTarget,
};
this.game.issueAICommand(command);
debugAI.log(`[AITactics] ${ai.playerId}: Attacking with ${armyUnits.length} units`);
}
}

// State transition logic
if (enemyBuildingCount === 0) {
ai.state = 'building';
this.isEngaged.set(ai.playerId, false);
debugAI.log(`[AITactics] ${ai.playerId}: No enemy buildings remaining, returning to build`);
// No buildings left - check for remaining enemy units
if (this.hasRemainingEnemyUnits(ai)) {
// Hunt remaining enemy units
const enemyCluster = this.findEnemyUnitCluster(ai);
if (enemyCluster) {
debugAI.log(
`[AITactics] ${ai.playerId}: UNIT HUNT MODE - no buildings, targeting enemy units at ` +
`(${enemyCluster.x.toFixed(0)}, ${enemyCluster.y.toFixed(0)})`
);

// Attack-move to enemy unit cluster
const command: GameCommand = {
tick: currentTick,
playerId: ai.playerId,
type: 'ATTACK',
entityIds: armyUnits,
targetPosition: enemyCluster,
};
this.game.issueAICommand(command);
}
} else {
// Enemy fully eliminated - return units to base and transition to building
this.returnUnitsToBase(ai, armyUnits, currentTick);
ai.state = 'building';
this.isEngaged.set(ai.playerId, false);
debugAI.log(`[AITactics] ${ai.playerId}: Enemy eliminated, returning units to base`);
}
} else if (!engaged && !inHuntMode) {
const disengagedDuration = currentTick - (this.lastEngagementCheck.get(ai.playerId) || 0);
if (disengagedDuration > 100) {
Expand Down Expand Up @@ -1142,6 +1197,71 @@
return null;
}

/**
* Find enemy units when no buildings remain.
* Returns the centroid of the nearest enemy unit cluster, or null if no enemies exist.
*/
private findEnemyUnitCluster(ai: AIPlayer): { x: number; y: number } | null {
const units = this.world.getEntitiesWith('Unit', 'Transform', 'Selectable', 'Health');
const enemyPositions: Array<{ x: number; y: number }> = [];

for (const entity of units) {
const selectable = entity.get<Selectable>('Selectable')!;
const unit = entity.get<Unit>('Unit')!;
const transform = entity.get<Transform>('Transform')!;
const health = entity.get<Health>('Health')!;

// Skip own units, dead units, and workers
if (selectable.playerId === ai.playerId) continue;
if (health.isDead()) continue;
if (unit.isWorker) continue;

// Use isEnemy check for proper team handling
if (!isEnemy(ai.playerId, selectable.playerId)) continue;

Check failure on line 1220 in src/engine/systems/ai/AITacticsManager.ts

View workflow job for this annotation

GitHub Actions / type-check

Expected 4 arguments, but got 2.

enemyPositions.push({ x: transform.x, y: transform.y });
}

if (enemyPositions.length === 0) {
return null;
}

// Find centroid of enemy positions
let sumX = 0;
let sumY = 0;
for (const pos of enemyPositions) {
sumX += pos.x;
sumY += pos.y;
}

return {
x: sumX / enemyPositions.length,
y: sumY / enemyPositions.length,
};
}

/**
* Check if any enemy units remain (excluding workers).
*/
private hasRemainingEnemyUnits(ai: AIPlayer): boolean {
const units = this.world.getEntitiesWith('Unit', 'Selectable', 'Health');

for (const entity of units) {
const selectable = entity.get<Selectable>('Selectable')!;
const unit = entity.get<Unit>('Unit')!;
const health = entity.get<Health>('Health')!;

if (selectable.playerId === ai.playerId) continue;
if (health.isDead()) continue;
if (unit.isWorker) continue;
if (!isEnemy(ai.playerId, selectable.playerId)) continue;

Check failure on line 1257 in src/engine/systems/ai/AITacticsManager.ts

View workflow job for this annotation

GitHub Actions / type-check

Expected 4 arguments, but got 2.

return true;
}

return false;
}

/**
* Find all enemy buildings for map sweeping.
*/
Expand Down Expand Up @@ -1212,4 +1332,112 @@

return path.length > 0 ? path : null;
}

/**
* Recover stuck assault units that have been cleared from assault mode.
* Called in ALL AI states to prevent orphaned units from sitting forever.
*
* Units that timed out of assault mode (assaultIdleTicks exceeded threshold in CombatSystem)
* are now just idle and need to be re-commanded. This method rallies them back to base
* so the AI can use them in future attacks.
*/
public recoverStuckAssaultUnits(ai: AIPlayer, currentTick: number): void {
const basePos = this.coordinator.findAIBase(ai);
if (!basePos) return;

const rallyPoint = {
x: basePos.x + 10,
y: basePos.y + 10,
};

const armyUnits = this.getArmyUnits(ai.playerId);
const unitsToRecover: number[] = [];

for (const entityId of armyUnits) {
const entity = this.world.getEntity(entityId);
if (!entity) continue;

const unit = entity.get<Unit>('Unit');
const health = entity.get<Health>('Health');
const transform = entity.get<Transform>('Transform');
if (!unit || !health || !transform) continue;
if (health.isDead()) continue;

// Find idle units that are NOT in assault mode, NOT holding position,
// and are far from the rally point (likely orphaned after assault)
const isOrphanedUnit =
unit.state === 'idle' &&
!unit.isInAssaultMode &&
!unit.isHoldingPosition &&
unit.targetEntityId === null;

if (isOrphanedUnit) {
const dx = transform.x - rallyPoint.x;
const dy = transform.y - rallyPoint.y;
const distanceToRally = Math.sqrt(dx * dx + dy * dy);

// Only recover units that are far from base (likely stuck at enemy position)
if (distanceToRally > 40) {
unitsToRecover.push(entityId);
}
}
}

if (unitsToRecover.length > 0) {
const command: GameCommand = {
tick: currentTick,
playerId: ai.playerId,
type: 'MOVE',
entityIds: unitsToRecover,
targetPosition: rallyPoint,
};
this.game.issueAICommand(command);
debugAI.log(
`[AITactics] ${ai.playerId}: Recovering ${unitsToRecover.length} orphaned units to rally point`
);
}
}

/**
* Return army units to base after victory/hunt completion.
* Clears assault mode on all units and issues move commands to rally point.
*/
private returnUnitsToBase(ai: AIPlayer, armyUnits: number[], currentTick: number): void {
const basePos = this.coordinator.findAIBase(ai);
if (!basePos) return;

const rallyPoint = {
x: basePos.x + 10,
y: basePos.y + 10,
};

// Clear assault mode on all army units
for (const entityId of armyUnits) {
const entity = this.world.getEntity(entityId);
if (!entity) continue;

const unit = entity.get<Unit>('Unit');
if (!unit) continue;

// Clear assault mode so units don't stay aggressive
unit.isInAssaultMode = false;
unit.assaultDestination = null;
unit.assaultIdleTicks = 0;
}

// Send all units back to rally point
if (armyUnits.length > 0) {
const command: GameCommand = {
tick: currentTick,
playerId: ai.playerId,
type: 'MOVE',
entityIds: armyUnits,
targetPosition: rallyPoint,
};
this.game.issueAICommand(command);
debugAI.log(
`[AITactics] ${ai.playerId}: Returning ${armyUnits.length} units to base after victory`
);
}
}
}
Loading
Loading