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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
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,44 +648,80 @@ 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;
|
||||
|
||||
while (queue.isNotEmpty && distance < maxAcousticRange) {
|
||||
List<({int x, int y})> nextQueue = [];
|
||||
|
||||
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);
|
||||
log(
|
||||
'[DEBUG] Enemy #${entity.debugId} (${entity.type.name}) '
|
||||
'alerted by gunfire in area $area',
|
||||
);
|
||||
|
||||
// Wake them up!
|
||||
if (entity.state == EntityState.idle ||
|
||||
entity.state == EntityState.ambushing) {
|
||||
if (entity.state == EntityState.idle) {
|
||||
entity.state = EntityState.patrolling;
|
||||
entity.lastActionTime = _timeAliveMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int _areaAt(int x, int y) {
|
||||
if (x < 0 || x >= 64 || y < 0 || y >= 64) return -1;
|
||||
return _areaGrid[y][x];
|
||||
}
|
||||
|
||||
// 2. Expand the sound wave outward to North, East, South, West
|
||||
bool _isAreaConnectedToPlayer(int area) {
|
||||
return area >= 0 && area < _areasByPlayer.length && _areasByPlayer[area];
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -654,23 +729,79 @@ class WolfEngine {
|
||||
(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);
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sound only travels through walkable tiles (air and OPEN doors).
|
||||
if (isWalkable(n.x, n.y)) {
|
||||
nextQueue.add(n);
|
||||
_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);
|
||||
}
|
||||
queue = nextQueue;
|
||||
distance++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,12 +137,56 @@ class Dog extends Enemy {
|
||||
_dodgeTicTimer--;
|
||||
}
|
||||
|
||||
newAngle = position.angleTo(playerPosition) + _dodgeAngleOffset;
|
||||
final baseAngle = position.angleTo(playerPosition);
|
||||
final preferredAngle = baseAngle + _dodgeAngleOffset;
|
||||
|
||||
movement = getValidMovement(
|
||||
// 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(newAngle) * currentMoveSpeed,
|
||||
math.sin(newAngle) * currentMoveSpeed,
|
||||
math.cos(candidateAngle) * currentMoveSpeed,
|
||||
math.sin(candidateAngle) * currentMoveSpeed,
|
||||
),
|
||||
playerPosition: playerPosition,
|
||||
isWalkable: isWalkable,
|
||||
@@ -128,10 +194,61 @@ class Dog extends Enemy {
|
||||
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) {
|
||||
if (_patrolReversalCooldownMs > 0) {
|
||||
// Stand still during the post-reversal pause.
|
||||
_patrolReversalCooldownMs -= elapsedDeltaMs;
|
||||
} else {
|
||||
movement = getValidMovement(
|
||||
intendedMovement: Coordinate2D(
|
||||
math.cos(angle) * currentMoveSpeed,
|
||||
@@ -142,6 +259,13 @@ class Dog extends Enemy {
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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,
|
||||
|
||||
@@ -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,6 +120,9 @@ class Guard extends Enemy {
|
||||
tryOpenDoor: tryOpenDoor,
|
||||
);
|
||||
} else if (state == EntityState.patrolling) {
|
||||
if (_patrolReversalCooldownMs > 0) {
|
||||
_patrolReversalCooldownMs -= elapsedDeltaMs;
|
||||
} else {
|
||||
// Normal patrol movement
|
||||
movement = getValidMovement(
|
||||
intendedMovement: Coordinate2D(
|
||||
@@ -123,6 +133,13 @@ class Guard extends Enemy {
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ WolfEngine _buildEngine({
|
||||
WolfLevel(
|
||||
name: 'Test Level',
|
||||
wallGrid: wallGrid,
|
||||
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
|
||||
objectGrid: objectGrid,
|
||||
musicIndex: 0,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ void main() {
|
||||
WolfLevel(
|
||||
name: 'Test Level',
|
||||
wallGrid: wallGrid,
|
||||
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
|
||||
objectGrid: objectGrid,
|
||||
musicIndex: 0,
|
||||
),
|
||||
|
||||
@@ -43,6 +43,7 @@ void main() {
|
||||
WolfLevel(
|
||||
name: 'Test Level',
|
||||
wallGrid: wallGrid,
|
||||
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
|
||||
objectGrid: objectGrid,
|
||||
musicIndex: 0,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user