Enhance enemy AI and area connectivity

- Introduced area grid management in WolfEngine to track player-connected areas.
- Updated enemy behavior to consider area connectivity when alerting and moving.
- Added debugging logs for enemy states and movements to assist in tracking AI behavior.
- Implemented fallback area generation for levels lacking area data.
- Enhanced patrol behavior for dogs and guards to prevent rapid direction changes after hitting walls.
- Updated tests to validate new area connectivity logic and enemy behavior under various conditions.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-19 18:03:01 +01:00
parent 7b1ec777d3
commit 4700e669ce
21 changed files with 565 additions and 135 deletions

View File

@@ -16,7 +16,7 @@ class CliSilentAudio implements EngineAudio {
@override
void playLevelMusic(WolfLevel level) {
// Optional: Print a log so you know it's working!
// print("🎵 Playing music for: ${level.name} 🎵");
// debugPrint("🎵 Playing music for: ${level.name} 🎵");
}
@override

View File

@@ -1,3 +1,4 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
@@ -46,16 +47,25 @@ class DoorManager {
}
/// Handles player-initiated interaction with doors based on facing direction.
void handleInteraction(double playerX, double playerY, double playerAngle) {
///
/// Returns the door tile coordinates when a door actually transitions/opening
/// starts, otherwise returns `null`.
({int x, int y})? handleInteraction(
double playerX,
double playerY,
double playerAngle,
) {
int targetX = (playerX + math.cos(playerAngle)).toInt();
int targetY = (playerY + math.sin(playerAngle)).toInt();
final int key = _key(targetX, targetY);
if (doors.containsKey(key)) {
if (doors[key]!.interact()) {
onPlaySound(WolfSound.openDoor);
}
if (doors.containsKey(key) && doors[key]!.interact()) {
onPlaySound(WolfSound.openDoor);
log('[DEBUG] Player opened door at ($targetX, $targetY)');
return (x: targetX, y: targetY);
}
return null;
}
/// Attempted by AI entities to open a door blocking their path.

View File

@@ -1,3 +1,4 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
@@ -103,7 +104,7 @@ class Player {
pendingWeaponType = weaponType;
switchState = WeaponSwitchState.lowering;
print("[PLAYER] Requesting weapon switch to ${weaponType.name}.");
log("[PLAYER] Requesting weapon switch to ${weaponType.name}.");
}
// --- Health & Damage ---
@@ -116,16 +117,16 @@ class Player {
damageFlash = math.min(1.0, damageFlash + (damage * 0.05));
if (health <= 0) {
print("[PLAYER] Died! Final Score: $score");
log("[PLAYER] Died! Final Score: $score");
} else {
print("[PLAYER] Took $damage damage ($health)");
log("[PLAYER] Took $damage damage ($health)");
}
}
void heal(int amount) {
final int newHealth = math.min(100, health + amount);
if (health < 100) {
print("[PLAYER] Healed for $amount ($health -> $newHealth)");
log("[PLAYER] Healed for $amount ($health -> $newHealth)");
}
health = newHealth;
}
@@ -133,14 +134,14 @@ class Player {
void addAmmo(int amount) {
final int newAmmo = math.min(99, ammo + amount);
if (ammo < 99) {
print("[PLAYER] Picked up ammo $amount ($ammo -> $newAmmo)");
log("[PLAYER] Picked up ammo $amount ($ammo -> $newAmmo)");
}
ammo = newAmmo;
}
void addLives(int amount) {
lives = math.min(9, lives + amount);
print("[PLAYER] Adding $amount extra lives (Total Lives: $lives)");
log("[PLAYER] Adding $amount extra lives (Total Lives: $lives)");
}
/// Attempts to collect [item] and returns the SFX to play.
@@ -160,7 +161,7 @@ class Player {
);
if (effect == null) return null;
print("[PLAYER] Collected ${item.runtimeType} - Effect: $effect");
log("[PLAYER] Collected ${item.runtimeType} - Effect: $effect");
if (effect.healthToRestore > 0) {
heal(effect.healthToRestore);
@@ -171,7 +172,7 @@ class Player {
}
if (effect.scoreToAdd > 0) {
print("[PLAYER] Adding ${effect.scoreToAdd} score.");
log("[PLAYER] Adding ${effect.scoreToAdd} score.");
score += effect.scoreToAdd;
}
@@ -181,12 +182,12 @@ class Player {
}
if (effect.grantGoldKey) {
print("[PLAYER] Collected Gold Key.");
log("[PLAYER] Collected Gold Key.");
hasGoldKey = true;
}
if (effect.grantSilverKey) {
print("[PLAYER] Collected Silver Key.");
log("[PLAYER] Collected Silver Key.");
hasSilverKey = true;
}
@@ -203,7 +204,7 @@ class Player {
if (weaponType == WeaponType.machineGun) hasMachineGun = true;
if (weaponType == WeaponType.chainGun) hasChainGun = true;
print("[PLAYER] Collected ${weaponType.name}.");
log("[PLAYER] Collected ${weaponType.name}.");
}
if (effect.requestWeaponSwitch case final weaponType?) {
@@ -252,7 +253,7 @@ class Player {
currentTime: currentTime,
onEnemyKilled: (Enemy killedEnemy) {
score += killedEnemy.scoreValue;
print(
log(
"[PLAYER] Killed ${killedEnemy.runtimeType}! +${killedEnemy.scoreValue} (Score: $score)",
);
},
@@ -262,7 +263,7 @@ class Player {
if (currentWeapon.state == WeaponState.idle &&
ammo <= 0 &&
currentWeapon.type != WeaponType.knife) {
print("[PLAYER] Out of ammo, switching to knife.");
log("[PLAYER] Out of ammo, switching to knife.");
requestWeaponSwitch(WeaponType.knife);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
@@ -124,6 +125,15 @@ class WolfEngine {
/// All dynamic entities currently in the level (Enemies, Pickups).
List<Entity> entities = [];
/// Per-tile area numbers derived from map plane 0 (or fallback fill).
late SpriteMap _areaGrid;
/// True for areas currently connected to the player's area.
List<bool> _areasByPlayer = [];
/// Total number of known area indices in the active level.
int _areaCount = 0;
int _currentEpisodeIndex = 0;
bool _isPlayerMovingFast = false;
@@ -175,6 +185,7 @@ class WolfEngine {
return;
}
frameBuffer = FrameBuffer(width, height);
log("[DEBUG] FrameBuffer resized to ${width}x$height");
}
/// The primary heartbeat of the engine.
@@ -222,6 +233,8 @@ class WolfEngine {
player.x = validatedPos.x;
player.y = validatedPos.y;
_refreshPlayerConnectedAreas();
// 4. Update Dynamic World
_updateEntities(delta);
@@ -329,6 +342,7 @@ class WolfEngine {
// Create a mutable copy of the wall grid so pushwalls can modify it
currentLevel = List.generate(64, (y) => List.from(activeLevel.wallGrid[y]));
_areaGrid = List.generate(64, (y) => List.from(activeLevel.areaGrid[y]));
final SpriteMap objectLevel = activeLevel.objectGrid;
doorManager.initDoors(currentLevel);
@@ -371,6 +385,9 @@ class WolfEngine {
}
}
_buildFallbackAreasIfNeeded();
_refreshPlayerConnectedAreas();
_bumpPlayerIfStuck();
}
@@ -542,6 +559,8 @@ class WolfEngine {
playerAngle: player.angle,
isPlayerRunning: _isPlayerMovingFast,
isWalkable: isWalkable,
areaAt: _areaAt,
isAreaConnectedToPlayer: _isAreaConnectedToPlayer,
tryOpenDoor: doorManager.tryOpenDoor,
onDamagePlayer: (int damage) {
final difficultyMode = difficulty ?? Difficulty.medium;
@@ -555,22 +574,42 @@ class WolfEngine {
Coordinate2D scaledMovement = intent.movement * timeScale;
Coordinate2D safeMovement = _clampMovement(scaledMovement);
final validatedPosition = _calculateValidatedPosition(
entity.position,
safeMovement,
);
entity.angle = intent.newAngle;
// Final sanity check: only move if the destination is on-map
double nextX = entity.x + safeMovement.x;
double nextY = entity.y + safeMovement.y;
if (nextX >= 0 && nextX < 64 && nextY >= 0 && nextY < 64) {
entity.x = nextX;
entity.y = nextY;
if (validatedPosition.x >= 0 &&
validatedPosition.x < 64 &&
validatedPosition.y >= 0 &&
validatedPosition.y < 64) {
entity.x = validatedPosition.x;
entity.y = validatedPosition.y;
}
// Handle Item Drops
if (entity.state == EntityState.dead &&
entity.isDying &&
!entity.hasDroppedItem) {
final dx = player.x - entity.x;
final dy = player.y - entity.y;
final distance = math.sqrt(dx * dx + dy * dy);
final bearingToPlayer = math.atan2(dy, dx);
log(
'[DEBUG] Enemy #${entity.debugId} (${entity.type.name}) death context '
'enemyPos=(${entity.x.toStringAsFixed(2)}, ${entity.y.toStringAsFixed(2)}) '
'enemyTile=(${entity.x.toInt()}, ${entity.y.toInt()}) '
'playerPos=(${player.x.toStringAsFixed(2)}, ${player.y.toStringAsFixed(2)}) '
'playerTile=(${player.x.toInt()}, ${player.y.toInt()}) '
'delta=(${dx.toStringAsFixed(2)}, ${dy.toStringAsFixed(2)}) '
'dist=${distance.toStringAsFixed(3)} '
'bearing=${bearingToPlayer.toStringAsFixed(3)}',
);
entity.hasDroppedItem = true;
Entity? droppedItem;
@@ -609,68 +648,160 @@ class WolfEngine {
if (itemsToAdd.isNotEmpty) entities.addAll(itemsToAdd);
}
/// Propagates weapon noise through corridors and open doors using a Breadth-First Search.
/// Canonical-style gunfire alert propagation: enemies in areas connected to
/// the player area hear the shot (unless they are ambush-only placements).
void _propagateGunfire() {
int maxAcousticRange = 20; // How many tiles the sound wave travels
int startX = player.x.toInt();
int startY = player.y.toInt();
for (final entity in entities) {
if (entity is! Enemy ||
entity.isAlerted ||
entity.state == EntityState.dead) {
continue;
}
// Track visited tiles using a 1D index to prevent infinite loops
Set<int> visited = {startY * 64 + startX};
final int area = _areaAt(
entity.position.x.toInt(),
entity.position.y.toInt(),
);
if (area < 0 || !_isAreaConnectedToPlayer(area)) {
continue;
}
List<({int x, int y})> queue = [(x: startX, y: startY)];
// Ambush enemies are intentionally deaf to noise until they see the player.
if (entity.state == EntityState.ambushing) {
continue;
}
int distance = 0;
entity.isAlerted = true;
audio.playSoundEffect(entity.alertSoundId);
log(
'[DEBUG] Enemy #${entity.debugId} (${entity.type.name}) '
'alerted by gunfire in area $area',
);
while (queue.isNotEmpty && distance < maxAcousticRange) {
List<({int x, int y})> nextQueue = [];
if (entity.state == EntityState.idle) {
entity.state = EntityState.patrolling;
entity.lastActionTime = _timeAliveMs;
}
}
}
for (var tile in queue) {
// 1. Alert any enemies standing on this specific tile
for (var entity in entities) {
if (entity is Enemy &&
!entity.isAlerted &&
entity.state != EntityState.dead) {
if (entity.position.x.toInt() == tile.x &&
entity.position.y.toInt() == tile.y) {
entity.isAlerted = true;
audio.playSoundEffect(entity.alertSoundId);
int _areaAt(int x, int y) {
if (x < 0 || x >= 64 || y < 0 || y >= 64) return -1;
return _areaGrid[y][x];
}
// Wake them up!
if (entity.state == EntityState.idle ||
entity.state == EntityState.ambushing) {
entity.state = EntityState.patrolling;
entity.lastActionTime = _timeAliveMs;
}
}
}
}
bool _isAreaConnectedToPlayer(int area) {
return area >= 0 && area < _areasByPlayer.length && _areasByPlayer[area];
}
// 2. Expand the sound wave outward to North, East, South, West
final neighbors = <({int x, int y})>[
(x: tile.x + 1, y: tile.y),
(x: tile.x - 1, y: tile.y),
(x: tile.x, y: tile.y + 1),
(x: tile.x, y: tile.y - 1),
];
for (var n in neighbors) {
// Keep it within the 64x64 grid limits
if (n.x >= 0 && n.x < 64 && n.y >= 0 && n.y < 64) {
int idx = n.y * 64 + n.x;
if (!visited.contains(idx)) {
visited.add(idx);
// Sound only travels through walkable tiles (air and OPEN doors).
if (isWalkable(n.x, n.y)) {
nextQueue.add(n);
}
}
}
void _buildFallbackAreasIfNeeded() {
int maxArea = -1;
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
if (_areaGrid[y][x] > maxArea) {
maxArea = _areaGrid[y][x];
}
}
queue = nextQueue;
distance++;
}
if (maxArea >= 0) {
_areaCount = maxArea + 1;
_areasByPlayer = List<bool>.filled(_areaCount, false);
return;
}
// Compatibility fallback for maps without canonical area data: infer area
// sectors by flood-filling contiguous walkable/door tiles.
int nextArea = 0;
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
if (_areaGrid[y][x] >= 0 || !_isAreaFillPassable(x, y)) continue;
final queue = <({int x, int y})>[(x: x, y: y)];
_areaGrid[y][x] = nextArea;
for (int i = 0; i < queue.length; i++) {
final tile = queue[i];
final neighbors = <({int x, int y})>[
(x: tile.x + 1, y: tile.y),
(x: tile.x - 1, y: tile.y),
(x: tile.x, y: tile.y + 1),
(x: tile.x, y: tile.y - 1),
];
for (final n in neighbors) {
if (n.x < 0 || n.x >= 64 || n.y < 0 || n.y >= 64) continue;
if (_areaGrid[n.y][n.x] >= 0 || !_isAreaFillPassable(n.x, n.y)) {
continue;
}
_areaGrid[n.y][n.x] = nextArea;
queue.add(n);
}
}
nextArea++;
}
}
_areaCount = nextArea;
_areasByPlayer = List<bool>.filled(_areaCount, false);
}
bool _isAreaFillPassable(int x, int y) {
final id = currentLevel[y][x];
return id == 0 || (id >= 90 && id <= 101);
}
bool _isAreaTraversalPassable(int x, int y) {
if (x < 0 || x >= 64 || y < 0 || y >= 64) return false;
final id = currentLevel[y][x];
if (id == 0) return true;
if (id >= 90 && id <= 101) {
// For area connectivity, opening/closing doors already bridge sectors.
final door = doorManager.doors[((y & 0xFFFF) << 16) | (x & 0xFFFF)];
return door != null && door.state != DoorState.closed;
}
return false;
}
void _refreshPlayerConnectedAreas() {
if (_areaCount <= 0) return;
for (int i = 0; i < _areasByPlayer.length; i++) {
_areasByPlayer[i] = false;
}
final int startX = player.x.toInt();
final int startY = player.y.toInt();
final int playerArea = _areaAt(startX, startY);
if (playerArea < 0) return;
final visited = List<List<bool>>.generate(
64,
(_) => List<bool>.filled(64, false),
);
final queue = <({int x, int y})>[(x: startX, y: startY)];
visited[startY][startX] = true;
for (int i = 0; i < queue.length; i++) {
final tile = queue[i];
final area = _areaAt(tile.x, tile.y);
if (area >= 0 && area < _areasByPlayer.length) {
_areasByPlayer[area] = true;
}
final neighbors = <({int x, int y})>[
(x: tile.x + 1, y: tile.y),
(x: tile.x - 1, y: tile.y),
(x: tile.x, y: tile.y + 1),
(x: tile.x, y: tile.y - 1),
];
for (final n in neighbors) {
if (n.x < 0 || n.x >= 64 || n.y < 0 || n.y >= 64) continue;
if (visited[n.y][n.x] || !_isAreaTraversalPassable(n.x, n.y)) continue;
visited[n.y][n.x] = true;
queue.add(n);
}
}
}