Added some additional sound effects

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-18 02:40:53 +01:00
parent 806c9b6966
commit 7fe9a8bc40
8 changed files with 284 additions and 13 deletions

View File

@@ -19,6 +19,12 @@ class Pushwall {
/// ///
/// Only one pushwall can be active (moving) at any given time. /// Only one pushwall can be active (moving) at any given time.
class PushwallManager { class PushwallManager {
/// Creates a pushwall manager with an optional sound dispatch callback.
PushwallManager({this.onPlaySound});
/// Optional callback used to emit audio cues when pushwalls activate.
final void Function(int sfxId)? onPlaySound;
final Map<String, Pushwall> pushwalls = {}; final Map<String, Pushwall> pushwalls = {};
Pushwall? activePushwall; Pushwall? activePushwall;
@@ -121,6 +127,7 @@ class PushwallManager {
int checkY = targetY + pw.dirY; int checkY = targetY + pw.dirY;
if (wallGrid[checkY][checkX] == 0) { if (wallGrid[checkY][checkX] == 0) {
activePushwall = pw; activePushwall = pw;
onPlaySound?.call(WolfSound.pushWall);
} }
} }
} }

View File

@@ -135,34 +135,55 @@ class Player {
ammo = newAmmo; ammo = newAmmo;
} }
bool tryPickup(Collectible item) { /// Attempts to collect [item] and returns the SFX to play.
///
/// Returns `null` when the item was not collected (for example: full health).
int? tryPickup(Collectible item) {
bool pickedUp = false; bool pickedUp = false;
int? pickupSfxId;
switch (item.type) { switch (item.type) {
case CollectibleType.health: case CollectibleType.health:
if (health >= 100) return false; if (health >= 100) return null;
heal(item.mapId == MapObject.dogFoodDecoration ? 4 : 25); heal(item.mapId == MapObject.food ? 4 : 25);
pickupSfxId = item.mapId == MapObject.food
? WolfSound.healthSmall
: WolfSound.healthLarge;
pickedUp = true; pickedUp = true;
break; break;
case CollectibleType.ammo: case CollectibleType.ammo:
if (ammo >= 99) return false; if (ammo >= 99) return null;
int previousAmmo = ammo; int previousAmmo = ammo;
addAmmo(8); addAmmo(8);
if (currentWeapon is Knife && previousAmmo <= 0) { if (currentWeapon is Knife && previousAmmo <= 0) {
requestWeaponSwitch(WeaponType.pistol); requestWeaponSwitch(WeaponType.pistol);
} }
pickupSfxId = WolfSound.getAmmo;
pickedUp = true; pickedUp = true;
break; break;
case CollectibleType.treasure: case CollectibleType.treasure:
if (item.mapId == MapObject.cross) score += 100; if (item.mapId == MapObject.cross) {
if (item.mapId == MapObject.chalice) score += 500; score += 100;
if (item.mapId == MapObject.chest) score += 1000; pickupSfxId = WolfSound.treasure1;
if (item.mapId == MapObject.crown) score += 5000; }
if (item.mapId == MapObject.chalice) {
score += 500;
pickupSfxId = WolfSound.treasure2;
}
if (item.mapId == MapObject.chest) {
score += 1000;
pickupSfxId = WolfSound.treasure3;
}
if (item.mapId == MapObject.crown) {
score += 5000;
pickupSfxId = WolfSound.treasure4;
}
if (item.mapId == MapObject.extraLife) { if (item.mapId == MapObject.extraLife) {
heal(100); heal(100);
addAmmo(25); addAmmo(25);
pickupSfxId = WolfSound.extraLife;
} }
pickedUp = true; pickedUp = true;
break; break;
@@ -175,6 +196,7 @@ class Player {
} }
addAmmo(8); addAmmo(8);
requestWeaponSwitch(WeaponType.machineGun); requestWeaponSwitch(WeaponType.machineGun);
pickupSfxId = WolfSound.getMachineGun;
pickedUp = true; pickedUp = true;
} }
if (item.mapId == MapObject.chainGun) { if (item.mapId == MapObject.chainGun) {
@@ -184,6 +206,7 @@ class Player {
} }
addAmmo(8); addAmmo(8);
requestWeaponSwitch(WeaponType.chainGun); requestWeaponSwitch(WeaponType.chainGun);
pickupSfxId = WolfSound.getGatling;
pickedUp = true; pickedUp = true;
} }
break; break;
@@ -191,10 +214,11 @@ class Player {
case CollectibleType.key: case CollectibleType.key:
if (item.mapId == MapObject.goldKey) hasGoldKey = true; if (item.mapId == MapObject.goldKey) hasGoldKey = true;
if (item.mapId == MapObject.silverKey) hasSilverKey = true; if (item.mapId == MapObject.silverKey) hasSilverKey = true;
pickupSfxId = WolfSound.getAmmo;
pickedUp = true; pickedUp = true;
break; break;
} }
return pickedUp; return pickedUp ? pickupSfxId : null;
} }
bool fire(int currentTime) { bool fire(int currentTime) {

View File

@@ -22,6 +22,9 @@ class WolfEngine {
}) : audio = audio ?? CliSilentAudio(), }) : audio = audio ?? CliSilentAudio(),
doorManager = DoorManager( doorManager = DoorManager(
onPlaySound: (sfxId) => audio?.playSoundEffect(sfxId), onPlaySound: (sfxId) => audio?.playSoundEffect(sfxId),
),
pushwallManager = PushwallManager(
onPlaySound: (sfxId) => audio?.playSoundEffect(sfxId),
); );
/// Total milliseconds elapsed since the engine was initialized. /// Total milliseconds elapsed since the engine was initialized.
@@ -54,7 +57,7 @@ class WolfEngine {
FrameBuffer frameBuffer; FrameBuffer frameBuffer;
/// Handles the detection and movement of secret "Pushwalls". /// Handles the detection and movement of secret "Pushwalls".
final PushwallManager pushwallManager = PushwallManager(); final PushwallManager pushwallManager;
// --- World State --- // --- World State ---
@@ -203,6 +206,7 @@ class WolfEngine {
/// Handles floor transitions, including the "Level 10" secret floor logic. /// Handles floor transitions, including the "Level 10" secret floor logic.
void _onLevelCompleted({bool isSecretExit = false}) { void _onLevelCompleted({bool isSecretExit = false}) {
audio.playSoundEffect(WolfSound.levelDone);
audio.stopMusic(); audio.stopMusic();
final currentEpisode = data.episodes[_currentEpisodeIndex]; final currentEpisode = data.episodes[_currentEpisodeIndex];
@@ -335,6 +339,13 @@ class WolfEngine {
for (Entity entity in entities) { for (Entity entity in entities) {
if (entity is Enemy) { if (entity is Enemy) {
if (entity.state == EntityState.dead &&
entity.isDying &&
!entity.hasPlayedDeathSound) {
audio.playSoundEffect(entity.deathSoundId);
entity.hasPlayedDeathSound = true;
}
// --- ANIMATION TRANSITION FIX (SAFE VERSION) --- // --- ANIMATION TRANSITION FIX (SAFE VERSION) ---
// We check if the enemy is in the 'dead' state and currently 'isDying'. // We check if the enemy is in the 'dead' state and currently 'isDying'.
// Inside WolfEngine._updateEntities // Inside WolfEngine._updateEntities
@@ -397,7 +408,9 @@ class WolfEngine {
} }
} else if (entity is Collectible) { } else if (entity is Collectible) {
if (player.position.distanceTo(entity.position) < 0.5) { if (player.position.distanceTo(entity.position) < 0.5) {
if (player.tryPickup(entity)) { final pickupSfxId = player.tryPickup(entity);
if (pickupSfxId != null) {
audio.playSoundEffect(pickupSfxId);
itemsToRemove.add(entity); itemsToRemove.add(entity);
} }
} }

View File

@@ -17,6 +17,9 @@ class HansGrosse extends Enemy {
@override @override
int get attackSoundId => WolfSound.naziFire; int get attackSoundId => WolfSound.naziFire;
@override
int get deathSoundId => WolfSound.mutti;
HansGrosse({ HansGrosse({
required super.x, required super.x,
required super.y, required super.y,

View File

@@ -54,9 +54,15 @@ abstract class Enemy extends Entity {
/// The sound played when this enemy performs its attack animation. /// The sound played when this enemy performs its attack animation.
int get attackSoundId => type.attackSoundId; int get attackSoundId => type.attackSoundId;
/// The sound played once when this enemy starts dying.
int get deathSoundId => type.deathSoundId;
/// 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;
/// Prevents duplicate death screams while death frames continue animating.
bool hasPlayedDeathSound = false;
/// When true, the enemy has spotted the player and is actively pursuing or attacking. /// When true, the enemy has spotted the player and is actively pursuing or attacking.
bool isAlerted = false; bool isAlerted = false;

View File

@@ -13,6 +13,7 @@ enum EnemyType {
mapData: EnemyMapData(MapObject.guardStart), mapData: EnemyMapData(MapObject.guardStart),
alertSoundId: WolfSound.guardHalt, alertSoundId: WolfSound.guardHalt,
attackSoundId: WolfSound.naziFire, attackSoundId: WolfSound.naziFire,
deathSoundId: WolfSound.deathScream1,
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
idle: SpriteFrameRange(50, 57), idle: SpriteFrameRange(50, 57),
walking: SpriteFrameRange(58, 89), walking: SpriteFrameRange(58, 89),
@@ -28,6 +29,7 @@ enum EnemyType {
mapData: EnemyMapData(MapObject.dogStart), mapData: EnemyMapData(MapObject.dogStart),
alertSoundId: WolfSound.dogBark, alertSoundId: WolfSound.dogBark,
attackSoundId: WolfSound.dogAttack, attackSoundId: WolfSound.dogAttack,
deathSoundId: WolfSound.dogDeath,
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),
@@ -49,6 +51,7 @@ enum EnemyType {
mapData: EnemyMapData(MapObject.ssStart), mapData: EnemyMapData(MapObject.ssStart),
alertSoundId: WolfSound.ssSchutzstaffel, alertSoundId: WolfSound.ssSchutzstaffel,
attackSoundId: WolfSound.naziFire, attackSoundId: WolfSound.naziFire,
deathSoundId: WolfSound.ssMeinGott,
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
idle: SpriteFrameRange(138, 145), idle: SpriteFrameRange(138, 145),
walking: SpriteFrameRange(146, 177), walking: SpriteFrameRange(146, 177),
@@ -64,6 +67,7 @@ enum EnemyType {
mapData: EnemyMapData(MapObject.mutantStart, tierOffset: 18), mapData: EnemyMapData(MapObject.mutantStart, tierOffset: 18),
alertSoundId: WolfSound.guardHalt, alertSoundId: WolfSound.guardHalt,
attackSoundId: WolfSound.naziFire, attackSoundId: WolfSound.naziFire,
deathSoundId: WolfSound.deathScream2,
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
idle: SpriteFrameRange(187, 194), idle: SpriteFrameRange(187, 194),
walking: SpriteFrameRange(195, 226), walking: SpriteFrameRange(195, 226),
@@ -80,6 +84,7 @@ enum EnemyType {
mapData: EnemyMapData(MapObject.officerStart), mapData: EnemyMapData(MapObject.officerStart),
alertSoundId: WolfSound.guardHalt, alertSoundId: WolfSound.guardHalt,
attackSoundId: WolfSound.naziFire, attackSoundId: WolfSound.naziFire,
deathSoundId: WolfSound.deathScream3,
animations: EnemyAnimationMap( animations: EnemyAnimationMap(
idle: SpriteFrameRange(238, 245), idle: SpriteFrameRange(238, 245),
walking: SpriteFrameRange(246, 277), walking: SpriteFrameRange(246, 277),
@@ -104,6 +109,9 @@ enum EnemyType {
/// The sound played when this enemy attacks. /// The sound played when this enemy attacks.
final int attackSoundId; final int attackSoundId;
/// The sound played when this enemy enters its death animation.
final int deathSoundId;
/// 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;
@@ -112,6 +120,7 @@ enum EnemyType {
required this.animations, required this.animations,
required this.alertSoundId, required this.alertSoundId,
required this.attackSoundId, required this.attackSoundId,
required this.deathSoundId,
this.existsInShareware = true, this.existsInShareware = true,
}); });

View File

@@ -0,0 +1,206 @@
import 'dart:typed_data';
import 'package:test/test.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
void main() {
group('Engine audio events', () {
test('plays pickup sound effect when player collects ammo', () {
final input = _TestInput();
final audio = _CapturingAudio();
final engine = _buildEngine(
input: input,
audio: audio,
objectGridSetup: (grid) {
grid[2][2] = MapObject.playerEast;
},
);
engine.init();
engine.entities.add(
Collectible(
x: engine.player.x,
y: engine.player.y,
spriteIndex: 0,
mapId: MapObject.ammoClip,
type: CollectibleType.ammo,
),
);
engine.tick(const Duration(milliseconds: 16));
expect(audio.sfxIds, contains(WolfSound.getAmmo));
});
test('plays guard alert when guard notices player', () {
final input = _TestInput();
final audio = _CapturingAudio();
final engine = _buildEngine(
input: input,
audio: audio,
objectGridSetup: (grid) {
grid[2][2] = MapObject.playerEast;
grid[2][5] = MapObject.guardStart + 2;
},
);
engine.init();
// Wake-up delay is randomized, so tick long enough to always exceed it.
for (int i = 0; i < 80; i++) {
engine.tick(const Duration(milliseconds: 16));
}
expect(audio.sfxIds, contains(WolfSound.guardHalt));
});
test('plays dog death sound when dog dies', () {
final input = _TestInput();
final audio = _CapturingAudio();
final engine = _buildEngine(
input: input,
audio: audio,
objectGridSetup: (grid) {
grid[2][2] = MapObject.playerEast;
grid[2][4] = MapObject.dogStart;
},
);
engine.init();
final dog = engine.entities.whereType<Dog>().single;
dog.takeDamage(999, 0);
engine.tick(const Duration(milliseconds: 16));
expect(audio.sfxIds, contains(WolfSound.dogDeath));
});
test('plays pushwall sound when triggering a secret wall', () {
final input = _TestInput()..isInteracting = true;
final audio = _CapturingAudio();
final engine = _buildEngine(
input: input,
audio: audio,
wallGridSetup: (grid) {
grid[2][3] = 1;
},
objectGridSetup: (grid) {
grid[2][2] = MapObject.playerEast;
grid[2][3] = MapObject.pushwallTrigger;
},
);
engine.init();
engine.tick(const Duration(milliseconds: 16));
expect(audio.sfxIds, contains(WolfSound.pushWall));
});
});
}
/// Builds a minimal deterministic engine world for audio event tests.
WolfEngine _buildEngine({
required _TestInput input,
required _CapturingAudio audio,
void Function(SpriteMap grid)? wallGridSetup,
void Function(SpriteMap grid)? objectGridSetup,
}) {
final wallGrid = _buildGrid();
final objectGrid = _buildGrid();
_fillBoundaries(wallGrid, 2);
wallGridSetup?.call(wallGrid);
objectGridSetup?.call(objectGrid);
return WolfEngine(
data: WolfensteinData(
version: GameVersion.retail,
walls: [
_solidSprite(1),
_solidSprite(1),
_solidSprite(2),
_solidSprite(2),
],
sprites: List.generate(436, (_) => _solidSprite(255)),
sounds: List.generate(200, (_) => PcmSound(Uint8List(1))),
adLibSounds: const [],
music: const [],
vgaImages: const [],
episodes: [
Episode(
name: 'Episode 1',
levels: [
WolfLevel(
name: 'Test Level',
wallGrid: wallGrid,
objectGrid: objectGrid,
musicIndex: 0,
),
],
),
],
),
difficulty: Difficulty.hard,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: input,
audio: audio,
onGameWon: () {},
);
}
class _TestInput extends Wolf3dInput {
@override
void update() {}
}
/// Captures engine SFX IDs so tests can assert dispatch behavior.
class _CapturingAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
final List<int> sfxIds = [];
@override
Future<void> debugSoundTest() async {}
@override
Future<void> init() async {}
@override
void playLevelMusic(WolfLevel level) {}
@override
void playMenuMusic() {}
@override
void playSoundEffect(int sfxId) {
sfxIds.add(sfxId);
}
@override
void stopMusic() {}
@override
void dispose() {}
}
SpriteMap _buildGrid() => List.generate(64, (_) => List.filled(64, 0));
/// Fills the map borders so entities never walk off the test map.
void _fillBoundaries(SpriteMap grid, int wallId) {
for (int i = 0; i < 64; i++) {
grid[0][i] = wallId;
grid[63][i] = wallId;
grid[i][0] = wallId;
grid[i][63] = wallId;
}
}
/// Produces a simple solid-color sprite frame for synthetic test assets.
Sprite _solidSprite(int colorIndex) {
return Sprite(Uint8List.fromList(List.filled(64 * 64, colorIndex)));
}

View File

@@ -11,7 +11,10 @@ void main() {
}); });
test('uses the original mutant 18-ID difficulty offset', () { test('uses the original mutant 18-ID difficulty offset', () {
expect(EnemyType.mutant.mapData.getNormalizedId(216, Difficulty.easy), 216); expect(
EnemyType.mutant.mapData.getNormalizedId(216, Difficulty.easy),
216,
);
expect( expect(
EnemyType.mutant.mapData.getNormalizedId(234, Difficulty.medium), EnemyType.mutant.mapData.getNormalizedId(234, Difficulty.medium),
216, 216,