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;
// --- Enemy Range Constants ---
// Every enemy type occupies a block of 36 IDs.
// Modulo math is used on these ranges to determine orientation and patrol status.
// Enemies are encoded in 8-ID blocks: 4 standing directions and 4 patrol directions.
// 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 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 mutantStart = 136; // Claims 136-143, 172-179, 208-215
static const int officerStart = 252; // Claims 252-259, 288-295, 324-331
static const int dogStart = 134; // Claims 134-141, 170-177, 206-213
static const int mutantStart = 216; // Claims 216-223, 234-241, 252-259
// --- Missing Decorative Bodies ---
static const int deadGuard = 124; // Decorative only in WL1
@@ -119,9 +119,14 @@ abstract class MapObject {
final EnemyType? type = EnemyType.fromMapId(id);
if (type == null) return 0.0;
// Because all enemies are grouped in blocks of 4 (one for each CardinalDirection),
// modulo 4 extracts the specific orientation index.
return CardinalDirection.fromEnemyIndex(id % 4).radians;
final int? normalizedId = type.mapData.getNormalizedId(id, Difficulty.hard);
if (normalizedId == null) return 0.0;
// 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].

View File

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

View File

@@ -85,6 +85,7 @@ class WolfEngine {
/// Initializes the engine, sets the starting episode, and loads the first level.
void init() {
audio.activeGame = data;
_currentEpisodeIndex = startingEpisode;
_currentLevelIndex = 0;
_loadLevel();
@@ -247,9 +248,13 @@ class WolfEngine {
}
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();
_lastAcousticAlertTime = _timeAliveMs;
}
@@ -356,6 +361,7 @@ class WolfEngine {
isWalkable: isWalkable,
tryOpenDoor: doorManager.tryOpenDoor,
onDamagePlayer: (int damage) => player.takeDamage(damage),
onPlaySound: audio.playSoundEffect,
);
// 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 &&
entity.position.y.toInt() == tile.y) {
entity.isAlerted = true;
audio.playSoundEffect(entity.alertSoundId);
// Wake them up!
if (entity.state == EntityState.idle ||
@@ -480,6 +487,17 @@ class WolfEngine {
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
/// and map-boundary jumps during low framerate/high delta spikes.
Coordinate2D _clampMovement(Coordinate2D intent) {

View File

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

View File

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

View File

@@ -48,6 +48,12 @@ abstract class Enemy extends Entity {
/// knowing the specific subclass.
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.
bool hasDroppedItem = false;
@@ -111,6 +117,7 @@ abstract class Enemy extends Entity {
void handleAttackState({
required int elapsedDeltaMs,
required void Function(int damage) onDamagePlayer,
required void Function() onAttack,
required int shootTics,
required int cooldownTics,
required int postAttackTics,
@@ -122,6 +129,7 @@ abstract class Enemy extends Entity {
if (currentFrame == 1) {
// Phase 1: Bang! Calculate hit chance based on distance.
// 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);
if (math.Random().nextDouble() <= hitChance) {
@@ -147,13 +155,15 @@ abstract class Enemy extends Entity {
///
/// Includes a randomized delay based on [baseReactionMs] and [reactionVarianceMs]
/// to prevent all enemies in a room from reacting on the exact same frame.
void checkWakeUp({
bool checkWakeUp({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
int baseReactionMs = 200,
int reactionVarianceMs = 600,
}) {
bool didAlert = false;
if (!isAlerted && hasLineOfSight(playerPosition, isWalkable)) {
if (reactionTimeMs == 0) {
// First frame of spotting: calculate how long until they "wake up"
@@ -164,6 +174,7 @@ abstract class Enemy extends Entity {
} else if (elapsedMs >= reactionTimeMs) {
// Reaction delay has passed
isAlerted = true;
didAlert = true;
if (state == EntityState.idle || state == EntityState.ambushing) {
state = EntityState.patrolling;
@@ -174,6 +185,8 @@ abstract class Enemy extends Entity {
reactionTimeMs = 0;
}
}
return didAlert;
}
/// 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 void Function(int x, int y) tryOpenDoor,
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.
@@ -358,7 +372,9 @@ abstract class Enemy extends Entity {
// 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.
double spawnAngle = CardinalDirection.fromEnemyIndex(normalizedId).radians;
double spawnAngle = CardinalDirection.fromEnemyIndex(
normalizedId - matchedType.mapData.baseId,
).radians;
EntityState spawnState;

View File

@@ -11,6 +11,8 @@ enum EnemyType {
/// Standard Brown Guard (The most common enemy).
guard(
mapData: EnemyMapData(MapObject.guardStart),
alertSoundId: WolfSound.guardHalt,
attackSoundId: WolfSound.naziFire,
animations: EnemyAnimationMap(
idle: SpriteFrameRange(50, 57),
walking: SpriteFrameRange(58, 89),
@@ -24,6 +26,8 @@ enum EnemyType {
/// Attack Dog (Fast melee enemy).
dog(
mapData: EnemyMapData(MapObject.dogStart),
alertSoundId: WolfSound.dogBark,
attackSoundId: WolfSound.dogAttack,
animations: EnemyAnimationMap(
// Dogs don't have true idle sprites, so map idle to the first walk frame safely
idle: SpriteFrameRange(99, 106),
@@ -43,6 +47,8 @@ enum EnemyType {
/// SS Officer (Blue uniform, machine gun).
ss(
mapData: EnemyMapData(MapObject.ssStart),
alertSoundId: WolfSound.ssSchutzstaffel,
attackSoundId: WolfSound.naziFire,
animations: EnemyAnimationMap(
idle: SpriteFrameRange(138, 145),
walking: SpriteFrameRange(146, 177),
@@ -55,7 +61,9 @@ enum EnemyType {
/// Undead Mutant (Exclusive to later episodes/retail).
mutant(
mapData: EnemyMapData(MapObject.mutantStart),
mapData: EnemyMapData(MapObject.mutantStart, tierOffset: 18),
alertSoundId: WolfSound.guardHalt,
attackSoundId: WolfSound.naziFire,
animations: EnemyAnimationMap(
idle: SpriteFrameRange(187, 194),
walking: SpriteFrameRange(195, 226),
@@ -70,6 +78,8 @@ enum EnemyType {
/// High-ranking Officer (White uniform, fast pistol).
officer(
mapData: EnemyMapData(MapObject.officerStart),
alertSoundId: WolfSound.guardHalt,
attackSoundId: WolfSound.naziFire,
animations: EnemyAnimationMap(
idle: SpriteFrameRange(238, 245),
walking: SpriteFrameRange(246, 277),
@@ -88,12 +98,20 @@ enum EnemyType {
/// The animation ranges for this enemy type.
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.
final bool existsInShareware;
const EnemyType({
required this.mapData,
required this.animations,
required this.alertSoundId,
required this.attackSoundId,
this.existsInShareware = true,
});
@@ -151,7 +169,6 @@ enum EnemyType {
// --- Octant Calculation ---
// 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;
if (octant < 0) octant += 8;

View File

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

View File

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

View File

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

View File

@@ -32,22 +32,25 @@ class SS extends Enemy {
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
required void Function(int sfxId) onPlaySound,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
checkWakeUp(
if (checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
);
)) {
onPlaySound(alertSoundId);
}
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) newAngle = angleToPlayer;
double diff = angleToPlayer - newAngle;
double diff = newAngle - angleToPlayer;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
@@ -104,6 +107,7 @@ class SS extends Enemy {
if (processTics(elapsedDeltaMs, moveSpeed: 0)) {
currentFrame++;
if (currentFrame == 1) {
onPlaySound(attackSoundId);
onDamagePlayer(damage);
setTics(5); // Bang!
} else if (currentFrame == 2) {
@@ -114,6 +118,7 @@ class SS extends Enemy {
if (math.Random().nextDouble() > 0.5) {
// 50% chance to burst
currentFrame = 1;
onPlaySound(attackSoundId);
onDamagePlayer(damage);
setTics(5);
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 '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() {
group('Enemy Sprite Range Validation', () {
@@ -22,6 +22,16 @@ void main() {
// Skip if we are comparing the exact same animation on the same enemy
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)) {
// Determine the specific frames that are clashing
final start = math.max(rangeA.start, rangeB.start);