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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user