Fix dog AI and animations

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-17 18:24:31 +01:00
parent 815ca4a13e
commit 62fce48527
10 changed files with 86 additions and 49 deletions

View File

@@ -416,7 +416,7 @@ class WolfEngine {
// Wake them up! // Wake them up!
if (entity.state == EntityState.idle || if (entity.state == EntityState.idle ||
entity.state == EntityState.ambush) { entity.state == EntityState.chasing) {
entity.state = EntityState.patrolling; entity.state = EntityState.patrolling;
entity.lastActionTime = _timeAliveMs; entity.lastActionTime = _timeAliveMs;
} }

View File

@@ -88,7 +88,7 @@ class HansGrosse extends Enemy {
switch (state) { switch (state) {
case EntityState.idle: case EntityState.idle:
case EntityState.ambush: case EntityState.chasing:
spriteIndex = _baseSprite; spriteIndex = _baseSprite;
break; break;

View File

@@ -11,6 +11,10 @@ class Dog extends Enemy {
/// 1024 / 65536 = ~0.0156 tiles per tic. /// 1024 / 65536 = ~0.0156 tiles per tic.
static const double speedPerTic = 0.0156; static const double speedPerTic = 0.0156;
// Used to simulate SelectDodgeDir's zigzag behavior
double _dodgeAngleOffset = 0.0;
int _dodgeTicTimer = 0;
@override @override
EnemyType get type => EnemyType.dog; EnemyType get type => EnemyType.dog;
@@ -20,10 +24,10 @@ class Dog extends Enemy {
required super.angle, required super.angle,
required super.mapId, required super.mapId,
}) : super( }) : super(
spriteIndex: EnemyType.dog.animations.idle.start, spriteIndex: EnemyType.dog.animations.walking.start,
state: EntityState.idle, state: EntityState.patrolling,
) { ) {
health = 1; // Dogs always have 1 HP in the original engine health = 1;
} }
@override @override
@@ -37,59 +41,76 @@ class Dog extends Enemy {
}) { }) {
Coordinate2D movement = const Coordinate2D(0, 0); Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle; double newAngle = angle;
double distance = position.distanceTo(playerPosition);
// 1. Perception // 1. Perception
checkWakeUp( if (state != EntityState.dead && !isDying) {
elapsedMs: elapsedMs, checkWakeUp(
playerPosition: playerPosition, elapsedMs: elapsedMs,
isWalkable: isWalkable, playerPosition: playerPosition,
); isWalkable: isWalkable,
);
}
// 2. Discrete AI Decision Rhythm
bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0); bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0);
// 2. State-Based Logic Gate
if (state == EntityState.attacking) { if (state == EntityState.attacking) {
// --- AUTHENTIC T_Bite / Jump Sequence ---
// Original Jump sequence (s_dogjump1 to s_dogjump5) is 5 frames, 10 tics each
if (ticReady) { if (ticReady) {
currentFrame++; currentFrame++;
// Phase 2: The actual bite
if (currentFrame == 1) { if (currentFrame == 1) {
// Phase 2: The actual bite (s_dogjump2) final bool attackSuccessful =
// Original hit chance is US_RndT() < 180 (~70% chance) math.Random().nextDouble() < (180 / 256);
if (distance <= 1.2 && math.Random().nextDouble() < (180 / 256)) {
// Original damage: US_RndT() >> 4 (0 to 15 damage) double dx = (position.x - playerPosition.x).abs() - speedPerTic;
int actualDamage = math.Random().nextInt(16); double dy = (position.y - playerPosition.y).abs() - speedPerTic;
onDamagePlayer(actualDamage); bool inBiteRange = dx <= 1.0 && dy <= 1.0;
if (inBiteRange && attackSuccessful) {
onDamagePlayer(math.Random().nextInt(16));
} }
setTics(10);
} else if (currentFrame < 5) {
setTics(10); // Phases 3-5: Mid-air and Landing
} else {
// Sequence complete, return to chase
state = EntityState.patrolling;
currentFrame = 0;
setTics(10);
} }
if (currentFrame >= 5) {
state = EntityState.chasing;
currentFrame = 0;
}
setTics(10);
} }
} else if (state != EntityState.dead) { _updateAnimation(elapsedMs, newAngle, playerPosition);
// 3. Continuous Movement (T_DogChase / T_Path) return (movement: const Coordinate2D(0, 0), newAngle: newAngle);
} else if (state != EntityState.dead && !isDying) {
// 3. Chase / Movement Logic
double ticsThisFrame = elapsedDeltaMs / 14.28; double ticsThisFrame = elapsedDeltaMs / 14.28;
double currentMoveSpeed = speedPerTic * ticsThisFrame; double currentMoveSpeed = speedPerTic * ticsThisFrame;
if (isAlerted) { if (isAlerted || state == EntityState.chasing) {
newAngle = position.angleTo(playerPosition); state = EntityState.chasing;
// Trigger Jump: Original uses MINACTORDIST (approx 0.8 tiles) double dx = (position.x - playerPosition.x).abs() - currentMoveSpeed;
if (distance <= 0.8) { double dy = (position.y - playerPosition.y).abs() - currentMoveSpeed;
if (dx <= 1.0 && dy <= 1.0) {
state = EntityState.attacking; state = EntityState.attacking;
currentFrame = 0; currentFrame = 0;
lastActionTime = elapsedMs; lastActionTime = elapsedMs;
setTics(10); setTics(10);
return (movement: const Coordinate2D(0, 0), newAngle: newAngle); return (
movement: const Coordinate2D(0, 0),
newAngle: position.angleTo(playerPosition),
);
} }
if (_dodgeTicTimer <= 0) {
_dodgeAngleOffset =
(math.Random().nextBool() ? 1 : -1) * (math.pi / 4);
_dodgeTicTimer = 15;
} else if (ticReady) {
_dodgeTicTimer--;
}
newAngle = position.angleTo(playerPosition) + _dodgeAngleOffset;
movement = getValidMovement( movement = getValidMovement(
intendedMovement: Coordinate2D( intendedMovement: Coordinate2D(
math.cos(newAngle) * currentMoveSpeed, math.cos(newAngle) * currentMoveSpeed,
@@ -97,8 +118,13 @@ class Dog extends Enemy {
), ),
playerPosition: playerPosition, playerPosition: playerPosition,
isWalkable: isWalkable, isWalkable: isWalkable,
tryOpenDoor: tryOpenDoor, // FIX: Alerted dogs cannot open doors! Pass an empty closure to treat doors as walls.
tryOpenDoor: (x, y) {},
); );
if (movement.x == 0 && movement.y == 0) {
_dodgeTicTimer = 0;
}
} else if (state == EntityState.patrolling) { } else if (state == EntityState.patrolling) {
movement = getValidMovement( movement = getValidMovement(
intendedMovement: Coordinate2D( intendedMovement: Coordinate2D(
@@ -107,13 +133,14 @@ class Dog extends Enemy {
), ),
playerPosition: playerPosition, playerPosition: playerPosition,
isWalkable: isWalkable, isWalkable: isWalkable,
// Patrolling dogs using T_Path CAN open doors in the original logic.
tryOpenDoor: tryOpenDoor, tryOpenDoor: tryOpenDoor,
); );
} }
if (ticReady) { if (ticReady) {
currentFrame = (currentFrame + 1) % 4; currentFrame = (currentFrame + 1) % 4;
setTics(10); // Chase rhythm (s_dogchase1-4) setTics(10);
} }
} }
@@ -126,7 +153,7 @@ class Dog extends Enemy {
double newAngle, double newAngle,
Coordinate2D playerPosition, Coordinate2D playerPosition,
) { ) {
double diff = position.angleTo(playerPosition) - newAngle; double diff = newAngle - position.angleTo(playerPosition);
while (diff <= -math.pi) { while (diff <= -math.pi) {
diff += 2 * math.pi; diff += 2 * math.pi;
} }
@@ -135,8 +162,9 @@ class Dog extends Enemy {
} }
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking, EntityState.patrolling || EntityState.chasing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead, EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle, _ => EnemyAnimation.idle,
}; };
@@ -146,7 +174,10 @@ class Dog extends Enemy {
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
lastActionTime: lastActionTime, lastActionTime: lastActionTime,
angleDiff: diff, angleDiff: diff,
walkFrameOverride: (state == EntityState.patrolling || isAlerted) walkFrameOverride:
(state == EntityState.patrolling ||
state == EntityState.chasing ||
isAlerted)
? currentFrame ? currentFrame
: null, : null,
); );

View File

@@ -165,7 +165,7 @@ abstract class Enemy extends Entity {
// Reaction delay has passed // Reaction delay has passed
isAlerted = true; isAlerted = true;
if (state == EntityState.idle || state == EntityState.ambush) { if (state == EntityState.idle || state == EntityState.chasing) {
state = EntityState.patrolling; state = EntityState.patrolling;
setTics(10); setTics(10);
} }
@@ -356,7 +356,7 @@ abstract class Enemy extends Entity {
} else if (mapData.isStaticForDifficulty(objId, difficulty)) { } else if (mapData.isStaticForDifficulty(objId, difficulty)) {
spawnState = EntityState.idle; spawnState = EntityState.idle;
} else if (mapData.isAmbushForDifficulty(objId, difficulty)) { } else if (mapData.isAmbushForDifficulty(objId, difficulty)) {
spawnState = EntityState.ambush; spawnState = EntityState.chasing;
} else { } else {
// The ID belongs to this enemy type, but not for this specific difficulty level // The ID belongs to this enemy type, but not for this specific difficulty level
return null; return null;

View File

@@ -25,11 +25,17 @@ enum EnemyType {
dog( dog(
mapData: EnemyMapData(MapObject.dogStart), mapData: EnemyMapData(MapObject.dogStart),
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
// Dogs don't have true idle sprites, so map idle to the first walk frame safely
idle: SpriteFrameRange(99, 106), idle: SpriteFrameRange(99, 106),
walking: SpriteFrameRange(107, 130), // Dogs have 4 walk frames * 8 octants = 32 sprites
walking: SpriteFrameRange(99, 130),
// Jump / Attack is 3 global sprites
attacking: SpriteFrameRange(135, 137), attacking: SpriteFrameRange(135, 137),
// Dogs don't have pain frames in Wolf3D
pain: SpriteFrameRange(0, 0), pain: SpriteFrameRange(0, 0),
// Die is 3 global sprites
dying: SpriteFrameRange(131, 133), dying: SpriteFrameRange(131, 133),
// Dead is 1 global sprite
dead: SpriteFrameRange(134, 134), dead: SpriteFrameRange(134, 134),
), ),
), ),

View File

@@ -132,7 +132,7 @@ class Guard extends Enemy {
} }
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking, EntityState.patrolling || EntityState.chasing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain, EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead, EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,

View File

@@ -57,7 +57,7 @@ class Mutant extends Enemy {
} }
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking, EntityState.patrolling || EntityState.chasing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain, EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead, EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,

View File

@@ -57,7 +57,7 @@ class Officer extends Enemy {
} }
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking, EntityState.patrolling || EntityState.chasing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain, EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead, EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,

View File

@@ -56,7 +56,7 @@ class SS extends Enemy {
} }
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking, EntityState.patrolling || EntityState.chasing => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain, EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead, EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,

View File

@@ -6,7 +6,7 @@ enum EntityState {
staticObj, staticObj,
/// Enemies waiting for the player to enter their field of view. /// Enemies waiting for the player to enter their field of view.
ambush, chasing,
/// Enemies standing still but capable of hearing noise. /// Enemies standing still but capable of hearing noise.
idle, idle,