diff --git a/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart b/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart index 4b313ba..f6feaef 100644 --- a/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart +++ b/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart @@ -1,3 +1,4 @@ +import 'dart:developer'; import 'dart:io'; import 'dart:typed_data'; @@ -12,7 +13,7 @@ Future> 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> 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> 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> discoverInDirectory({ loadedVersions[version] = data; } catch (e) { - print('Error parsing data for ${version.name}: $e'); + log('Error parsing data for ${version.name}: $e'); } } diff --git a/packages/wolf_3d_dart/lib/src/data/wl_parser.dart b/packages/wolf_3d_dart/lib/src/data/wl_parser.dart index 36d2a4b..7bc11b4 100644 --- a/packages/wolf_3d_dart/lib/src/data/wl_parser.dart +++ b/packages/wolf_3d_dart/lib/src/data/wl_parser.dart @@ -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 _sharewareMusicMap = [ 2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Episode 1 @@ -486,16 +488,25 @@ abstract class WLParser { // --- BUILD 64x64 GRIDS --- List> wallGrid = []; List> objectGrid = []; + List> areaGrid = []; for (int y = 0; y < 64; y++) { List wallRow = []; List objectRow = []; + List 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, ), ); diff --git a/packages/wolf_3d_dart/lib/src/data_types/wolf_level.dart b/packages/wolf_3d_dart/lib/src/data_types/wolf_level.dart index ad889ec..defa4ac 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/wolf_level.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/wolf_level.dart @@ -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, }); } diff --git a/packages/wolf_3d_dart/lib/src/engine/audio/silent_renderer.dart b/packages/wolf_3d_dart/lib/src/engine/audio/silent_renderer.dart index 36b3899..c5d7938 100644 --- a/packages/wolf_3d_dart/lib/src/engine/audio/silent_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/engine/audio/silent_renderer.dart @@ -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 diff --git a/packages/wolf_3d_dart/lib/src/engine/managers/door_manager.dart b/packages/wolf_3d_dart/lib/src/engine/managers/door_manager.dart index 11d3242..ab6fd6d 100644 --- a/packages/wolf_3d_dart/lib/src/engine/managers/door_manager.dart +++ b/packages/wolf_3d_dart/lib/src/engine/managers/door_manager.dart @@ -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. diff --git a/packages/wolf_3d_dart/lib/src/engine/player/player.dart b/packages/wolf_3d_dart/lib/src/engine/player/player.dart index 46a931e..af0be1c 100644 --- a/packages/wolf_3d_dart/lib/src/engine/player/player.dart +++ b/packages/wolf_3d_dart/lib/src/engine/player/player.dart @@ -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); } } diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index 398b185..e40d783 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -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 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 _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 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.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.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>.generate( + 64, + (_) => List.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); + } } } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart index 22f6ad2..7d0f21c 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart @@ -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); diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart index cde4b34..1818e7a 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart @@ -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 = [ + 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); } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart index 0a97056..7d2e202 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart @@ -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, diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart index 6096f1d..b56ed2f 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/guard.dart @@ -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) { diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart index 0ae9ab7..b296730 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/mutant.dart @@ -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); } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart index 0fb6b65..0d918cf 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/officer.dart @@ -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); } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart index 2d6c2b1..75c85c6 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart @@ -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); } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/weapon/weapon.dart b/packages/wolf_3d_dart/lib/src/entities/entities/weapon/weapon.dart index 21add95..0e56592 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/weapon/weapon.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/weapon/weapon.dart @@ -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; } diff --git a/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart b/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart index cd39872..45da72c 100644 --- a/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart +++ b/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart @@ -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 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"); } } } diff --git a/packages/wolf_3d_dart/test/engine/audio_events_test.dart b/packages/wolf_3d_dart/test/engine/audio_events_test.dart index 6213ea6..aa077e0 100644 --- a/packages/wolf_3d_dart/test/engine/audio_events_test.dart +++ b/packages/wolf_3d_dart/test/engine/audio_events_test.dart @@ -132,6 +132,7 @@ WolfEngine _buildEngine({ WolfLevel( name: 'Test Level', wallGrid: wallGrid, + areaGrid: List.generate(64, (_) => List.filled(64, -1)), objectGrid: objectGrid, musicIndex: 0, ), diff --git a/packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart b/packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart index 5228bdf..e483c58 100644 --- a/packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart +++ b/packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart @@ -18,8 +18,9 @@ void main() { ss.takeDamage(999, engine.timeAliveMs); engine.tick(const Duration(milliseconds: 16)); - final droppedMachineGun = - engine.entities.whereType().any( + final droppedMachineGun = engine.entities + .whereType() + .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, ), diff --git a/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart b/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart index 58bb245..8241e12 100644 --- a/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart +++ b/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart @@ -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), + ); + }, + ); }); } diff --git a/packages/wolf_3d_dart/test/rasterizer/pushwall_rasterizer_test.dart b/packages/wolf_3d_dart/test/rasterizer/pushwall_rasterizer_test.dart index 414ba85..51bde6c 100644 --- a/packages/wolf_3d_dart/test/rasterizer/pushwall_rasterizer_test.dart +++ b/packages/wolf_3d_dart/test/rasterizer/pushwall_rasterizer_test.dart @@ -43,6 +43,7 @@ void main() { WolfLevel( name: 'Test Level', wallGrid: wallGrid, + areaGrid: List.generate(64, (_) => List.filled(64, -1)), objectGrid: objectGrid, musicIndex: 0, ), diff --git a/packages/wolf_3d_dart/test/rendering/pushwall_renderer_test.dart b/packages/wolf_3d_dart/test/rendering/pushwall_renderer_test.dart index 41341a5..cb96998 100644 --- a/packages/wolf_3d_dart/test/rendering/pushwall_renderer_test.dart +++ b/packages/wolf_3d_dart/test/rendering/pushwall_renderer_test.dart @@ -43,6 +43,7 @@ void main() { WolfLevel( name: 'Test Level', wallGrid: wallGrid, + areaGrid: List.generate(64, (_) => List.filled(64, -1)), objectGrid: objectGrid, musicIndex: 0, ),