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

@@ -1,3 +1,4 @@
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
@@ -12,7 +13,7 @@ Future<Map<GameVersion, WolfensteinData>> discoverInDirectory({
}) async {
final dir = Directory(directoryPath ?? Directory.current.path);
if (!await dir.exists()) {
print('Warning: Directory does not exist -> ${dir.path}');
log('Warning: Directory does not exist -> ${dir.path}');
return {};
}
@@ -60,7 +61,9 @@ Future<Map<GameVersion, WolfensteinData>> discoverInDirectory({
.where((f) => !foundFiles.containsKey(f))
.map((f) => '${f.baseName}.$ext')
.join(', ');
print('Found partial data for ${version.name}. Missing: $missingFiles');
log(
'Found partial data for ${version.name}. Missing: $missingFiles',
);
continue;
}
@@ -73,10 +76,10 @@ Future<Map<GameVersion, WolfensteinData>> discoverInDirectory({
final hash = md5.convert(vswapBytes).toString();
final identity = DataVersion.fromChecksum(hash);
print('--- Found ${version.name} ---');
print('MD5 Identity: ${identity.name} ($hash)');
log('--- Found ${version.name} ---');
log('MD5 Identity: ${identity.name} ($hash)');
if (identity == DataVersion.version10Retail) {
print(
log(
'Note: Detected v1.0 specific file structure (MAPTEMP support active).',
);
}
@@ -97,7 +100,7 @@ Future<Map<GameVersion, WolfensteinData>> discoverInDirectory({
loadedVersions[version] = data;
} catch (e) {
print('Error parsing data for ${version.name}: $e');
log('Error parsing data for ${version.name}: $e');
}
}

View File

@@ -11,6 +11,8 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// objects (walls, sprites, audio, levels, and UI elements) using original
/// algorithms like Carmackization, RLEW, and Huffman expansion.
abstract class WLParser {
static const int _areaTileBase = 107;
// --- Original Song Lookup Tables ---
static const List<int> _sharewareMusicMap = [
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Episode 1
@@ -486,16 +488,25 @@ abstract class WLParser {
// --- BUILD 64x64 GRIDS ---
List<List<int>> wallGrid = [];
List<List<int>> objectGrid = [];
List<List<int>> areaGrid = [];
for (int y = 0; y < 64; y++) {
List<int> wallRow = [];
List<int> objectRow = [];
List<int> areaRow = [];
for (int x = 0; x < 64; x++) {
wallRow.add(flatWallGrid[y * 64 + x]);
final rawWallId = flatWallGrid[y * 64 + x];
wallRow.add(rawWallId);
objectRow.add(flatObjectGrid[y * 64 + x]);
if (rawWallId >= _areaTileBase) {
areaRow.add(rawWallId - _areaTileBase);
} else {
areaRow.add(-1);
}
}
wallGrid.add(wallRow);
objectGrid.add(objectRow);
areaGrid.add(areaRow);
}
// --- ASSIGN MUSIC ---
@@ -508,6 +519,7 @@ abstract class WLParser {
name: parsedName,
wallGrid: wallGrid,
objectGrid: objectGrid,
areaGrid: areaGrid,
musicIndex: trackIndex,
),
);

View File

@@ -16,6 +16,12 @@ class WolfLevel {
/// A 64x64 grid of indices pointing to game objects, entities, and triggers.
final SpriteMap objectGrid;
/// A 64x64 grid of area numbers extracted from plane 0.
///
/// Cells with `-1` are non-area solids/unknown tiles. Area numbers are
/// zero-based and correspond to original AREATILE-derived sectors.
final SpriteMap areaGrid;
/// The index of the [ImfMusic] track to play while this level is active.
final int musicIndex;
@@ -23,6 +29,7 @@ class WolfLevel {
required this.name,
required this.wallGrid,
required this.objectGrid,
required this.areaGrid,
required this.musicIndex,
});
}

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);
}
}
}

View File

@@ -81,6 +81,8 @@ class HansGrosse extends Enemy {
required double playerAngle,
required bool isPlayerRunning,
required bool Function(int x, int y) isWalkable,
required int Function(int x, int y) areaAt,
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
@@ -96,6 +98,8 @@ class HansGrosse extends Enemy {
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
areaAt: areaAt,
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
baseReactionMs: 50,
)) {
onPlaySound(alertSoundId);

View File

@@ -1,3 +1,4 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart';
@@ -13,6 +14,12 @@ class Dog extends Enemy {
// Used to simulate SelectDodgeDir's zigzag behavior
double _dodgeAngleOffset = 0.0;
int _dodgeTicTimer = 0;
int _stuckFrames = 0;
bool _wasMoving = false;
/// Prevents rapid patrol direction oscillation after hitting a wall.
/// After each reversal the dog stands still for this many milliseconds.
int _patrolReversalCooldownMs = 0;
@override
EnemyType get type => EnemyType.dog;
@@ -38,10 +45,13 @@ class Dog extends Enemy {
required double playerAngle,
required bool isPlayerRunning,
required bool Function(int x, int y) isWalkable,
required int Function(int x, int y) areaAt,
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
}) {
final previousState = state;
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
@@ -51,6 +61,8 @@ class Dog extends Enemy {
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
areaAt: areaAt,
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
)) {
onPlaySound(alertSoundId);
}
@@ -91,7 +103,17 @@ class Dog extends Enemy {
double currentMoveSpeed = speedPerTic * ticsThisFrame;
if (isAlerted) {
state = EntityState.ambushing;
// Only commit to the chase (ambushing) state once we have a valid
// movement path. While blocked, the dog waits in its current state
// so it does not visually appear to charge a closed door.
final bool startedChaseThisFrame = state != EntityState.ambushing;
if (startedChaseThisFrame) {
// Reset dodge at chase start so the first pursuit vector is not biased
// by stale/random strafe offset from previous behavior.
_dodgeAngleOffset = 0.0;
_dodgeTicTimer = 0;
}
double dx = (position.x - playerPosition.x).abs() - currentMoveSpeed;
double dy = (position.y - playerPosition.y).abs() - currentMoveSpeed;
@@ -115,33 +137,135 @@ class Dog extends Enemy {
_dodgeTicTimer--;
}
newAngle = position.angleTo(playerPosition) + _dodgeAngleOffset;
final baseAngle = position.angleTo(playerPosition);
final preferredAngle = baseAngle + _dodgeAngleOffset;
movement = getValidMovement(
intendedMovement: Coordinate2D(
math.cos(newAngle) * currentMoveSpeed,
math.sin(newAngle) * currentMoveSpeed,
),
playerPosition: playerPosition,
isWalkable: isWalkable,
// Alerted dogs cannot open doors! Pass an empty closure to treat doors as walls.
tryOpenDoor: (x, y) {},
);
// Build a SelectDodgeDir-inspired candidate list. The first two entries
// are the dodge-shifted diagonal and the direct diagonal (original
// behaviour). The next two are the pure cardinal directions toward the
// player along each axis — these align with doorways and corridors and
// handle cases where all diagonal variants clip a wall beside the opening.
final double toDx = playerPosition.x - position.x;
final double toDy = playerPosition.y - position.y;
final double primaryCardinal = toDx.abs() >= toDy.abs()
? (toDx >= 0 ? 0.0 : math.pi)
: (toDy >= 0 ? math.pi / 2 : -math.pi / 2);
final double secondaryCardinal = toDx.abs() < toDy.abs()
? (toDx >= 0 ? 0.0 : math.pi)
: (toDy >= 0 ? math.pi / 2 : -math.pi / 2);
// Diagonal-toward-player is the primary candidate. In a corridor the
// diagonal naturally wall-slides into the correct lane (e.g. northwest
// intent → blocked north → slides west along the corridor floor toward
// the door). Cardinal directions are kept only as last-resort fallbacks
// for when the entity is pinned in a corner.
final candidateAngles = <double>[
if (startedChaseThisFrame) ...[
baseAngle, // direct diagonal toward player on first chase frame
preferredAngle, // dodge-offset diagonal
] else ...[
preferredAngle, // dodge-offset diagonal (established chase)
baseAngle, // direct diagonal fallback
],
primaryCardinal,
secondaryCardinal,
baseAngle + math.pi / 2,
baseAngle - math.pi / 2,
];
newAngle = preferredAngle;
movement = const Coordinate2D(0, 0);
// A movement magnitude threshold prevents accepting near-zero floating-
// point residuals (e.g. cos(π/2) ≈ 6e-17) as valid movement.
final double minEffective = currentMoveSpeed * 0.5;
int selectedCandidateIndex = -1;
for (int i = 0; i < candidateAngles.length; i++) {
final candidateAngle = candidateAngles[i];
final candidateMovement = getValidMovement(
intendedMovement: Coordinate2D(
math.cos(candidateAngle) * currentMoveSpeed,
math.sin(candidateAngle) * currentMoveSpeed,
),
playerPosition: playerPosition,
isWalkable: isWalkable,
// Alerted dogs cannot open doors! Pass an empty closure to treat doors as walls.
tryOpenDoor: (x, y) {},
);
if (candidateMovement.x.abs() + candidateMovement.y.abs() >=
minEffective) {
newAngle = candidateAngle;
movement = candidateMovement;
selectedCandidateIndex = i;
break;
}
}
if (movement.x == 0 && movement.y == 0) {
// Blocked — don't visually enter chase state yet.
// Keep current state (patrolling) until a path opens.
} else {
state = EntityState.ambushing;
}
if (movement.x == 0 && movement.y == 0) {
_dodgeTicTimer = 0;
_stuckFrames++;
// Emit periodic diagnostics for dogs that appear to keep facing/moving
// into blocked geometry. This is intentionally sparse to avoid log spam.
if (_stuckFrames == 1 || _stuckFrames % 20 == 0) {
final int tileX = x.toInt();
final int tileY = y.toInt();
final int playerTileX = playerPosition.x.toInt();
final int playerTileY = playerPosition.y.toInt();
log(
'[DEBUG] Dog #$debugId stuck=$_stuckFrames '
'at ($tileX,$tileY)->player($playerTileX,$playerTileY) '
'base=${baseAngle.toStringAsFixed(3)} '
'pref=${preferredAngle.toStringAsFixed(3)} '
'cand=$selectedCandidateIndex '
'walk N=${isWalkable(tileX, tileY - 1)} '
'E=${isWalkable(tileX + 1, tileY)} '
'S=${isWalkable(tileX, tileY + 1)} '
'W=${isWalkable(tileX - 1, tileY)}',
);
}
} else {
if (_stuckFrames >= 20) {
log(
'[DEBUG] Dog #$debugId unstuck after $_stuckFrames frames '
'using candidate $selectedCandidateIndex '
'move=(${movement.x.toStringAsFixed(4)},${movement.y.toStringAsFixed(4)})',
);
}
_stuckFrames = 0;
}
} else if (state == EntityState.patrolling) {
movement = getValidMovement(
intendedMovement: Coordinate2D(
math.cos(angle) * currentMoveSpeed,
math.sin(angle) * currentMoveSpeed,
),
playerPosition: playerPosition,
isWalkable: isWalkable,
// Patrolling dogs using T_Path CAN open doors in the original logic.
tryOpenDoor: tryOpenDoor,
);
if (_patrolReversalCooldownMs > 0) {
// Stand still during the post-reversal pause.
_patrolReversalCooldownMs -= elapsedDeltaMs;
} else {
movement = getValidMovement(
intendedMovement: Coordinate2D(
math.cos(angle) * currentMoveSpeed,
math.sin(angle) * currentMoveSpeed,
),
playerPosition: playerPosition,
isWalkable: isWalkable,
// Patrolling dogs using T_Path CAN open doors in the original logic.
tryOpenDoor: tryOpenDoor,
);
// Matches T_Path behavior: when patrol is blocked, reverse direction.
// A cooldown prevents immediate re-reversal and rapid oscillation.
if (movement.x == 0 && movement.y == 0) {
newAngle = angle + math.pi;
_patrolReversalCooldownMs = 600;
}
}
}
if (ticReady) {
@@ -150,6 +274,29 @@ class Dog extends Enemy {
}
}
if (state != previousState) {
log(
'[DEBUG] Dog #$debugId state ${previousState.name} -> ${state.name} '
'at (${x.toStringAsFixed(2)}, ${y.toStringAsFixed(2)})',
);
}
final bool isMovingNow = movement.x.abs() + movement.y.abs() > 0.0001;
if (isMovingNow && !_wasMoving) {
final moveAngle = math.atan2(movement.y, movement.x);
final cardinalDirection = movement.x.abs() >= movement.y.abs()
? (movement.x >= 0 ? 'east' : 'west')
: (movement.y >= 0 ? 'south' : 'north');
log(
'[DEBUG] Dog #$debugId movement start '
'from (${x.toStringAsFixed(2)}, ${y.toStringAsFixed(2)}) '
'tile (${x.toInt()}, ${y.toInt()}) '
'dir=$cardinalDirection angle=${moveAngle.toStringAsFixed(3)} '
'vec=(${movement.x.toStringAsFixed(4)}, ${movement.y.toStringAsFixed(4)})',
);
}
_wasMoving = isMovingNow;
_updateAnimation(elapsedMs, newAngle, playerPosition);
return (movement: movement, newAngle: newAngle);
}

View File

@@ -1,3 +1,4 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/entities/entities/enemies/dog.dart';
@@ -14,6 +15,11 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// This class encapsulates the shared AI behaviors, including movement physics,
/// difficulty-based spawning, and the signature Wolfenstein 3D sight-detection system.
abstract class Enemy extends Entity {
static int _nextDebugId = 0;
/// A sequential ID assigned at spawn for debug identification.
final int debugId = ++_nextDebugId;
Enemy({
required super.x,
required super.y,
@@ -99,7 +105,9 @@ abstract class Enemy extends Entity {
/// Canonical Wolf3D ranged damage buckets by tile distance.
int rollRangedDamage(double distance, {bool isSharpShooter = false}) {
final adjustedDistance = isSharpShooter ? (distance * (2.0 / 3.0)) : distance;
final adjustedDistance = isSharpShooter
? (distance * (2.0 / 3.0))
: distance;
if (adjustedDistance < 2.0) {
return math.Random().nextInt(64); // 0..63
@@ -138,7 +146,9 @@ abstract class Enemy extends Entity {
required bool isPlayerRunning,
bool isSharpShooter = false,
}) {
final adjustedDistance = isSharpShooter ? (distance * (2.0 / 3.0)) : distance;
final adjustedDistance = isSharpShooter
? (distance * (2.0 / 3.0))
: distance;
final dist = adjustedDistance.floor();
final isVisible = isVisibleFromPlayer(
playerPosition: playerPosition,
@@ -170,6 +180,9 @@ abstract class Enemy extends Entity {
if (health <= 0) {
state = EntityState.dead;
isDying = true;
log(
'[DEBUG] Enemy #$debugId (${type.name}) killed at tile (${x.toInt()}, ${y.toInt()})',
);
} else if (math.Random().nextDouble() < 0.5) {
state = EntityState.pain;
setTics(5); // Add this so they actually pause to flinch
@@ -227,11 +240,18 @@ abstract class Enemy extends Entity {
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required int Function(int x, int y) areaAt,
required bool Function(int area) isAreaConnectedToPlayer,
int baseReactionMs = 200,
int reactionVarianceMs = 600,
}) {
bool didAlert = false;
final int area = areaAt(x.toInt(), y.toInt());
if (area >= 0 && !isAreaConnectedToPlayer(area)) {
return false;
}
if (!isAlerted && hasLineOfSight(playerPosition, isWalkable)) {
if (reactionTimeMs == 0) {
// First frame of spotting: calculate how long until they "wake up"
@@ -244,6 +264,12 @@ abstract class Enemy extends Entity {
isAlerted = true;
didAlert = true;
log(
'[DEBUG] Enemy #$debugId (${type.name}) alerted by sight '
'enemy=(${x.toStringAsFixed(2)}, ${y.toStringAsFixed(2)}) '
'player=(${playerPosition.x.toStringAsFixed(2)}, ${playerPosition.y.toStringAsFixed(2)})',
);
if (state == EntityState.idle || state == EntityState.ambushing) {
state = EntityState.patrolling;
setTics(10);
@@ -361,9 +387,17 @@ abstract class Enemy extends Entity {
if (!canMoveY) tryOpenDoor(currentTileX, targetTileY);
if (!canMoveDiag) tryOpenDoor(targetTileX, targetTileY);
// Allow sliding: if X is clear but Y/Diag is blocked, return only X movement
if (canMoveX) return Coordinate2D(intendedMovement.x, 0);
if (canMoveY) return Coordinate2D(0, intendedMovement.y);
// Slide in the primary direction of travel. This prevents the entity from
// being deflected sideways when navigating through a doorway: if the dog
// is moving mostly north and clips a wall tile beside an open door, the
// Y (northward) slide is preferred over the X (sideways) slide.
if (intendedMovement.y.abs() >= intendedMovement.x.abs()) {
if (canMoveY) return Coordinate2D(0, intendedMovement.y);
if (canMoveX) return Coordinate2D(intendedMovement.x, 0);
} else {
if (canMoveX) return Coordinate2D(intendedMovement.x, 0);
if (canMoveY) return Coordinate2D(0, intendedMovement.y);
}
return const Coordinate2D(0, 0);
}
}
@@ -393,6 +427,8 @@ abstract class Enemy extends Entity {
required double playerAngle,
required bool isPlayerRunning,
required bool Function(int x, int y) isWalkable,
required int Function(int x, int y) areaAt,
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int damage) onDamagePlayer,
required void Function(int sfxId) onPlaySound,

View File

@@ -13,6 +13,9 @@ class Guard extends Enemy {
@override
EnemyType get type => EnemyType.guard;
/// Prevents rapid patrol direction oscillation after hitting a wall.
int _patrolReversalCooldownMs = 0;
Guard({
required super.x,
required super.y,
@@ -34,6 +37,8 @@ class Guard extends Enemy {
required double playerAngle,
required bool isPlayerRunning,
required bool Function(int x, int y) isWalkable,
required int Function(int x, int y) areaAt,
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
@@ -47,6 +52,8 @@ class Guard extends Enemy {
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
areaAt: areaAt,
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
)) {
onPlaySound(alertSoundId);
}
@@ -113,16 +120,26 @@ class Guard extends Enemy {
tryOpenDoor: tryOpenDoor,
);
} else if (state == EntityState.patrolling) {
// Normal patrol movement
movement = getValidMovement(
intendedMovement: Coordinate2D(
math.cos(angle) * currentMoveSpeed,
math.sin(angle) * currentMoveSpeed,
),
playerPosition: playerPosition,
isWalkable: isWalkable,
tryOpenDoor: tryOpenDoor,
);
if (_patrolReversalCooldownMs > 0) {
_patrolReversalCooldownMs -= elapsedDeltaMs;
} else {
// Normal patrol movement
movement = getValidMovement(
intendedMovement: Coordinate2D(
math.cos(angle) * currentMoveSpeed,
math.sin(angle) * currentMoveSpeed,
),
playerPosition: playerPosition,
isWalkable: isWalkable,
tryOpenDoor: tryOpenDoor,
);
// Matches T_Path behavior: when patrol is blocked, reverse direction.
// A cooldown prevents immediate re-reversal and rapid oscillation.
if (movement.x == 0 && movement.y == 0) {
newAngle = angle + math.pi;
_patrolReversalCooldownMs = 600;
}
}
}
if (ticReady) {

View File

@@ -32,6 +32,8 @@ class Mutant extends Enemy {
required double playerAngle,
required bool isPlayerRunning,
required bool Function(int x, int y) isWalkable,
required int Function(int x, int y) areaAt,
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
@@ -43,6 +45,8 @@ class Mutant extends Enemy {
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
areaAt: areaAt,
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
)) {
onPlaySound(alertSoundId);
}

View File

@@ -32,6 +32,8 @@ class Officer extends Enemy {
required double playerAngle,
required bool isPlayerRunning,
required bool Function(int x, int y) isWalkable,
required int Function(int x, int y) areaAt,
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
@@ -43,6 +45,8 @@ class Officer extends Enemy {
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
areaAt: areaAt,
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
)) {
onPlaySound(alertSoundId);
}

View File

@@ -31,6 +31,8 @@ class SS extends Enemy {
required double playerAngle,
required bool isPlayerRunning,
required bool Function(int x, int y) isWalkable,
required int Function(int x, int y) areaAt,
required bool Function(int area) isAreaConnectedToPlayer,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
@@ -42,6 +44,8 @@ class SS extends Enemy {
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
areaAt: areaAt,
isAreaConnectedToPlayer: isAreaConnectedToPlayer,
)) {
onPlaySound(alertSoundId);
}

View File

@@ -1,3 +1,4 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart';
@@ -43,7 +44,7 @@ abstract class Weapon {
// Safety check!
if (calculatedIndex < 0 || calculatedIndex >= maxSprites) {
print("WARNING: Weapon sprite index $calculatedIndex out of bounds!");
log("WARNING: Weapon sprite index $calculatedIndex out of bounds!");
return 0;
}
return calculatedIndex;
@@ -64,7 +65,7 @@ abstract class Weapon {
lastFrameTime = currentTime;
_triggerReleased = false;
print("[WEAPON] $type fired.");
log("[WEAPON] $type fired.");
return true;
}

View File

@@ -1,3 +1,4 @@
import 'dart:developer';
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
@@ -11,7 +12,7 @@ class WolfAudio implements EngineAudio {
// Play the first 50 sounds with a 2-second gap to identify them
for (int i = 0; i < 50; i++) {
Future.delayed(Duration(seconds: i * 2), () {
print("[AUDIO] Testing Sound ID: $i");
log("[AUDIO] Testing Sound ID: $i");
playSoundEffect(i);
});
}
@@ -50,11 +51,11 @@ class WolfAudio implements EngineAudio {
}
_isInitialized = true;
print(
log(
"[AUDIO] AudioPlayers initialized successfully with $_maxSfxChannels SFX channels.",
);
} catch (e) {
print("[AUDIO] Failed to initialize AudioPlayers - $e");
log("[AUDIO] Failed to initialize AudioPlayers - $e");
}
}
@@ -90,7 +91,7 @@ class WolfAudio implements EngineAudio {
);
await _musicPlayer.play(BytesSource(wavBytes));
} catch (e) {
print("[AUDIO] Error playing music track - $e");
log("[AUDIO] Error playing music track - $e");
}
}
@@ -124,7 +125,7 @@ class WolfAudio implements EngineAudio {
if (index < data.music.length) {
await playMusic(data.music[index]);
} else {
print("[AUDIO] Warning - Track index $index out of bounds.");
log("[AUDIO] Warning - Track index $index out of bounds.");
}
}
@@ -134,7 +135,7 @@ class WolfAudio implements EngineAudio {
@override
Future<void> playSoundEffect(int sfxId) async {
print("[AUDIO] Playing sfx id $sfxId");
log("[AUDIO] Playing sfx id $sfxId");
// The original engine uses a specific starting chunk for digitized sounds.
// In many loaders, the 'sounds' list is already just the digitized ones.
// If your list contains EVERYTHING, you need to add the offset (174).
@@ -168,7 +169,7 @@ class WolfAudio implements EngineAudio {
// Note: We use BytesSource because createWavFile returns Uint8List (the file bytes)
await player.play(BytesSource(wavBytes));
} catch (e) {
print("[AUDIO] SFX Error - $e");
log("[AUDIO] SFX Error - $e");
}
}
}

View File

@@ -132,6 +132,7 @@ WolfEngine _buildEngine({
WolfLevel(
name: 'Test Level',
wallGrid: wallGrid,
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
objectGrid: objectGrid,
musicIndex: 0,
),

View File

@@ -18,8 +18,9 @@ void main() {
ss.takeDamage(999, engine.timeAliveMs);
engine.tick(const Duration(milliseconds: 16));
final droppedMachineGun =
engine.entities.whereType<WeaponCollectible>().any(
final droppedMachineGun = engine.entities
.whereType<WeaponCollectible>()
.any(
(item) => item.mapId == MapObject.machineGun,
);
expect(droppedMachineGun, isTrue);
@@ -85,6 +86,7 @@ WolfEngine _buildEngine() {
WolfLevel(
name: 'Test Level',
wallGrid: wallGrid,
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
objectGrid: objectGrid,
musicIndex: 0,
),

View File

@@ -66,6 +66,8 @@ void main() {
playerAngle: 0,
isPlayerRunning: false,
isWalkable: (_, _) => false,
areaAt: (_, _) => 0,
isAreaConnectedToPlayer: (_) => true,
onDamagePlayer: (_) {},
tryOpenDoor: (_, _) {},
onPlaySound: (_) {},
@@ -78,6 +80,8 @@ void main() {
playerAngle: 0,
isPlayerRunning: false,
isWalkable: (_, _) => false,
areaAt: (_, _) => 0,
isAreaConnectedToPlayer: (_) => true,
onDamagePlayer: (_) {},
tryOpenDoor: (_, _) {},
onPlaySound: (_) {},
@@ -100,6 +104,8 @@ void main() {
playerAngle: 0,
isPlayerRunning: false,
isWalkable: (_, _) => true,
areaAt: (_, _) => 0,
isAreaConnectedToPlayer: (_) => true,
onDamagePlayer: (_) {},
tryOpenDoor: (_, _) {},
onPlaySound: (_) {},
@@ -112,16 +118,53 @@ void main() {
playerAngle: 0,
isPlayerRunning: false,
isWalkable: (_, _) => true,
areaAt: (_, _) => 0,
isAreaConnectedToPlayer: (_) => true,
onDamagePlayer: (_) {},
tryOpenDoor: (_, _) {},
onPlaySound: (_) {},
);
expect(guardIntent.movement.x.abs() + guardIntent.movement.y.abs(),
greaterThan(0));
expect(
dogIntent.movement.x.abs() + dogIntent.movement.y.abs(), greaterThan(0));
guardIntent.movement.x.abs() + guardIntent.movement.y.abs(),
greaterThan(0),
);
expect(
dogIntent.movement.x.abs() + dogIntent.movement.y.abs(),
greaterThan(0),
);
});
test(
'alerted dog can choose alternate path when direct chase is blocked',
() {
final dog = Dog(x: 8.99, y: 8.99, angle: 0, mapId: MapObject.dogStart);
dog.isAlerted = true;
final intent = dog.update(
elapsedMs: 1000,
elapsedDeltaMs: 16,
playerPosition: const Coordinate2D(11.99, 8.99),
playerAngle: 0,
isPlayerRunning: false,
isWalkable: (x, y) {
// Block the direct east chase tile only.
if (x == 9 && y == 8) return false;
return true;
},
areaAt: (_, _) => 0,
isAreaConnectedToPlayer: (_) => true,
onDamagePlayer: (_) {},
tryOpenDoor: (_, _) {},
onPlaySound: (_) {},
);
expect(
intent.movement.x.abs() + intent.movement.y.abs(),
greaterThan(0),
);
},
);
});
}

View File

@@ -43,6 +43,7 @@ void main() {
WolfLevel(
name: 'Test Level',
wallGrid: wallGrid,
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
objectGrid: objectGrid,
musicIndex: 0,
),

View File

@@ -43,6 +43,7 @@ void main() {
WolfLevel(
name: 'Test Level',
wallGrid: wallGrid,
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
objectGrid: objectGrid,
musicIndex: 0,
),