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_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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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