diff --git a/packages/wolf_3d_dart/lib/src/data_types/map_objects.dart b/packages/wolf_3d_dart/lib/src/data_types/map_objects.dart index 84f0168..deb2650 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/map_objects.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/map_objects.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart'; @@ -66,6 +68,16 @@ abstract class MapObject { static const int vines = 68; // --- Logic & Triggers --- + // Path markers used by patrolling enemies (T_Path-style route turns). + static const int patrolEast = 90; + static const int patrolNorth = 91; + static const int patrolWest = 92; + static const int patrolSouth = 93; + static const int patrolEastAlt = 94; + static const int patrolNorthAlt = 95; + static const int patrolWestAlt = 96; + static const int patrolSouthAlt = 97; + static const int pushwallTrigger = 98; static const int secretExitTrigger = 99; static const int normalExitTrigger = 100; @@ -143,4 +155,20 @@ abstract class MapObject { // 3. Static items (ammo, health, plants) are constant across all difficulties. return true; } + + /// Returns a patrol facing angle for path markers, or null if [objId] + /// is not a patrol routing marker. + static double? patrolAngleForMarker(int objId) { + return switch (objId) { + patrolEast => 0.0, + patrolNorth => -math.pi / 4, + patrolWest => -math.pi / 2, + patrolSouth => -3 * math.pi / 4, + patrolEastAlt => math.pi, + patrolNorthAlt => 3 * math.pi / 4, + patrolWestAlt => math.pi / 2, + patrolSouthAlt => math.pi / 4, + _ => null, + }; + } } 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 e40d783..17023b1 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 @@ -122,6 +122,9 @@ class WolfEngine { /// The static level data source used for reloading or reference. late WolfLevel activeLevel; + /// The static object layer (spawns, markers, triggers) for the active level. + late SpriteMap _objectLevel; + /// All dynamic entities currently in the level (Enemies, Pickups). List entities = []; @@ -134,6 +137,9 @@ class WolfEngine { /// Total number of known area indices in the active level. int _areaCount = 0; + /// Last tile processed for patrol marker routing, keyed by enemy debug id. + final Map _lastPatrolTileByEnemy = {}; + int _currentEpisodeIndex = 0; bool _isPlayerMovingFast = false; @@ -336,6 +342,7 @@ class WolfEngine { /// Wipes the current world state and builds a new floor from map data. void _loadLevel() { entities.clear(); + _lastPatrolTileByEnemy.clear(); final episode = data.episodes[_currentEpisodeIndex]; activeLevel = episode.levels[_currentLevelIndex]; @@ -343,16 +350,16 @@ 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; + _objectLevel = activeLevel.objectGrid; doorManager.initDoors(currentLevel); - pushwallManager.initPushwalls(currentLevel, objectLevel); + pushwallManager.initPushwalls(currentLevel, _objectLevel); audio.playLevelMusic(activeLevel); // Spawn Player and Entities from the Object Grid for (int y = 0; y < 64; y++) { for (int x = 0; x < 64; x++) { - int objId = objectLevel[y][x]; + int objId = _objectLevel[y][x]; // Map IDs 19-22 are Reserved for Player Starts if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) { @@ -552,6 +559,10 @@ class WolfEngine { } // Standard AI Update cycle + if (!entity.isAlerted && entity.state == EntityState.patrolling) { + _applyPatrolPathMarker(entity); + } + final intent = entity.update( elapsedMs: _timeAliveMs, elapsedDeltaMs: elapsed.inMilliseconds, @@ -805,6 +816,40 @@ class WolfEngine { } } + void _applyPatrolPathMarker(Enemy enemy) { + final tileX = enemy.x.toInt(); + final tileY = enemy.y.toInt(); + if (tileX < 0 || tileX >= 64 || tileY < 0 || tileY >= 64) return; + + final previousTile = _lastPatrolTileByEnemy[enemy.debugId]; + if (previousTile != null && + previousTile.x == tileX && + previousTile.y == tileY) { + return; + } + _lastPatrolTileByEnemy[enemy.debugId] = (x: tileX, y: tileY); + + final markerId = _objectLevel[tileY][tileX]; + final markerAngle = MapObject.patrolAngleForMarker(markerId); + if (enemy is Dog) { + log( + '[DEBUG] Dog #${enemy.debugId} patrol tile entry ' + '($tileX, $tileY) marker=$markerId', + ); + } + if (markerAngle == null) return; + + final normalizedDiff = + ((enemy.angle - markerAngle + math.pi) % (2 * math.pi)) - math.pi; + if (normalizedDiff.abs() > 0.001) { + enemy.angle = markerAngle; + log( + '[DEBUG] Enemy #${enemy.debugId} (${enemy.type.name}) ' + 'patrol marker $markerId applied at ($tileX, $tileY)', + ); + } + } + /// Returns true if a tile is empty or contains a door that is sufficiently open. bool isWalkable(int x, int y) { // 1. Boundary Guard: Prevent range errors by checking if coordinates are on the map 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 1818e7a..e48bd61 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 @@ -17,10 +17,6 @@ class Dog extends Enemy { 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; @@ -103,6 +99,7 @@ class Dog extends Enemy { double currentMoveSpeed = speedPerTic * ticsThisFrame; if (isAlerted) { + resetPatrolPathState(); // 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. @@ -179,6 +176,7 @@ class Dog extends Enemy { // 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; + final double currentDistanceToPlayer = position.distanceTo(playerPosition); int selectedCandidateIndex = -1; for (int i = 0; i < candidateAngles.length; i++) { @@ -194,13 +192,26 @@ class Dog extends Enemy { tryOpenDoor: (x, y) {}, ); - if (candidateMovement.x.abs() + candidateMovement.y.abs() >= - minEffective) { - newAngle = candidateAngle; - movement = candidateMovement; - selectedCandidateIndex = i; - break; + if (candidateMovement.x.abs() + candidateMovement.y.abs() < minEffective) { + continue; } + + final candidatePosition = position + candidateMovement; + final double candidateDistanceToPlayer = candidatePosition.distanceTo( + playerPosition, + ); + + // Canonical-like chase guard: when actively pursuing, do not pick a + // vector that moves the dog farther away from the player if a closer + // candidate exists this frame. + if (candidateDistanceToPlayer > currentDistanceToPlayer + 0.001) { + continue; + } + + newAngle = candidateAngle; + movement = candidateMovement; + selectedCandidateIndex = i; + break; } if (movement.x == 0 && movement.y == 0) { @@ -245,27 +256,13 @@ class Dog extends Enemy { _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, - 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; - } - } + movement = getPatrolPathMovement( + moveSpeed: currentMoveSpeed, + playerPosition: playerPosition, + isWalkable: isWalkable, + // Patrolling dogs using T_Path CAN open doors in the original logic. + tryOpenDoor: tryOpenDoor, + ); } if (ticReady) { 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 7d2e202..28eeeda 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 @@ -79,6 +79,10 @@ abstract class Enemy extends Entity { /// This replaces the `ob->temp2` variable found in the original `WL_ACT2.C`. int reactionTimeMs = 0; + ({int x, int y})? _patrolTargetTile; + int _patrolDirX = 0; + int _patrolDirY = 0; + /// Processes elapsed time and returns true if the enemy's animation frame has completed. /// /// Movement is applied continuously during the frame, but state changes (like deciding @@ -208,6 +212,9 @@ abstract class Enemy extends Entity { currentFrame++; if (currentFrame == 1) { + log( + '[DEBUG] Enemy #$debugId (${type.name}) is shooting at player at distance ${distance.toStringAsFixed(2)}', + ); // Phase 1: Bang! Calculate hit chance based on distance. // Drops by ~5% per tile distance, capped at a minimum 10% chance to hit. onAttack(); @@ -259,6 +266,12 @@ abstract class Enemy extends Entity { elapsedMs + baseReactionMs + math.Random().nextInt(reactionVarianceMs); + log( + '[DEBUG] Enemy #$debugId (${type.name}) spotted player but is reacting... ' + 'enemy=(${x.toStringAsFixed(2)}, ${y.toStringAsFixed(2)}) ' + 'player=(${playerPosition.x.toStringAsFixed(2)}, ${playerPosition.y.toStringAsFixed(2)}) ' + 'reactionTimeMs=$reactionTimeMs', + ); } else if (elapsedMs >= reactionTimeMs) { // Reaction delay has passed isAlerted = true; @@ -353,6 +366,13 @@ abstract class Enemy extends Entity { required bool Function(int x, int y) isWalkable, required void Function(int x, int y) tryOpenDoor, }) { + Coordinate2D normalizeTiny(Coordinate2D movement) { + const epsilon = 1e-6; + final x = movement.x.abs() < epsilon ? 0.0 : movement.x; + final y = movement.y.abs() < epsilon ? 0.0 : movement.y; + return Coordinate2D(x, y); + } + final double distToPlayer = position.distanceTo(playerPosition); const double minDistance = 0.9; @@ -392,11 +412,11 @@ abstract class Enemy extends Entity { // 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); + if (canMoveY) return normalizeTiny(Coordinate2D(0, intendedMovement.y)); + if (canMoveX) return normalizeTiny(Coordinate2D(intendedMovement.x, 0)); } else { - if (canMoveX) return Coordinate2D(intendedMovement.x, 0); - if (canMoveY) return Coordinate2D(0, intendedMovement.y); + if (canMoveX) return normalizeTiny(Coordinate2D(intendedMovement.x, 0)); + if (canMoveY) return normalizeTiny(Coordinate2D(0, intendedMovement.y)); } return const Coordinate2D(0, 0); } @@ -406,17 +426,100 @@ abstract class Enemy extends Entity { if (movedX && !movedY) { if (!isWalkable(targetTileX, currentTileY)) { tryOpenDoor(targetTileX, currentTileY); - return Coordinate2D(0, intendedMovement.y); + return const Coordinate2D(0, 0); } } if (movedY && !movedX) { if (!isWalkable(currentTileX, targetTileY)) { tryOpenDoor(currentTileX, targetTileY); - return Coordinate2D(intendedMovement.x, 0); + return const Coordinate2D(0, 0); } } - return intendedMovement; + return normalizeTiny(intendedMovement); + } + + void resetPatrolPathState() { + _patrolTargetTile = null; + _patrolDirX = 0; + _patrolDirY = 0; + } + + Coordinate2D getPatrolPathMovement({ + required double moveSpeed, + required Coordinate2D playerPosition, + required bool Function(int x, int y) isWalkable, + required void Function(int x, int y) tryOpenDoor, + }) { + ({int x, int y}) directionFromAngle8(double angle) { + int dx = math.cos(angle).round(); + int dy = math.sin(angle).round(); + + if (dx == 0 && dy == 0) { + dx = math.cos(angle) >= 0 ? 1 : -1; + } + + return (x: dx.clamp(-1, 1), y: dy.clamp(-1, 1)); + } + + final currentTileX = x.toInt(); + final currentTileY = y.toInt(); + final dir = directionFromAngle8(angle); + + if (dir.x != _patrolDirX || dir.y != _patrolDirY) { + _patrolDirX = dir.x; + _patrolDirY = dir.y; + _patrolTargetTile = null; + } + + final reachedCurrentTarget = + _patrolTargetTile != null && + _patrolTargetTile!.x == currentTileX && + _patrolTargetTile!.y == currentTileY; + + if (_patrolTargetTile == null || reachedCurrentTarget) { + _patrolTargetTile = ( + x: currentTileX + _patrolDirX, + y: currentTileY + _patrolDirY, + ); + } + + final target = _patrolTargetTile!; + if (!isWalkable(target.x, target.y)) { + tryOpenDoor(target.x, target.y); + return const Coordinate2D(0, 0); + } + + final targetCenter = Coordinate2D(target.x + 0.5, target.y + 0.5); + final toTarget = targetCenter - position; + final distance = math.sqrt( + toTarget.x * toTarget.x + toTarget.y * toTarget.y, + ); + + if (distance <= 0.0001) { + return const Coordinate2D(0, 0); + } + + if (distance <= moveSpeed) { + return getValidMovement( + intendedMovement: toTarget, + playerPosition: playerPosition, + isWalkable: isWalkable, + tryOpenDoor: tryOpenDoor, + ); + } + + final step = Coordinate2D( + (toTarget.x / distance) * moveSpeed, + (toTarget.y / distance) * moveSpeed, + ); + + return getValidMovement( + intendedMovement: step, + playerPosition: playerPosition, + isWalkable: isWalkable, + tryOpenDoor: tryOpenDoor, + ); } /// The per-frame update logic to be implemented by specific enemy types. 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 b56ed2f..a80db1f 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,9 +13,6 @@ 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, @@ -95,6 +92,7 @@ class Guard extends Enemy { double currentMoveSpeed = speedPerTic * ticsThisFrame; if (isAlerted) { + resetPatrolPathState(); newAngle = position.angleTo(playerPosition); // AI Decision: Should I stop to shoot? (Only check on tic boundaries) @@ -120,26 +118,12 @@ class Guard extends Enemy { tryOpenDoor: tryOpenDoor, ); } else if (state == EntityState.patrolling) { - 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; - } - } + movement = getPatrolPathMovement( + moveSpeed: currentMoveSpeed, + playerPosition: playerPosition, + isWalkable: isWalkable, + tryOpenDoor: tryOpenDoor, + ); } 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 b296730..42d0893 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 @@ -81,17 +81,26 @@ class Mutant extends Enemy { ); if (state == EntityState.patrolling) { - if (!isAlerted || distance > 0.8) { - double currentMoveAngle = isAlerted ? angleToPlayer : angle; - movement = getValidMovement( - intendedMovement: Coordinate2D( - math.cos(currentMoveAngle) * speed, - math.sin(currentMoveAngle) * speed, - ), + if (!isAlerted) { + movement = getPatrolPathMovement( + moveSpeed: speed, playerPosition: playerPosition, isWalkable: isWalkable, tryOpenDoor: tryOpenDoor, ); + } else { + resetPatrolPathState(); + if (distance > 0.8) { + movement = getValidMovement( + intendedMovement: Coordinate2D( + math.cos(angleToPlayer) * speed, + math.sin(angleToPlayer) * speed, + ), + playerPosition: playerPosition, + isWalkable: isWalkable, + tryOpenDoor: tryOpenDoor, + ); + } } if (processTics(elapsedDeltaMs, moveSpeed: speed)) { 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 0d918cf..e18c463 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 @@ -81,17 +81,26 @@ class Officer extends Enemy { ); if (state == EntityState.patrolling) { - if (!isAlerted || distance > 0.8) { - double currentMoveAngle = isAlerted ? angleToPlayer : angle; - movement = getValidMovement( - intendedMovement: Coordinate2D( - math.cos(currentMoveAngle) * speed, - math.sin(currentMoveAngle) * speed, - ), + if (!isAlerted) { + movement = getPatrolPathMovement( + moveSpeed: speed, playerPosition: playerPosition, isWalkable: isWalkable, tryOpenDoor: tryOpenDoor, ); + } else { + resetPatrolPathState(); + if (distance > 0.8) { + movement = getValidMovement( + intendedMovement: Coordinate2D( + math.cos(angleToPlayer) * speed, + math.sin(angleToPlayer) * speed, + ), + playerPosition: playerPosition, + isWalkable: isWalkable, + tryOpenDoor: tryOpenDoor, + ); + } } if (processTics(elapsedDeltaMs, moveSpeed: speed)) { 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 75c85c6..ef1485f 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 @@ -80,17 +80,26 @@ class SS extends Enemy { ); if (state == EntityState.patrolling) { - if (!isAlerted || distance > 0.8) { - double currentMoveAngle = isAlerted ? angleToPlayer : angle; - movement = getValidMovement( - intendedMovement: Coordinate2D( - math.cos(currentMoveAngle) * speed, - math.sin(currentMoveAngle) * speed, - ), + if (!isAlerted) { + movement = getPatrolPathMovement( + moveSpeed: speed, playerPosition: playerPosition, isWalkable: isWalkable, tryOpenDoor: tryOpenDoor, ); + } else { + resetPatrolPathState(); + if (distance > 0.8) { + movement = getValidMovement( + intendedMovement: Coordinate2D( + math.cos(angleToPlayer) * speed, + math.sin(angleToPlayer) * speed, + ), + playerPosition: playerPosition, + isWalkable: isWalkable, + tryOpenDoor: tryOpenDoor, + ); + } } if (processTics(elapsedDeltaMs, moveSpeed: speed)) { 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 8241e12..243367c 100644 --- a/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart +++ b/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:test/test.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart'; @@ -165,6 +167,80 @@ void main() { ); }, ); + + test('alerted dog chase does not increase player distance', () { + final dog = Dog(x: 10.5, y: 10.5, angle: 0, mapId: MapObject.dogStart); + dog + ..isAlerted = true + ..state = EntityState.patrolling; + + final playerPosition = const Coordinate2D(8.5, 10.5); + final double before = dog.position.distanceTo(playerPosition); + + final intent = dog.update( + elapsedMs: 1000, + elapsedDeltaMs: 16, + playerPosition: playerPosition, + playerAngle: 0, + isPlayerRunning: false, + isWalkable: (x, y) { + // Block direct west and diagonals to force fallback selection. + if (x == 9 && y == 10) return false; + if (x == 9 && y == 9) return false; + if (x == 9 && y == 11) return false; + return true; + }, + areaAt: (_, _) => 0, + isAreaConnectedToPlayer: (_) => true, + onDamagePlayer: (_) {}, + tryOpenDoor: (_, _) {}, + onPlaySound: (_) {}, + ); + + final after = (dog.position + intent.movement).distanceTo(playerPosition); + expect(after, lessThanOrEqualTo(before + 0.001)); + }); + + test('blocked cardinal movement yields exact zero vector', () { + final dog = Dog( + x: 10.5, + y: 10.5, + angle: -1.57079632679, // north + mapId: MapObject.dogStart, + ); + + final movement = dog.getValidMovement( + intendedMovement: const Coordinate2D(1e-7, -0.6), + playerPosition: const Coordinate2D(30, 30), + isWalkable: (x, y) { + // Block tile directly north of dog. + if (x == 10 && y == 9) return false; + return true; + }, + tryOpenDoor: (_, _) {}, + ); + + expect(movement.x, 0); + expect(movement.y, 0); + }); + + test('patrol tile-step keeps north heading in map coordinates', () { + final guard = Guard( + x: 20.5, + y: 20.5, + angle: -math.pi / 2, + mapId: MapObject.guardStart, + ); + + final movement = guard.getPatrolPathMovement( + moveSpeed: 0.1, + playerPosition: const Coordinate2D(40, 40), + isWalkable: (_, _) => true, + tryOpenDoor: (_, _) {}, + ); + + expect(movement.y, lessThan(0)); + }); }); } diff --git a/packages/wolf_3d_dart/test/entities/patrol_marker_mapping_test.dart b/packages/wolf_3d_dart/test/entities/patrol_marker_mapping_test.dart new file mode 100644 index 0000000..7c706e8 --- /dev/null +++ b/packages/wolf_3d_dart/test/entities/patrol_marker_mapping_test.dart @@ -0,0 +1,48 @@ +import 'dart:math' as math; + +import 'package:test/test.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; + +void main() { + group('Map patrol marker angles', () { + test('maps marker ids 90-97 to T_Path 8-direction semantics', () { + expect(MapObject.patrolAngleForMarker(MapObject.patrolEast), 0.0); + + expect( + MapObject.patrolAngleForMarker(MapObject.patrolNorth), + closeTo(-math.pi / 4, 1e-9), + ); + expect( + MapObject.patrolAngleForMarker(MapObject.patrolWest), + closeTo(-math.pi / 2, 1e-9), + ); + expect( + MapObject.patrolAngleForMarker(MapObject.patrolSouth), + closeTo(-3 * math.pi / 4, 1e-9), + ); + + expect( + MapObject.patrolAngleForMarker(MapObject.patrolEastAlt), + closeTo(math.pi, 1e-9), + ); + expect( + MapObject.patrolAngleForMarker(MapObject.patrolNorthAlt), + closeTo(3 * math.pi / 4, 1e-9), + ); + expect( + MapObject.patrolAngleForMarker(MapObject.patrolWestAlt), + closeTo(math.pi / 2, 1e-9), + ); + expect( + MapObject.patrolAngleForMarker(MapObject.patrolSouthAlt), + closeTo(math.pi / 4, 1e-9), + ); + }); + + test('returns null for non-marker ids', () { + expect(MapObject.patrolAngleForMarker(MapObject.pushwallTrigger), isNull); + expect(MapObject.patrolAngleForMarker(MapObject.playerNorth), isNull); + expect(MapObject.patrolAngleForMarker(0), isNull); + }); + }); +}