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:
@@ -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_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
|
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
|
||||||
|
|
||||||
@@ -66,6 +68,16 @@ abstract class MapObject {
|
|||||||
static const int vines = 68;
|
static const int vines = 68;
|
||||||
|
|
||||||
// --- Logic & Triggers ---
|
// --- 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 pushwallTrigger = 98;
|
||||||
static const int secretExitTrigger = 99;
|
static const int secretExitTrigger = 99;
|
||||||
static const int normalExitTrigger = 100;
|
static const int normalExitTrigger = 100;
|
||||||
@@ -143,4 +155,20 @@ abstract class MapObject {
|
|||||||
// 3. Static items (ammo, health, plants) are constant across all difficulties.
|
// 3. Static items (ammo, health, plants) are constant across all difficulties.
|
||||||
return true;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ class WolfEngine {
|
|||||||
/// The static level data source used for reloading or reference.
|
/// The static level data source used for reloading or reference.
|
||||||
late WolfLevel activeLevel;
|
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).
|
/// All dynamic entities currently in the level (Enemies, Pickups).
|
||||||
List<Entity> entities = [];
|
List<Entity> entities = [];
|
||||||
|
|
||||||
@@ -134,6 +137,9 @@ class WolfEngine {
|
|||||||
/// Total number of known area indices in the active level.
|
/// Total number of known area indices in the active level.
|
||||||
int _areaCount = 0;
|
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;
|
int _currentEpisodeIndex = 0;
|
||||||
|
|
||||||
bool _isPlayerMovingFast = false;
|
bool _isPlayerMovingFast = false;
|
||||||
@@ -336,6 +342,7 @@ class WolfEngine {
|
|||||||
/// Wipes the current world state and builds a new floor from map data.
|
/// Wipes the current world state and builds a new floor from map data.
|
||||||
void _loadLevel() {
|
void _loadLevel() {
|
||||||
entities.clear();
|
entities.clear();
|
||||||
|
_lastPatrolTileByEnemy.clear();
|
||||||
|
|
||||||
final episode = data.episodes[_currentEpisodeIndex];
|
final episode = data.episodes[_currentEpisodeIndex];
|
||||||
activeLevel = episode.levels[_currentLevelIndex];
|
activeLevel = episode.levels[_currentLevelIndex];
|
||||||
@@ -343,16 +350,16 @@ class WolfEngine {
|
|||||||
// Create a mutable copy of the wall grid so pushwalls can modify it
|
// Create a mutable copy of the wall grid so pushwalls can modify it
|
||||||
currentLevel = List.generate(64, (y) => List.from(activeLevel.wallGrid[y]));
|
currentLevel = List.generate(64, (y) => List.from(activeLevel.wallGrid[y]));
|
||||||
_areaGrid = List.generate(64, (y) => List.from(activeLevel.areaGrid[y]));
|
_areaGrid = List.generate(64, (y) => List.from(activeLevel.areaGrid[y]));
|
||||||
final SpriteMap objectLevel = activeLevel.objectGrid;
|
_objectLevel = activeLevel.objectGrid;
|
||||||
|
|
||||||
doorManager.initDoors(currentLevel);
|
doorManager.initDoors(currentLevel);
|
||||||
pushwallManager.initPushwalls(currentLevel, objectLevel);
|
pushwallManager.initPushwalls(currentLevel, _objectLevel);
|
||||||
audio.playLevelMusic(activeLevel);
|
audio.playLevelMusic(activeLevel);
|
||||||
|
|
||||||
// Spawn Player and Entities from the Object Grid
|
// Spawn Player and Entities from the Object Grid
|
||||||
for (int y = 0; y < 64; y++) {
|
for (int y = 0; y < 64; y++) {
|
||||||
for (int x = 0; x < 64; x++) {
|
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
|
// Map IDs 19-22 are Reserved for Player Starts
|
||||||
if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) {
|
if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) {
|
||||||
@@ -552,6 +559,10 @@ class WolfEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Standard AI Update cycle
|
// Standard AI Update cycle
|
||||||
|
if (!entity.isAlerted && entity.state == EntityState.patrolling) {
|
||||||
|
_applyPatrolPathMarker(entity);
|
||||||
|
}
|
||||||
|
|
||||||
final intent = entity.update(
|
final intent = entity.update(
|
||||||
elapsedMs: _timeAliveMs,
|
elapsedMs: _timeAliveMs,
|
||||||
elapsedDeltaMs: elapsed.inMilliseconds,
|
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.
|
/// Returns true if a tile is empty or contains a door that is sufficiently open.
|
||||||
bool isWalkable(int x, int y) {
|
bool isWalkable(int x, int y) {
|
||||||
// 1. Boundary Guard: Prevent range errors by checking if coordinates are on the map
|
// 1. Boundary Guard: Prevent range errors by checking if coordinates are on the map
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ class Dog extends Enemy {
|
|||||||
int _stuckFrames = 0;
|
int _stuckFrames = 0;
|
||||||
bool _wasMoving = false;
|
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
|
@override
|
||||||
EnemyType get type => EnemyType.dog;
|
EnemyType get type => EnemyType.dog;
|
||||||
|
|
||||||
@@ -103,6 +99,7 @@ class Dog extends Enemy {
|
|||||||
double currentMoveSpeed = speedPerTic * ticsThisFrame;
|
double currentMoveSpeed = speedPerTic * ticsThisFrame;
|
||||||
|
|
||||||
if (isAlerted) {
|
if (isAlerted) {
|
||||||
|
resetPatrolPathState();
|
||||||
// Only commit to the chase (ambushing) state once we have a valid
|
// Only commit to the chase (ambushing) state once we have a valid
|
||||||
// movement path. While blocked, the dog waits in its current state
|
// movement path. While blocked, the dog waits in its current state
|
||||||
// so it does not visually appear to charge a closed door.
|
// 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-
|
// A movement magnitude threshold prevents accepting near-zero floating-
|
||||||
// point residuals (e.g. cos(π/2) ≈ 6e-17) as valid movement.
|
// point residuals (e.g. cos(π/2) ≈ 6e-17) as valid movement.
|
||||||
final double minEffective = currentMoveSpeed * 0.5;
|
final double minEffective = currentMoveSpeed * 0.5;
|
||||||
|
final double currentDistanceToPlayer = position.distanceTo(playerPosition);
|
||||||
|
|
||||||
int selectedCandidateIndex = -1;
|
int selectedCandidateIndex = -1;
|
||||||
for (int i = 0; i < candidateAngles.length; i++) {
|
for (int i = 0; i < candidateAngles.length; i++) {
|
||||||
@@ -194,13 +192,26 @@ class Dog extends Enemy {
|
|||||||
tryOpenDoor: (x, y) {},
|
tryOpenDoor: (x, y) {},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (candidateMovement.x.abs() + candidateMovement.y.abs() >=
|
if (candidateMovement.x.abs() + candidateMovement.y.abs() < minEffective) {
|
||||||
minEffective) {
|
continue;
|
||||||
newAngle = candidateAngle;
|
|
||||||
movement = candidateMovement;
|
|
||||||
selectedCandidateIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (movement.x == 0 && movement.y == 0) {
|
||||||
@@ -245,27 +256,13 @@ class Dog extends Enemy {
|
|||||||
_stuckFrames = 0;
|
_stuckFrames = 0;
|
||||||
}
|
}
|
||||||
} else if (state == EntityState.patrolling) {
|
} else if (state == EntityState.patrolling) {
|
||||||
if (_patrolReversalCooldownMs > 0) {
|
movement = getPatrolPathMovement(
|
||||||
// Stand still during the post-reversal pause.
|
moveSpeed: currentMoveSpeed,
|
||||||
_patrolReversalCooldownMs -= elapsedDeltaMs;
|
playerPosition: playerPosition,
|
||||||
} else {
|
isWalkable: isWalkable,
|
||||||
movement = getValidMovement(
|
// Patrolling dogs using T_Path CAN open doors in the original logic.
|
||||||
intendedMovement: Coordinate2D(
|
tryOpenDoor: tryOpenDoor,
|
||||||
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) {
|
if (ticReady) {
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ abstract class Enemy extends Entity {
|
|||||||
/// This replaces the `ob->temp2` variable found in the original `WL_ACT2.C`.
|
/// This replaces the `ob->temp2` variable found in the original `WL_ACT2.C`.
|
||||||
int reactionTimeMs = 0;
|
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.
|
/// 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
|
/// Movement is applied continuously during the frame, but state changes (like deciding
|
||||||
@@ -208,6 +212,9 @@ abstract class Enemy extends Entity {
|
|||||||
currentFrame++;
|
currentFrame++;
|
||||||
|
|
||||||
if (currentFrame == 1) {
|
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.
|
// Phase 1: Bang! Calculate hit chance based on distance.
|
||||||
// Drops by ~5% per tile distance, capped at a minimum 10% chance to hit.
|
// Drops by ~5% per tile distance, capped at a minimum 10% chance to hit.
|
||||||
onAttack();
|
onAttack();
|
||||||
@@ -259,6 +266,12 @@ abstract class Enemy extends Entity {
|
|||||||
elapsedMs +
|
elapsedMs +
|
||||||
baseReactionMs +
|
baseReactionMs +
|
||||||
math.Random().nextInt(reactionVarianceMs);
|
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) {
|
} else if (elapsedMs >= reactionTimeMs) {
|
||||||
// Reaction delay has passed
|
// Reaction delay has passed
|
||||||
isAlerted = true;
|
isAlerted = true;
|
||||||
@@ -353,6 +366,13 @@ abstract class Enemy extends Entity {
|
|||||||
required bool Function(int x, int y) isWalkable,
|
required bool Function(int x, int y) isWalkable,
|
||||||
required void Function(int x, int y) tryOpenDoor,
|
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);
|
final double distToPlayer = position.distanceTo(playerPosition);
|
||||||
const double minDistance = 0.9;
|
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
|
// is moving mostly north and clips a wall tile beside an open door, the
|
||||||
// Y (northward) slide is preferred over the X (sideways) slide.
|
// Y (northward) slide is preferred over the X (sideways) slide.
|
||||||
if (intendedMovement.y.abs() >= intendedMovement.x.abs()) {
|
if (intendedMovement.y.abs() >= intendedMovement.x.abs()) {
|
||||||
if (canMoveY) return Coordinate2D(0, intendedMovement.y);
|
if (canMoveY) return normalizeTiny(Coordinate2D(0, intendedMovement.y));
|
||||||
if (canMoveX) return Coordinate2D(intendedMovement.x, 0);
|
if (canMoveX) return normalizeTiny(Coordinate2D(intendedMovement.x, 0));
|
||||||
} else {
|
} else {
|
||||||
if (canMoveX) return Coordinate2D(intendedMovement.x, 0);
|
if (canMoveX) return normalizeTiny(Coordinate2D(intendedMovement.x, 0));
|
||||||
if (canMoveY) return Coordinate2D(0, intendedMovement.y);
|
if (canMoveY) return normalizeTiny(Coordinate2D(0, intendedMovement.y));
|
||||||
}
|
}
|
||||||
return const Coordinate2D(0, 0);
|
return const Coordinate2D(0, 0);
|
||||||
}
|
}
|
||||||
@@ -406,17 +426,100 @@ abstract class Enemy extends Entity {
|
|||||||
if (movedX && !movedY) {
|
if (movedX && !movedY) {
|
||||||
if (!isWalkable(targetTileX, currentTileY)) {
|
if (!isWalkable(targetTileX, currentTileY)) {
|
||||||
tryOpenDoor(targetTileX, currentTileY);
|
tryOpenDoor(targetTileX, currentTileY);
|
||||||
return Coordinate2D(0, intendedMovement.y);
|
return const Coordinate2D(0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (movedY && !movedX) {
|
if (movedY && !movedX) {
|
||||||
if (!isWalkable(currentTileX, targetTileY)) {
|
if (!isWalkable(currentTileX, targetTileY)) {
|
||||||
tryOpenDoor(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.
|
/// The per-frame update logic to be implemented by specific enemy types.
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ class Guard extends Enemy {
|
|||||||
@override
|
@override
|
||||||
EnemyType get type => EnemyType.guard;
|
EnemyType get type => EnemyType.guard;
|
||||||
|
|
||||||
/// Prevents rapid patrol direction oscillation after hitting a wall.
|
|
||||||
int _patrolReversalCooldownMs = 0;
|
|
||||||
|
|
||||||
Guard({
|
Guard({
|
||||||
required super.x,
|
required super.x,
|
||||||
required super.y,
|
required super.y,
|
||||||
@@ -95,6 +92,7 @@ class Guard extends Enemy {
|
|||||||
double currentMoveSpeed = speedPerTic * ticsThisFrame;
|
double currentMoveSpeed = speedPerTic * ticsThisFrame;
|
||||||
|
|
||||||
if (isAlerted) {
|
if (isAlerted) {
|
||||||
|
resetPatrolPathState();
|
||||||
newAngle = position.angleTo(playerPosition);
|
newAngle = position.angleTo(playerPosition);
|
||||||
|
|
||||||
// AI Decision: Should I stop to shoot? (Only check on tic boundaries)
|
// AI Decision: Should I stop to shoot? (Only check on tic boundaries)
|
||||||
@@ -120,26 +118,12 @@ class Guard extends Enemy {
|
|||||||
tryOpenDoor: tryOpenDoor,
|
tryOpenDoor: tryOpenDoor,
|
||||||
);
|
);
|
||||||
} else if (state == EntityState.patrolling) {
|
} else if (state == EntityState.patrolling) {
|
||||||
if (_patrolReversalCooldownMs > 0) {
|
movement = getPatrolPathMovement(
|
||||||
_patrolReversalCooldownMs -= elapsedDeltaMs;
|
moveSpeed: currentMoveSpeed,
|
||||||
} else {
|
playerPosition: playerPosition,
|
||||||
// Normal patrol movement
|
isWalkable: isWalkable,
|
||||||
movement = getValidMovement(
|
tryOpenDoor: tryOpenDoor,
|
||||||
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) {
|
if (ticReady) {
|
||||||
|
|||||||
@@ -81,17 +81,26 @@ class Mutant extends Enemy {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (state == EntityState.patrolling) {
|
if (state == EntityState.patrolling) {
|
||||||
if (!isAlerted || distance > 0.8) {
|
if (!isAlerted) {
|
||||||
double currentMoveAngle = isAlerted ? angleToPlayer : angle;
|
movement = getPatrolPathMovement(
|
||||||
movement = getValidMovement(
|
moveSpeed: speed,
|
||||||
intendedMovement: Coordinate2D(
|
|
||||||
math.cos(currentMoveAngle) * speed,
|
|
||||||
math.sin(currentMoveAngle) * speed,
|
|
||||||
),
|
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
isWalkable: isWalkable,
|
isWalkable: isWalkable,
|
||||||
tryOpenDoor: tryOpenDoor,
|
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)) {
|
if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
|
||||||
|
|||||||
@@ -81,17 +81,26 @@ class Officer extends Enemy {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (state == EntityState.patrolling) {
|
if (state == EntityState.patrolling) {
|
||||||
if (!isAlerted || distance > 0.8) {
|
if (!isAlerted) {
|
||||||
double currentMoveAngle = isAlerted ? angleToPlayer : angle;
|
movement = getPatrolPathMovement(
|
||||||
movement = getValidMovement(
|
moveSpeed: speed,
|
||||||
intendedMovement: Coordinate2D(
|
|
||||||
math.cos(currentMoveAngle) * speed,
|
|
||||||
math.sin(currentMoveAngle) * speed,
|
|
||||||
),
|
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
isWalkable: isWalkable,
|
isWalkable: isWalkable,
|
||||||
tryOpenDoor: tryOpenDoor,
|
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)) {
|
if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
|
||||||
|
|||||||
@@ -80,17 +80,26 @@ class SS extends Enemy {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (state == EntityState.patrolling) {
|
if (state == EntityState.patrolling) {
|
||||||
if (!isAlerted || distance > 0.8) {
|
if (!isAlerted) {
|
||||||
double currentMoveAngle = isAlerted ? angleToPlayer : angle;
|
movement = getPatrolPathMovement(
|
||||||
movement = getValidMovement(
|
moveSpeed: speed,
|
||||||
intendedMovement: Coordinate2D(
|
|
||||||
math.cos(currentMoveAngle) * speed,
|
|
||||||
math.sin(currentMoveAngle) * speed,
|
|
||||||
),
|
|
||||||
playerPosition: playerPosition,
|
playerPosition: playerPosition,
|
||||||
isWalkable: isWalkable,
|
isWalkable: isWalkable,
|
||||||
tryOpenDoor: tryOpenDoor,
|
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)) {
|
if (processTics(elapsedDeltaMs, moveSpeed: speed)) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_entities.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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user