feat: Implement patrol path markers and enhance enemy movement logic

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-19 18:48:11 +01:00
parent 4700e669ce
commit c62ea013ba
10 changed files with 393 additions and 85 deletions

View File

@@ -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,
};
}
}

View File

@@ -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<Entity> 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<int, ({int x, int y})> _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

View File

@@ -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,14 +192,27 @@ class Dog extends Enemy {
tryOpenDoor: (x, y) {},
);
if (candidateMovement.x.abs() + candidateMovement.y.abs() >=
minEffective) {
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) {
// Blocked — don't visually enter chase state yet.
@@ -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,
),
movement = getPatrolPathMovement(
moveSpeed: 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) {

View File

@@ -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.

View File

@@ -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,
),
movement = getPatrolPathMovement(
moveSpeed: currentMoveSpeed,
playerPosition: playerPosition,
isWalkable: isWalkable,
tryOpenDoor: tryOpenDoor,
);
// Matches T_Path behavior: when patrol is blocked, reverse direction.
// A cooldown prevents immediate re-reversal and rapid oscillation.
if (movement.x == 0 && movement.y == 0) {
newAngle = angle + math.pi;
_patrolReversalCooldownMs = 600;
}
}
}
if (ticReady) {

View File

@@ -81,18 +81,27 @@ class Mutant extends Enemy {
);
if (state == EntityState.patrolling) {
if (!isAlerted || distance > 0.8) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle;
if (!isAlerted) {
movement = getPatrolPathMovement(
moveSpeed: speed,
playerPosition: playerPosition,
isWalkable: isWalkable,
tryOpenDoor: tryOpenDoor,
);
} else {
resetPatrolPathState();
if (distance > 0.8) {
movement = getValidMovement(
intendedMovement: Coordinate2D(
math.cos(currentMoveAngle) * speed,
math.sin(currentMoveAngle) * speed,
math.cos(angleToPlayer) * speed,
math.sin(angleToPlayer) * speed,
),
playerPosition: playerPosition,
isWalkable: isWalkable,
tryOpenDoor: tryOpenDoor,
);
}
}
if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
currentFrame = (currentFrame + 1) % 4;

View File

@@ -81,18 +81,27 @@ class Officer extends Enemy {
);
if (state == EntityState.patrolling) {
if (!isAlerted || distance > 0.8) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle;
if (!isAlerted) {
movement = getPatrolPathMovement(
moveSpeed: speed,
playerPosition: playerPosition,
isWalkable: isWalkable,
tryOpenDoor: tryOpenDoor,
);
} else {
resetPatrolPathState();
if (distance > 0.8) {
movement = getValidMovement(
intendedMovement: Coordinate2D(
math.cos(currentMoveAngle) * speed,
math.sin(currentMoveAngle) * speed,
math.cos(angleToPlayer) * speed,
math.sin(angleToPlayer) * speed,
),
playerPosition: playerPosition,
isWalkable: isWalkable,
tryOpenDoor: tryOpenDoor,
);
}
}
if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
currentFrame = (currentFrame + 1) % 4;

View File

@@ -80,18 +80,27 @@ class SS extends Enemy {
);
if (state == EntityState.patrolling) {
if (!isAlerted || distance > 0.8) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle;
if (!isAlerted) {
movement = getPatrolPathMovement(
moveSpeed: speed,
playerPosition: playerPosition,
isWalkable: isWalkable,
tryOpenDoor: tryOpenDoor,
);
} else {
resetPatrolPathState();
if (distance > 0.8) {
movement = getValidMovement(
intendedMovement: Coordinate2D(
math.cos(currentMoveAngle) * speed,
math.sin(currentMoveAngle) * speed,
math.cos(angleToPlayer) * speed,
math.sin(angleToPlayer) * speed,
),
playerPosition: playerPosition,
isWalkable: isWalkable,
tryOpenDoor: tryOpenDoor,
);
}
}
if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
currentFrame = (currentFrame + 1) % 4;

View File

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

View File

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