diff --git a/.vscode/settings.json b/.vscode/settings.json index 47e81c5..f9c3db6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "cmake.ignoreCMakeListsMissing": true, "chat.tools.terminal.autoApprove": { - "flutter": true + "flutter": true, + "dart": true } } \ No newline at end of file diff --git a/apps/wolf_3d_gui/lib/main.dart b/apps/wolf_3d_gui/lib/main.dart index 7019ac0..6a5d18c 100644 --- a/apps/wolf_3d_gui/lib/main.dart +++ b/apps/wolf_3d_gui/lib/main.dart @@ -22,6 +22,9 @@ void main() async { runApp( MaterialApp( + darkTheme: ThemeData.dark(useMaterial3: true), + theme: ThemeData.light(useMaterial3: true), + themeMode: ThemeMode.system, home: wolf3d.availableGames.isEmpty ? const _NoGameDataScreen() : GameScreen(wolf3d: wolf3d), diff --git a/apps/wolf_3d_gui/lib/screens/audio_gallery.dart b/apps/wolf_3d_gui/lib/screens/audio_gallery.dart new file mode 100644 index 0000000..43ca7a6 --- /dev/null +++ b/apps/wolf_3d_gui/lib/screens/audio_gallery.dart @@ -0,0 +1,455 @@ +/// Debug browser for SFX and music assets with playback controls. +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_synth.dart'; +import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; +import 'package:wolf_3d_gui/screens/gallery_game_selector.dart'; + +class _AudioRow { + final int id; + final List aliases; + + const _AudioRow({required this.id, required this.aliases}); + + String get subtitle { + if (aliases.isEmpty) { + return 'No known alias'; + } + return aliases.join(', '); + } +} + +/// Displays all decoded SFX and music tracks for the selected game data. +class AudioGallery extends StatefulWidget { + /// Shared app facade used to access game assets and the audio backend. + final Wolf3d wolf3d; + + const AudioGallery({super.key, required this.wolf3d}); + + @override + State createState() => _AudioGalleryState(); +} + +class _AudioGalleryState extends State { + late WolfensteinData _selectedGame; + int? _playingMusicTrackIndex; + + int _gridColumnsForWidth(double width) { + if (width >= 1400) return 6; + if (width >= 1100) return 5; + if (width >= 800) return 4; + if (width >= 560) return 3; + return 2; + } + + static const List _knownSfxKeys = [ + SfxKey.openDoor, + SfxKey.closeDoor, + SfxKey.pushWall, + SfxKey.knifeAttack, + SfxKey.pistolFire, + SfxKey.machineGunFire, + SfxKey.chainGunFire, + SfxKey.enemyFire, + SfxKey.getMachineGun, + SfxKey.getAmmo, + SfxKey.getChainGun, + SfxKey.healthSmall, + SfxKey.healthLarge, + SfxKey.treasure1, + SfxKey.treasure2, + SfxKey.treasure3, + SfxKey.treasure4, + SfxKey.extraLife, + SfxKey.guardHalt, + SfxKey.dogBark, + SfxKey.dogDeath, + SfxKey.dogAttack, + SfxKey.deathScream1, + SfxKey.deathScream2, + SfxKey.deathScream3, + SfxKey.ssAlert, + SfxKey.ssDeath, + SfxKey.bossActive, + SfxKey.hansGrosseDeath, + SfxKey.schabbs, + SfxKey.schabbsDeath, + SfxKey.hitlerGreeting, + SfxKey.hitlerDeath, + SfxKey.mechaSteps, + SfxKey.ottoAlert, + SfxKey.gretelDeath, + SfxKey.levelComplete, + SfxKey.endBonus1, + SfxKey.endBonus2, + SfxKey.noBonus, + SfxKey.percent100, + ]; + + static const List _knownMusicKeys = [ + MusicKey.menuTheme, + MusicKey.level01, + MusicKey.level02, + MusicKey.level03, + MusicKey.level04, + MusicKey.level05, + MusicKey.level06, + MusicKey.level07, + MusicKey.level08, + MusicKey.level09, + MusicKey.level10, + MusicKey.level11, + MusicKey.level12, + MusicKey.level13, + MusicKey.level14, + MusicKey.level15, + MusicKey.level16, + MusicKey.level17, + MusicKey.level18, + MusicKey.level19, + MusicKey.level20, + ]; + + @override + void initState() { + super.initState(); + _selectedGame = + widget.wolf3d.maybeActiveGame ?? widget.wolf3d.availableGames.first; + } + + @override + void dispose() { + // Ensure debug playback does not continue after closing the gallery. + unawaited(widget.wolf3d.audio.stopAllAudio()); + super.dispose(); + } + + Map> _buildSfxAliases() { + final Map> aliasesById = {}; + for (final key in _knownSfxKeys) { + final ref = _selectedGame.registry.sfx.resolve(key); + if (ref == null) { + continue; + } + aliasesById + .putIfAbsent(ref.slotIndex, () => {}) + .add(_readableKeyName(key.toString(), 'SfxKey(')); + } + + return aliasesById.map( + (id, aliases) => MapEntry(id, aliases.toList()..sort()), + ); + } + + Map> _buildMusicAliases() { + final Map> aliasesById = {}; + for (final key in _knownMusicKeys) { + final route = _selectedGame.registry.music.resolve(key); + if (route == null) { + continue; + } + aliasesById + .putIfAbsent(route.trackIndex, () => {}) + .add(_readableKeyName(key.toString(), 'MusicKey(')); + } + + return aliasesById.map( + (id, aliases) => MapEntry(id, aliases.toList()..sort()), + ); + } + + String _readableKeyName(String raw, String prefix) { + final String trimmed = raw.startsWith(prefix) && raw.endsWith(')') + ? raw.substring(prefix.length, raw.length - 1) + : raw; + if (trimmed.isEmpty) { + return raw; + } + + return trimmed.replaceAllMapped( + RegExp(r'([a-z0-9])([A-Z])'), + (match) => '${match.group(1)} ${match.group(2)}', + ); + } + + Future _stopAllAudioPlayback() async { + await widget.wolf3d.audio.stopAllAudio(); + if (!mounted) { + return; + } + setState(() { + _playingMusicTrackIndex = null; + }); + } + + Future _selectGame(WolfensteinData game) async { + if (identical(_selectedGame, game)) { + return; + } + + await _stopAllAudioPlayback(); + widget.wolf3d.setActiveGame(game); + if (!mounted) { + return; + } + setState(() { + _selectedGame = game; + _playingMusicTrackIndex = null; + }); + } + + void _playSfx(int id) { + widget.wolf3d.audio.playSoundEffect(id); + } + + Future _toggleMusic(int trackIndex) async { + if (_playingMusicTrackIndex == trackIndex) { + await _stopAllAudioPlayback(); + return; + } + + final engineAudio = widget.wolf3d.audio; + if (engineAudio is! WolfAudio) { + return; + } + + if (trackIndex < 0 || trackIndex >= _selectedGame.music.length) { + return; + } + + await engineAudio.playMusic(_selectedGame.music[trackIndex]); + if (!mounted) { + return; + } + setState(() { + _playingMusicTrackIndex = trackIndex; + }); + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Audio Gallery'), + actions: [ + if (widget.wolf3d.availableGames.length > 1) + GalleryGameSelector( + wolf3d: widget.wolf3d, + selectedGame: _selectedGame, + onSelected: (game) { + unawaited(_selectGame(game)); + }, + ), + ], + bottom: const TabBar( + tabs: [ + Tab(icon: Icon(Icons.graphic_eq), text: 'SFX'), + Tab(icon: Icon(Icons.music_note), text: 'Music'), + ], + ), + ), + body: TabBarView( + children: [ + _buildSfxTab(), + _buildMusicTab(), + ], + ), + ), + ); + } + + Widget _buildSfxTab() { + final aliasesById = _buildSfxAliases(); + final rows = List<_AudioRow>.generate( + _selectedGame.sounds.length, + (id) => _AudioRow(id: id, aliases: aliasesById[id] ?? const []), + ); + + if (rows.isEmpty) { + return const Center(child: Text('No SFX available in this game data.')); + } + + return Column( + children: [ + const ListTile( + leading: Icon(Icons.info_outline), + title: Text('Tap any SFX to play it once'), + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final columns = _gridColumnsForWidth(constraints.maxWidth); + return GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 2.8, + ), + itemCount: rows.length, + itemBuilder: (context, index) { + final row = rows[index]; + return Card( + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _playSfx(row.id), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + children: [ + CircleAvatar(child: Text('${row.id}')), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SFX ${row.id}', + style: Theme.of( + context, + ).textTheme.titleSmall, + ), + const SizedBox(height: 2), + Text( + row.subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + ], + ), + ), + const Icon(Icons.play_arrow), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ), + ], + ); + } + + Widget _buildMusicTab() { + final aliasesById = _buildMusicAliases(); + final rows = List<_AudioRow>.generate( + _selectedGame.music.length, + (id) => _AudioRow(id: id, aliases: aliasesById[id] ?? const []), + ); + + if (rows.isEmpty) { + return const Center( + child: Text('No music tracks available in this game data.'), + ); + } + + return Column( + children: [ + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('Tap a track to play, tap again to stop'), + subtitle: Text( + _playingMusicTrackIndex == null + ? 'No track currently playing' + : 'Playing track ${_playingMusicTrackIndex!}', + ), + trailing: FilledButton.icon( + onPressed: _playingMusicTrackIndex == null + ? null + : () { + unawaited(_stopAllAudioPlayback()); + }, + icon: const Icon(Icons.stop), + label: const Text('Stop'), + ), + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final columns = _gridColumnsForWidth(constraints.maxWidth); + return GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 2.8, + ), + itemCount: rows.length, + itemBuilder: (context, index) { + final row = rows[index]; + final bool isPlaying = _playingMusicTrackIndex == row.id; + + return Card( + color: isPlaying + ? Theme.of(context).colorScheme.primaryContainer + : null, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + unawaited(_toggleMusic(row.id)); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + children: [ + CircleAvatar(child: Text('${row.id}')), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Track ${row.id}', + style: Theme.of( + context, + ).textTheme.titleSmall, + ), + const SizedBox(height: 2), + Text( + row.subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + ], + ), + ), + Icon(isPlaying ? Icons.stop : Icons.play_arrow), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ), + ], + ); + } +} diff --git a/apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart b/apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart index bc4b1be..76e1e48 100644 --- a/apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart @@ -3,6 +3,7 @@ library; import 'package:flutter/material.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; +import 'package:wolf_3d_gui/screens/audio_gallery.dart'; import 'package:wolf_3d_gui/screens/sprite_gallery.dart'; import 'package:wolf_3d_gui/screens/vga_gallery.dart'; @@ -29,6 +30,21 @@ class DebugToolsScreen extends StatelessWidget { style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 12), + Card( + child: ListTile( + leading: const Icon(Icons.library_music), + title: const Text('Audio Gallery'), + subtitle: const Text('Browse and test SFX and music tracks.'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AudioGallery(wolf3d: wolf3d), + ), + ); + }, + ), + ), Card( child: ListTile( leading: const Icon(Icons.image_search), diff --git a/packages/wolf_3d_dart/lib/src/engine/audio/engine_audio.dart b/packages/wolf_3d_dart/lib/src/engine/audio/engine_audio.dart index b515c25..ddd8cd8 100644 --- a/packages/wolf_3d_dart/lib/src/engine/audio/engine_audio.dart +++ b/packages/wolf_3d_dart/lib/src/engine/audio/engine_audio.dart @@ -6,6 +6,7 @@ abstract class EngineAudio { void playMenuMusic(); void playLevelMusic(WolfLevel level); void stopMusic(); + Future stopAllAudio(); void playSoundEffect(int sfxId); Future init(); void dispose(); diff --git a/packages/wolf_3d_dart/lib/src/engine/audio/silent_renderer.dart b/packages/wolf_3d_dart/lib/src/engine/audio/silent_renderer.dart index c5d7938..0fa90d5 100644 --- a/packages/wolf_3d_dart/lib/src/engine/audio/silent_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/engine/audio/silent_renderer.dart @@ -22,6 +22,9 @@ class CliSilentAudio implements EngineAudio { @override void stopMusic() {} + @override + Future stopAllAudio() async {} + @override void playSoundEffect(int sfxId) { // Optional: You could use the terminal 'bell' character here diff --git a/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart b/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart index 45da72c..a9ba049 100644 --- a/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart +++ b/packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart @@ -62,7 +62,7 @@ class WolfAudio implements EngineAudio { /// Disposes of the audio engine and frees resources. @override void dispose() { - stopMusic(); + stopAllAudio(); _musicPlayer.dispose(); for (final player in _sfxPlayers) { @@ -101,6 +101,16 @@ class WolfAudio implements EngineAudio { await _musicPlayer.stop(); } + @override + Future stopAllAudio() async { + if (!_isInitialized) return; + + await _musicPlayer.stop(); + for (final player in _sfxPlayers) { + await player.stop(); + } + } + Future pauseMusic() async { if (_isInitialized) await _musicPlayer.pause(); } diff --git a/packages/wolf_3d_dart/test/engine/audio_events_test.dart b/packages/wolf_3d_dart/test/engine/audio_events_test.dart index aa077e0..4c32243 100644 --- a/packages/wolf_3d_dart/test/engine/audio_events_test.dart +++ b/packages/wolf_3d_dart/test/engine/audio_events_test.dart @@ -181,6 +181,9 @@ class _CapturingAudio implements EngineAudio { @override void stopMusic() {} + @override + Future stopAllAudio() async {} + @override void dispose() {} } diff --git a/packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart b/packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart index e483c58..e7bc871 100644 --- a/packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart +++ b/packages/wolf_3d_dart/test/engine/enemy_drop_parity_test.dart @@ -130,6 +130,9 @@ class _SilentAudio implements EngineAudio { @override void stopMusic() {} + @override + Future stopAllAudio() async {} + @override void dispose() {} } diff --git a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart index 30d78b8..d369dc2 100644 --- a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart +++ b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart @@ -407,6 +407,9 @@ class _SilentAudio implements EngineAudio { @override void stopMusic() {} + @override + Future stopAllAudio() async {} + @override void dispose() {} } diff --git a/packages/wolf_3d_flutter/lib/audio/audio_adaptor.dart b/packages/wolf_3d_flutter/lib/audio/audio_adaptor.dart index 3e5a2b0..3c3b49a 100644 --- a/packages/wolf_3d_flutter/lib/audio/audio_adaptor.dart +++ b/packages/wolf_3d_flutter/lib/audio/audio_adaptor.dart @@ -17,6 +17,11 @@ class FlutterAudioAdapter implements EngineAudio { wolf3d.audio.stopMusic(); } + @override + Future stopAllAudio() async { + await wolf3d.audio.stopAllAudio(); + } + @override void playSoundEffect(int sfxId) { wolf3d.audio.playSoundEffect(sfxId);