diff --git a/packages/wolf_3d_dart/lib/src/engine/managers/pushwall_manager.dart b/packages/wolf_3d_dart/lib/src/engine/managers/pushwall_manager.dart index 53d44b8..591cd70 100644 --- a/packages/wolf_3d_dart/lib/src/engine/managers/pushwall_manager.dart +++ b/packages/wolf_3d_dart/lib/src/engine/managers/pushwall_manager.dart @@ -19,6 +19,12 @@ class Pushwall { /// /// Only one pushwall can be active (moving) at any given time. 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 pushwalls = {}; Pushwall? activePushwall; @@ -121,6 +127,7 @@ class PushwallManager { int checkY = targetY + pw.dirY; if (wallGrid[checkY][checkX] == 0) { activePushwall = pw; + onPlaySound?.call(WolfSound.pushWall); } } } diff --git a/packages/wolf_3d_dart/lib/src/engine/player/player.dart b/packages/wolf_3d_dart/lib/src/engine/player/player.dart index 7352584..cea796d 100644 --- a/packages/wolf_3d_dart/lib/src/engine/player/player.dart +++ b/packages/wolf_3d_dart/lib/src/engine/player/player.dart @@ -135,34 +135,55 @@ class Player { 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; + int? pickupSfxId; switch (item.type) { case CollectibleType.health: - if (health >= 100) return false; - heal(item.mapId == MapObject.dogFoodDecoration ? 4 : 25); + if (health >= 100) return null; + heal(item.mapId == MapObject.food ? 4 : 25); + pickupSfxId = item.mapId == MapObject.food + ? WolfSound.healthSmall + : WolfSound.healthLarge; pickedUp = true; break; case CollectibleType.ammo: - if (ammo >= 99) return false; + if (ammo >= 99) return null; int previousAmmo = ammo; addAmmo(8); if (currentWeapon is Knife && previousAmmo <= 0) { requestWeaponSwitch(WeaponType.pistol); } + pickupSfxId = WolfSound.getAmmo; pickedUp = true; break; case CollectibleType.treasure: - if (item.mapId == MapObject.cross) score += 100; - if (item.mapId == MapObject.chalice) score += 500; - if (item.mapId == MapObject.chest) score += 1000; - if (item.mapId == MapObject.crown) score += 5000; + if (item.mapId == MapObject.cross) { + score += 100; + pickupSfxId = WolfSound.treasure1; + } + 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) { heal(100); addAmmo(25); + pickupSfxId = WolfSound.extraLife; } pickedUp = true; break; @@ -175,6 +196,7 @@ class Player { } addAmmo(8); requestWeaponSwitch(WeaponType.machineGun); + pickupSfxId = WolfSound.getMachineGun; pickedUp = true; } if (item.mapId == MapObject.chainGun) { @@ -184,6 +206,7 @@ class Player { } addAmmo(8); requestWeaponSwitch(WeaponType.chainGun); + pickupSfxId = WolfSound.getGatling; pickedUp = true; } break; @@ -191,10 +214,11 @@ class Player { case CollectibleType.key: if (item.mapId == MapObject.goldKey) hasGoldKey = true; if (item.mapId == MapObject.silverKey) hasSilverKey = true; + pickupSfxId = WolfSound.getAmmo; pickedUp = true; break; } - return pickedUp; + return pickedUp ? pickupSfxId : null; } bool fire(int currentTime) { diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index 0a23022..55f0b5e 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -22,6 +22,9 @@ class WolfEngine { }) : audio = audio ?? CliSilentAudio(), doorManager = DoorManager( onPlaySound: (sfxId) => audio?.playSoundEffect(sfxId), + ), + pushwallManager = PushwallManager( + onPlaySound: (sfxId) => audio?.playSoundEffect(sfxId), ); /// Total milliseconds elapsed since the engine was initialized. @@ -54,7 +57,7 @@ class WolfEngine { FrameBuffer frameBuffer; /// Handles the detection and movement of secret "Pushwalls". - final PushwallManager pushwallManager = PushwallManager(); + final PushwallManager pushwallManager; // --- World State --- @@ -203,6 +206,7 @@ class WolfEngine { /// Handles floor transitions, including the "Level 10" secret floor logic. void _onLevelCompleted({bool isSecretExit = false}) { + audio.playSoundEffect(WolfSound.levelDone); audio.stopMusic(); final currentEpisode = data.episodes[_currentEpisodeIndex]; @@ -335,6 +339,13 @@ class WolfEngine { for (Entity entity in entities) { 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) --- // We check if the enemy is in the 'dead' state and currently 'isDying'. // Inside WolfEngine._updateEntities @@ -397,7 +408,9 @@ class WolfEngine { } } else if (entity is Collectible) { 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); } } diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart index 43294f2..c1d8dda 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/bosses/hans_grosse.dart @@ -17,6 +17,9 @@ class HansGrosse extends Enemy { @override int get attackSoundId => WolfSound.naziFire; + @override + int get deathSoundId => WolfSound.mutti; + HansGrosse({ required super.x, required super.y, diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart index f42effb..c7caa41 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart @@ -54,9 +54,15 @@ abstract class Enemy extends Entity { /// The sound played when this enemy performs its attack animation. 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. 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. bool isAlerted = false; diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy_type.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy_type.dart index 6621fa8..2ff9c62 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy_type.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy_type.dart @@ -13,6 +13,7 @@ enum EnemyType { mapData: EnemyMapData(MapObject.guardStart), alertSoundId: WolfSound.guardHalt, attackSoundId: WolfSound.naziFire, + deathSoundId: WolfSound.deathScream1, animations: EnemyAnimationMap( idle: SpriteFrameRange(50, 57), walking: SpriteFrameRange(58, 89), @@ -28,6 +29,7 @@ enum EnemyType { mapData: EnemyMapData(MapObject.dogStart), alertSoundId: WolfSound.dogBark, attackSoundId: WolfSound.dogAttack, + deathSoundId: WolfSound.dogDeath, animations: EnemyAnimationMap( // Dogs don't have true idle sprites, so map idle to the first walk frame safely idle: SpriteFrameRange(99, 106), @@ -49,6 +51,7 @@ enum EnemyType { mapData: EnemyMapData(MapObject.ssStart), alertSoundId: WolfSound.ssSchutzstaffel, attackSoundId: WolfSound.naziFire, + deathSoundId: WolfSound.ssMeinGott, animations: EnemyAnimationMap( idle: SpriteFrameRange(138, 145), walking: SpriteFrameRange(146, 177), @@ -64,6 +67,7 @@ enum EnemyType { mapData: EnemyMapData(MapObject.mutantStart, tierOffset: 18), alertSoundId: WolfSound.guardHalt, attackSoundId: WolfSound.naziFire, + deathSoundId: WolfSound.deathScream2, animations: EnemyAnimationMap( idle: SpriteFrameRange(187, 194), walking: SpriteFrameRange(195, 226), @@ -80,6 +84,7 @@ enum EnemyType { mapData: EnemyMapData(MapObject.officerStart), alertSoundId: WolfSound.guardHalt, attackSoundId: WolfSound.naziFire, + deathSoundId: WolfSound.deathScream3, animations: EnemyAnimationMap( idle: SpriteFrameRange(238, 245), walking: SpriteFrameRange(246, 277), @@ -104,6 +109,9 @@ enum EnemyType { /// The sound played when this enemy attacks. 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. final bool existsInShareware; @@ -112,6 +120,7 @@ enum EnemyType { required this.animations, required this.alertSoundId, required this.attackSoundId, + required this.deathSoundId, this.existsInShareware = true, }); diff --git a/packages/wolf_3d_dart/test/engine/audio_events_test.dart b/packages/wolf_3d_dart/test/engine/audio_events_test.dart new file mode 100644 index 0000000..0964a3b --- /dev/null +++ b/packages/wolf_3d_dart/test/engine/audio_events_test.dart @@ -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().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 sfxIds = []; + + @override + Future debugSoundTest() async {} + + @override + Future 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))); +} diff --git a/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart b/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart index cb72f04..e9cb54a 100644 --- a/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart +++ b/packages/wolf_3d_dart/test/entities/enemy_spawn_test.dart @@ -11,7 +11,10 @@ void main() { }); 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( EnemyType.mutant.mapData.getNormalizedId(234, Difficulty.medium), 216, @@ -44,4 +47,4 @@ Enemy _spawnEnemy(int mapId) { final enemy = Enemy.spawn(mapId, 8.5, 8.5, Difficulty.hard); expect(enemy, isNotNull); return enemy!; -} \ No newline at end of file +}