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!
if (entity.state == EntityState.idle ||
entity.state == EntityState.ambush) {
entity.state == EntityState.chasing) {
entity.state = EntityState.patrolling;
entity.lastActionTime = _timeAliveMs;
}

View File

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

View File

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

View File

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

View File

@@ -25,11 +25,17 @@ enum EnemyType {
dog(
mapData: EnemyMapData(MapObject.dogStart),
animations: EnemyAnimationMap(
// Dogs don't have true idle sprites, so map idle to the first walk frame safely
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),
// Dogs don't have pain frames in Wolf3D
pain: SpriteFrameRange(0, 0),
// Die is 3 global sprites
dying: SpriteFrameRange(131, 133),
// Dead is 1 global sprite
dead: SpriteFrameRange(134, 134),
),
),

View File

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

View File

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

View File

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

View File

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

View File

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