Refactor enemy sound handling and improve enemy type definitions

- Updated enemy classes to include alert and attack sound IDs.
- Refactored checkWakeUp and attack logic to play sounds appropriately.
- Enhanced enemy type definitions with sound mappings.
- Added unit tests for enemy spawn and validation.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-18 02:26:44 +01:00
parent b040b88d8a
commit 806c9b6966
13 changed files with 179 additions and 33 deletions

View File

@@ -89,13 +89,13 @@ abstract class MapObject {
static const int bossFettgesicht = 223; static const int bossFettgesicht = 223;
// --- Enemy Range Constants --- // --- Enemy Range Constants ---
// Every enemy type occupies a block of 36 IDs. // Enemies are encoded in 8-ID blocks: 4 standing directions and 4 patrol directions.
// Modulo math is used on these ranges to determine orientation and patrol status. // Most enemy families shift by 36 IDs between difficulty tiers, while mutants shift by 18.
static const int guardStart = 108; // Claims 108-115, 144-151, 180-187 static const int guardStart = 108; // Claims 108-115, 144-151, 180-187
static const int dogStart = 116; // Claims 116-123, 152-159, 188-195 static const int officerStart = 116; // Claims 116-123, 152-159, 188-195
static const int ssStart = 126; // Claims 126-133, 162-169, 198-205 static const int ssStart = 126; // Claims 126-133, 162-169, 198-205
static const int mutantStart = 136; // Claims 136-143, 172-179, 208-215 static const int dogStart = 134; // Claims 134-141, 170-177, 206-213
static const int officerStart = 252; // Claims 252-259, 288-295, 324-331 static const int mutantStart = 216; // Claims 216-223, 234-241, 252-259
// --- Missing Decorative Bodies --- // --- Missing Decorative Bodies ---
static const int deadGuard = 124; // Decorative only in WL1 static const int deadGuard = 124; // Decorative only in WL1
@@ -119,9 +119,14 @@ abstract class MapObject {
final EnemyType? type = EnemyType.fromMapId(id); final EnemyType? type = EnemyType.fromMapId(id);
if (type == null) return 0.0; if (type == null) return 0.0;
// Because all enemies are grouped in blocks of 4 (one for each CardinalDirection), final int? normalizedId = type.mapData.getNormalizedId(id, Difficulty.hard);
// modulo 4 extracts the specific orientation index. if (normalizedId == null) return 0.0;
return CardinalDirection.fromEnemyIndex(id % 4).radians;
// Enemy IDs are stored as 8-ID blocks per family.
// Offsets 0-3 are standing directions and 4-7 are patrol directions.
return CardinalDirection.fromEnemyIndex(
normalizedId - type.mapData.baseId,
).radians;
} }
/// Validates if an object should exist on the map given the current [difficulty]. /// Validates if an object should exist on the map given the current [difficulty].

View File

@@ -197,8 +197,8 @@ class Player {
return pickedUp; return pickedUp;
} }
void fire(int currentTime) { bool fire(int currentTime) {
if (switchState != WeaponSwitchState.idle) return; if (switchState != WeaponSwitchState.idle) return false;
// We pass the isFiring state to handle automatic vs semi-auto behavior // We pass the isFiring state to handle automatic vs semi-auto behavior
bool shotFired = currentWeapon.fire(currentTime, currentAmmo: ammo); bool shotFired = currentWeapon.fire(currentTime, currentAmmo: ammo);
@@ -206,6 +206,8 @@ class Player {
if (shotFired && currentWeapon.type != WeaponType.knife) { if (shotFired && currentWeapon.type != WeaponType.knife) {
ammo--; ammo--;
} }
return shotFired;
} }
void releaseTrigger() { void releaseTrigger() {

View File

@@ -85,6 +85,7 @@ class WolfEngine {
/// Initializes the engine, sets the starting episode, and loads the first level. /// Initializes the engine, sets the starting episode, and loads the first level.
void init() { void init() {
audio.activeGame = data;
_currentEpisodeIndex = startingEpisode; _currentEpisodeIndex = startingEpisode;
_currentLevelIndex = 0; _currentLevelIndex = 0;
_loadLevel(); _loadLevel();
@@ -247,9 +248,13 @@ class WolfEngine {
} }
if (input.isFiring) { if (input.isFiring) {
player.fire(_timeAliveMs); final bool shotFired = player.fire(_timeAliveMs);
if (_timeAliveMs - _lastAcousticAlertTime > 400) { if (shotFired) {
_playPlayerWeaponSound();
}
if (shotFired && _timeAliveMs - _lastAcousticAlertTime > 400) {
_propagateGunfire(); _propagateGunfire();
_lastAcousticAlertTime = _timeAliveMs; _lastAcousticAlertTime = _timeAliveMs;
} }
@@ -356,6 +361,7 @@ class WolfEngine {
isWalkable: isWalkable, isWalkable: isWalkable,
tryOpenDoor: doorManager.tryOpenDoor, tryOpenDoor: doorManager.tryOpenDoor,
onDamagePlayer: (int damage) => player.takeDamage(damage), onDamagePlayer: (int damage) => player.takeDamage(damage),
onPlaySound: audio.playSoundEffect,
); );
// Scale the enemy's movement intent to prevent super-speed on 90Hz/120Hz displays // Scale the enemy's movement intent to prevent super-speed on 90Hz/120Hz displays
@@ -429,6 +435,7 @@ class WolfEngine {
if (entity.position.x.toInt() == tile.x && if (entity.position.x.toInt() == tile.x &&
entity.position.y.toInt() == tile.y) { entity.position.y.toInt() == tile.y) {
entity.isAlerted = true; entity.isAlerted = true;
audio.playSoundEffect(entity.alertSoundId);
// Wake them up! // Wake them up!
if (entity.state == EntityState.idle || if (entity.state == EntityState.idle ||
@@ -480,6 +487,17 @@ class WolfEngine {
return false; return false;
} }
void _playPlayerWeaponSound() {
final sfxId = switch (player.currentWeapon.type) {
WeaponType.knife => WolfSound.knifeAttack,
WeaponType.pistol => WolfSound.pistolFire,
WeaponType.machineGun => WolfSound.machineGunFire,
WeaponType.chainGun => WolfSound.gatlingFire,
};
audio.playSoundEffect(sfxId);
}
/// Clamps movement to a maximum of 0.2 tiles per step to prevent wall-clipping /// Clamps movement to a maximum of 0.2 tiles per step to prevent wall-clipping
/// and map-boundary jumps during low framerate/high delta spikes. /// and map-boundary jumps during low framerate/high delta spikes.
Coordinate2D _clampMovement(Coordinate2D intent) { Coordinate2D _clampMovement(Coordinate2D intent) {

View File

@@ -11,6 +11,12 @@ class HansGrosse extends Enemy {
EnemyType get type => EnemyType get type =>
throw UnimplementedError("Hans Grosse uses manual animation logic."); throw UnimplementedError("Hans Grosse uses manual animation logic.");
@override
int get alertSoundId => WolfSound.bossActive;
@override
int get attackSoundId => WolfSound.naziFire;
HansGrosse({ HansGrosse({
required super.x, required super.x,
required super.y, required super.y,
@@ -69,6 +75,7 @@ class HansGrosse extends Enemy {
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor, required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
}) { }) {
Coordinate2D movement = const Coordinate2D(0, 0); Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle; double newAngle = angle;
@@ -77,12 +84,14 @@ class HansGrosse extends Enemy {
newAngle = position.angleTo(playerPosition); newAngle = position.angleTo(playerPosition);
} }
checkWakeUp( if (checkWakeUp(
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
playerPosition: playerPosition, playerPosition: playerPosition,
isWalkable: isWalkable, isWalkable: isWalkable,
baseReactionMs: 50, baseReactionMs: 50,
); )) {
onPlaySound(alertSoundId);
}
double distance = position.distanceTo(playerPosition); double distance = position.distanceTo(playerPosition);
@@ -130,6 +139,7 @@ class HansGrosse extends Enemy {
setTics(10); setTics(10);
} else if (currentFrame == 2) { } else if (currentFrame == 2) {
spriteIndex = _baseSprite + 6; // Fire spriteIndex = _baseSprite + 6; // Fire
onPlaySound(attackSoundId);
onDamagePlayer(damage); onDamagePlayer(damage);
setTics(4); setTics(4);
} else if (currentFrame == 3) { } else if (currentFrame == 3) {

View File

@@ -38,17 +38,20 @@ class Dog extends Enemy {
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor, required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
}) { }) {
Coordinate2D movement = const Coordinate2D(0, 0); Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle; double newAngle = angle;
// 1. Perception // 1. Perception
if (state != EntityState.dead && !isDying) { if (state != EntityState.dead && !isDying) {
checkWakeUp( if (checkWakeUp(
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
playerPosition: playerPosition, playerPosition: playerPosition,
isWalkable: isWalkable, isWalkable: isWalkable,
); )) {
onPlaySound(alertSoundId);
}
} }
bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0); bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0);
@@ -59,6 +62,7 @@ class Dog extends Enemy {
currentFrame++; currentFrame++;
// Phase 2: The actual bite // Phase 2: The actual bite
if (currentFrame == 1) { if (currentFrame == 1) {
onPlaySound(attackSoundId);
final bool attackSuccessful = final bool attackSuccessful =
math.Random().nextDouble() < (180 / 256); math.Random().nextDouble() < (180 / 256);

View File

@@ -48,6 +48,12 @@ abstract class Enemy extends Entity {
/// knowing the specific subclass. /// knowing the specific subclass.
EnemyType get type; EnemyType get type;
/// The sound played when this enemy notices the player or hears combat.
int get alertSoundId => type.alertSoundId;
/// The sound played when this enemy performs its attack animation.
int get attackSoundId => type.attackSoundId;
/// Ensures enemies drop only one item (like ammo or a key) upon death. /// Ensures enemies drop only one item (like ammo or a key) upon death.
bool hasDroppedItem = false; bool hasDroppedItem = false;
@@ -111,6 +117,7 @@ abstract class Enemy extends Entity {
void handleAttackState({ void handleAttackState({
required int elapsedDeltaMs, required int elapsedDeltaMs,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
required void Function() onAttack,
required int shootTics, required int shootTics,
required int cooldownTics, required int cooldownTics,
required int postAttackTics, required int postAttackTics,
@@ -122,6 +129,7 @@ abstract class Enemy extends Entity {
if (currentFrame == 1) { if (currentFrame == 1) {
// 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();
double hitChance = (1.0 - (distance * 0.05)).clamp(0.1, 1.0); double hitChance = (1.0 - (distance * 0.05)).clamp(0.1, 1.0);
if (math.Random().nextDouble() <= hitChance) { if (math.Random().nextDouble() <= hitChance) {
@@ -147,13 +155,15 @@ abstract class Enemy extends Entity {
/// ///
/// Includes a randomized delay based on [baseReactionMs] and [reactionVarianceMs] /// Includes a randomized delay based on [baseReactionMs] and [reactionVarianceMs]
/// to prevent all enemies in a room from reacting on the exact same frame. /// to prevent all enemies in a room from reacting on the exact same frame.
void checkWakeUp({ bool checkWakeUp({
required int elapsedMs, required int elapsedMs,
required Coordinate2D playerPosition, required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
int baseReactionMs = 200, int baseReactionMs = 200,
int reactionVarianceMs = 600, int reactionVarianceMs = 600,
}) { }) {
bool didAlert = false;
if (!isAlerted && hasLineOfSight(playerPosition, isWalkable)) { if (!isAlerted && hasLineOfSight(playerPosition, isWalkable)) {
if (reactionTimeMs == 0) { if (reactionTimeMs == 0) {
// First frame of spotting: calculate how long until they "wake up" // First frame of spotting: calculate how long until they "wake up"
@@ -164,6 +174,7 @@ abstract class Enemy extends Entity {
} else if (elapsedMs >= reactionTimeMs) { } else if (elapsedMs >= reactionTimeMs) {
// Reaction delay has passed // Reaction delay has passed
isAlerted = true; isAlerted = true;
didAlert = true;
if (state == EntityState.idle || state == EntityState.ambushing) { if (state == EntityState.idle || state == EntityState.ambushing) {
state = EntityState.patrolling; state = EntityState.patrolling;
@@ -174,6 +185,8 @@ abstract class Enemy extends Entity {
reactionTimeMs = 0; reactionTimeMs = 0;
} }
} }
return didAlert;
} }
/// Determines if there is a clear, unobstructed path between the enemy and the player. /// Determines if there is a clear, unobstructed path between the enemy and the player.
@@ -312,6 +325,7 @@ 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,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
required void Function(int sfxId) onPlaySound,
}); });
/// Factory method to spawn the correct [Enemy] subclass based on a Map ID. /// Factory method to spawn the correct [Enemy] subclass based on a Map ID.
@@ -358,7 +372,9 @@ abstract class Enemy extends Entity {
// Resolve spawn orientation using the NORMALIZED ID (0-7 offset from base) // Resolve spawn orientation using the NORMALIZED ID (0-7 offset from base)
// This prevents offset math bugs (like the Mutant's 18-ID shift) from breaking facing directions. // This prevents offset math bugs (like the Mutant's 18-ID shift) from breaking facing directions.
double spawnAngle = CardinalDirection.fromEnemyIndex(normalizedId).radians; double spawnAngle = CardinalDirection.fromEnemyIndex(
normalizedId - matchedType.mapData.baseId,
).radians;
EntityState spawnState; EntityState spawnState;

View File

@@ -11,6 +11,8 @@ enum EnemyType {
/// Standard Brown Guard (The most common enemy). /// Standard Brown Guard (The most common enemy).
guard( guard(
mapData: EnemyMapData(MapObject.guardStart), mapData: EnemyMapData(MapObject.guardStart),
alertSoundId: WolfSound.guardHalt,
attackSoundId: WolfSound.naziFire,
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
idle: SpriteFrameRange(50, 57), idle: SpriteFrameRange(50, 57),
walking: SpriteFrameRange(58, 89), walking: SpriteFrameRange(58, 89),
@@ -24,6 +26,8 @@ enum EnemyType {
/// Attack Dog (Fast melee enemy). /// Attack Dog (Fast melee enemy).
dog( dog(
mapData: EnemyMapData(MapObject.dogStart), mapData: EnemyMapData(MapObject.dogStart),
alertSoundId: WolfSound.dogBark,
attackSoundId: WolfSound.dogAttack,
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
// Dogs don't have true idle sprites, so map idle to the first walk frame safely // Dogs don't have true idle sprites, so map idle to the first walk frame safely
idle: SpriteFrameRange(99, 106), idle: SpriteFrameRange(99, 106),
@@ -43,6 +47,8 @@ enum EnemyType {
/// SS Officer (Blue uniform, machine gun). /// SS Officer (Blue uniform, machine gun).
ss( ss(
mapData: EnemyMapData(MapObject.ssStart), mapData: EnemyMapData(MapObject.ssStart),
alertSoundId: WolfSound.ssSchutzstaffel,
attackSoundId: WolfSound.naziFire,
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
idle: SpriteFrameRange(138, 145), idle: SpriteFrameRange(138, 145),
walking: SpriteFrameRange(146, 177), walking: SpriteFrameRange(146, 177),
@@ -55,7 +61,9 @@ enum EnemyType {
/// Undead Mutant (Exclusive to later episodes/retail). /// Undead Mutant (Exclusive to later episodes/retail).
mutant( mutant(
mapData: EnemyMapData(MapObject.mutantStart), mapData: EnemyMapData(MapObject.mutantStart, tierOffset: 18),
alertSoundId: WolfSound.guardHalt,
attackSoundId: WolfSound.naziFire,
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
idle: SpriteFrameRange(187, 194), idle: SpriteFrameRange(187, 194),
walking: SpriteFrameRange(195, 226), walking: SpriteFrameRange(195, 226),
@@ -70,6 +78,8 @@ enum EnemyType {
/// High-ranking Officer (White uniform, fast pistol). /// High-ranking Officer (White uniform, fast pistol).
officer( officer(
mapData: EnemyMapData(MapObject.officerStart), mapData: EnemyMapData(MapObject.officerStart),
alertSoundId: WolfSound.guardHalt,
attackSoundId: WolfSound.naziFire,
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
idle: SpriteFrameRange(238, 245), idle: SpriteFrameRange(238, 245),
walking: SpriteFrameRange(246, 277), walking: SpriteFrameRange(246, 277),
@@ -88,12 +98,20 @@ enum EnemyType {
/// The animation ranges for this enemy type. /// The animation ranges for this enemy type.
final EnemyAnimationMap animations; final EnemyAnimationMap animations;
/// The sound played when this enemy first becomes alerted.
final int alertSoundId;
/// The sound played when this enemy attacks.
final int attackSoundId;
/// If false, this enemy type will be ignored when loading shareware data. /// If false, this enemy type will be ignored when loading shareware data.
final bool existsInShareware; final bool existsInShareware;
const EnemyType({ const EnemyType({
required this.mapData, required this.mapData,
required this.animations, required this.animations,
required this.alertSoundId,
required this.attackSoundId,
this.existsInShareware = true, this.existsInShareware = true,
}); });
@@ -151,7 +169,6 @@ enum EnemyType {
// --- Octant Calculation --- // --- Octant Calculation ---
// We split the circle into 8 segments (octants). // We split the circle into 8 segments (octants).
// Adding pi/8 offsets the segments so "North" covers the +/- 22.5 degree range.
int octant = ((angleDiff + (math.pi / 8)) / (math.pi / 4)).floor() % 8; int octant = ((angleDiff + (math.pi / 8)) / (math.pi / 4)).floor() % 8;
if (octant < 0) octant += 8; if (octant < 0) octant += 8;

View File

@@ -35,17 +35,20 @@ class Guard extends Enemy {
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor, required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
}) { }) {
Coordinate2D movement = const Coordinate2D(0, 0); Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle; double newAngle = angle;
double distance = position.distanceTo(playerPosition); double distance = position.distanceTo(playerPosition);
// 1. Perception (SightPlayer) // 1. Perception (SightPlayer)
checkWakeUp( if (checkWakeUp(
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
playerPosition: playerPosition, playerPosition: playerPosition,
isWalkable: isWalkable, isWalkable: isWalkable,
); )) {
onPlaySound(alertSoundId);
}
// 2. Discrete AI Logic (Decisions happen every 10 tics) // 2. Discrete AI Logic (Decisions happen every 10 tics)
bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0); bool ticReady = processTics(elapsedDeltaMs, moveSpeed: 0);
@@ -55,6 +58,7 @@ class Guard extends Enemy {
elapsedDeltaMs: elapsedDeltaMs, elapsedDeltaMs: elapsedDeltaMs,
distance: distance, distance: distance,
onDamagePlayer: onDamagePlayer, onDamagePlayer: onDamagePlayer,
onAttack: () => onPlaySound(attackSoundId),
shootTics: 20, shootTics: 20,
cooldownTics: 20, cooldownTics: 20,
postAttackTics: 20, postAttackTics: 20,
@@ -123,7 +127,7 @@ class Guard 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;
} }

View File

@@ -33,22 +33,25 @@ class Mutant extends Enemy {
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor, required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
}) { }) {
Coordinate2D movement = const Coordinate2D(0, 0); Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle; double newAngle = angle;
checkWakeUp( if (checkWakeUp(
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
playerPosition: playerPosition, playerPosition: playerPosition,
isWalkable: isWalkable, isWalkable: isWalkable,
); )) {
onPlaySound(alertSoundId);
}
double distance = position.distanceTo(playerPosition); double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition); double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer; if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
double diff = angleToPlayer - newAngle; double diff = newAngle - angleToPlayer;
while (diff <= -math.pi) { while (diff <= -math.pi) {
diff += 2 * math.pi; diff += 2 * math.pi;
} }
@@ -105,6 +108,7 @@ class Mutant extends Enemy {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) { if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++; currentFrame++;
if (currentFrame == 1) { if (currentFrame == 1) {
onPlaySound(attackSoundId);
onDamagePlayer(damage); onDamagePlayer(damage);
setTics(4); setTics(4);
} else if (currentFrame == 2) { } else if (currentFrame == 2) {

View File

@@ -33,22 +33,25 @@ class Officer extends Enemy {
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor, required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
}) { }) {
Coordinate2D movement = const Coordinate2D(0, 0); Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle; double newAngle = angle;
checkWakeUp( if (checkWakeUp(
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
playerPosition: playerPosition, playerPosition: playerPosition,
isWalkable: isWalkable, isWalkable: isWalkable,
); )) {
onPlaySound(alertSoundId);
}
double distance = position.distanceTo(playerPosition); double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition); double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer; if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
double diff = angleToPlayer - newAngle; double diff = newAngle - angleToPlayer;
while (diff <= -math.pi) { while (diff <= -math.pi) {
diff += 2 * math.pi; diff += 2 * math.pi;
} }
@@ -105,6 +108,7 @@ class Officer extends Enemy {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) { if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++; currentFrame++;
if (currentFrame == 1) { if (currentFrame == 1) {
onPlaySound(attackSoundId);
onDamagePlayer(damage); onDamagePlayer(damage);
setTics(4); // Bang! setTics(4); // Bang!
} else if (currentFrame == 2) { } else if (currentFrame == 2) {

View File

@@ -32,22 +32,25 @@ class SS extends Enemy {
required bool Function(int x, int y) isWalkable, required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor, required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
}) { }) {
Coordinate2D movement = const Coordinate2D(0, 0); Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle; double newAngle = angle;
checkWakeUp( if (checkWakeUp(
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
playerPosition: playerPosition, playerPosition: playerPosition,
isWalkable: isWalkable, isWalkable: isWalkable,
); )) {
onPlaySound(alertSoundId);
}
double distance = position.distanceTo(playerPosition); double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition); double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer; if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
double diff = angleToPlayer - newAngle; double diff = newAngle - angleToPlayer;
while (diff <= -math.pi) { while (diff <= -math.pi) {
diff += 2 * math.pi; diff += 2 * math.pi;
} }
@@ -104,6 +107,7 @@ class SS extends Enemy {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) { if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++; currentFrame++;
if (currentFrame == 1) { if (currentFrame == 1) {
onPlaySound(attackSoundId);
onDamagePlayer(damage); onDamagePlayer(damage);
setTics(5); // Bang! setTics(5); // Bang!
} else if (currentFrame == 2) { } else if (currentFrame == 2) {
@@ -114,6 +118,7 @@ class SS extends Enemy {
if (math.Random().nextDouble() > 0.5) { if (math.Random().nextDouble() > 0.5) {
// 50% chance to burst // 50% chance to burst
currentFrame = 1; currentFrame = 1;
onPlaySound(attackSoundId);
onDamagePlayer(damage); onDamagePlayer(damage);
setTics(5); setTics(5);
return (movement: movement, newAngle: newAngle); return (movement: movement, newAngle: newAngle);

View File

@@ -0,0 +1,47 @@
import 'package:test/test.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
void main() {
group('Enemy map IDs', () {
test('maps officers, dogs, and mutants to the correct enemy types', () {
expect(EnemyType.fromMapId(116), EnemyType.officer);
expect(EnemyType.fromMapId(134), EnemyType.dog);
expect(EnemyType.fromMapId(216), EnemyType.mutant);
});
test('uses the original mutant 18-ID difficulty offset', () {
expect(EnemyType.mutant.mapData.getNormalizedId(216, Difficulty.easy), 216);
expect(
EnemyType.mutant.mapData.getNormalizedId(234, Difficulty.medium),
216,
);
expect(
EnemyType.mutant.mapData.getNormalizedId(252, Difficulty.hard),
216,
);
});
});
group('Enemy spawn facing', () {
test('spawns dog directions using the original dog map IDs', () {
expect(_spawnEnemy(134).angle, CardinalDirection.east.radians);
expect(_spawnEnemy(135).angle, CardinalDirection.north.radians);
expect(_spawnEnemy(136).angle, CardinalDirection.west.radians);
expect(_spawnEnemy(137).angle, CardinalDirection.south.radians);
});
test('maps patrol variants to the same facing directions', () {
expect(_spawnEnemy(138).angle, CardinalDirection.east.radians);
expect(_spawnEnemy(139).angle, CardinalDirection.north.radians);
expect(_spawnEnemy(140).angle, CardinalDirection.west.radians);
expect(_spawnEnemy(141).angle, CardinalDirection.south.radians);
});
});
}
Enemy _spawnEnemy(int mapId) {
final enemy = Enemy.spawn(mapId, 8.5, 8.5, Difficulty.hard);
expect(enemy, isNotNull);
return enemy!;
}

View File

@@ -1,7 +1,7 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart';
void main() { void main() {
group('Enemy Sprite Range Validation', () { group('Enemy Sprite Range Validation', () {
@@ -22,6 +22,16 @@ void main() {
// Skip if we are comparing the exact same animation on the same enemy // Skip if we are comparing the exact same animation on the same enemy
if (enemyA == enemyB && animA == animB) return; if (enemyA == enemyB && animA == animB) return;
// Dogs intentionally reuse some standing frames as walking frames.
final isDogIdleWalkReuse =
enemyA == EnemyType.dog &&
enemyB == EnemyType.dog &&
((animA == EnemyAnimation.idle &&
animB == EnemyAnimation.walking) ||
(animA == EnemyAnimation.walking &&
animB == EnemyAnimation.idle));
if (isDogIdleWalkReuse) return;
if (rangeA.overlaps(rangeB)) { if (rangeA.overlaps(rangeB)) {
// Determine the specific frames that are clashing // Determine the specific frames that are clashing
final start = math.max(rangeA.start, rangeB.start); final start = math.max(rangeA.start, rangeB.start);