diff --git a/.metadata b/.metadata index 89db5de..87ba2b3 100644 --- a/.metadata +++ b/.metadata @@ -15,24 +15,9 @@ migration: - platform: root create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: android - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: ios - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: linux - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: macos - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - platform: web create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: windows - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 # User provided section diff --git a/lib/features/difficulty/difficulty_screen.dart b/lib/features/difficulty/difficulty_screen.dart index c29c9f7..b8957a0 100644 --- a/lib/features/difficulty/difficulty_screen.dart +++ b/lib/features/difficulty/difficulty_screen.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter_soloud/flutter_soloud.dart'; import 'package:wolf_3d_data_types/wolf_3d_data_types.dart'; +import 'package:wolf_3d_synth/wolf_3d_synth.dart'; import 'package:wolf_dart/features/difficulty/difficulty.dart'; import 'package:wolf_dart/features/renderer/renderer.dart'; -class DifficultyScreen extends StatelessWidget { +class DifficultyScreen extends StatefulWidget { const DifficultyScreen( this.data, { super.key, @@ -11,7 +13,86 @@ class DifficultyScreen extends StatelessWidget { final WolfensteinData data; - bool get isShareware => data.version == GameVersion.shareware; + @override + State createState() => _DifficultyScreenState(); +} + +class _DifficultyScreenState extends State { + AudioSource? _menuMusicSource; + SoundHandle? _menuMusicHandle; + + bool get isShareware => widget.data.version == GameVersion.shareware; + + @override + void initState() { + super.initState(); + _playMenuMusic(); + } + + Future _playMenuMusic() async { + final soloud = SoLoud.instance; + + if (!soloud.isInitialized) { + return; + } + + // 2. We only want to play music if the IMF data actually exists + if (widget.data.music.isNotEmpty) { + // Get the first track (usually the menu theme "Wondering About My Loved Ones") + final music = widget.data.music.first; + + // Render the hardware instructions into PCM and wrap in a WAV header + final pcmSamples = ImfRenderer.render(music); + + final wavBytes = ImfRenderer.createWavFile(pcmSamples); + + // 3. Load the bytes into SoLoud's memory + // The 'menu_theme.wav' string is just a dummy name to tell SoLoud it's dealing with a WAV format + _menuMusicSource = await soloud.loadMem('menu_theme.wav', wavBytes); + + // 4. Play the source and tell it to loop continuously! + _menuMusicHandle = await soloud.play( + _menuMusicSource!, + looping: true, + ); + } + } + + @override + void dispose() { + _cleanupAudio(); + super.dispose(); + } + + void _cleanupAudio() { + final soloud = SoLoud.instance; + + // Stop the playback + if (_menuMusicHandle != null) { + soloud.stop(_menuMusicHandle!); + } + + // Free the raw WAV data from C++ memory + if (_menuMusicSource != null) { + soloud.disposeSource(_menuMusicSource!); + } + } + + void _startGame(Difficulty difficulty, {bool showGallery = false}) { + // Stop the music and clear memory right before we push the new route + _cleanupAudio(); + + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => WolfRenderer( + widget.data, + difficulty: difficulty, + isShareware: isShareware, + showSpriteGallery: showGallery, + ), + ), + ); + } @override Widget build(BuildContext context) { @@ -19,18 +100,7 @@ class DifficultyScreen extends StatelessWidget { backgroundColor: Colors.black, floatingActionButton: FloatingActionButton( backgroundColor: Colors.red[900], - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => WolfRenderer( - data, - difficulty: Difficulty.bringEmOn, - isShareware: isShareware, - showSpriteGallery: true, - ), - ), - ); - }, + onPressed: () => _startGame(Difficulty.bringEmOn, showGallery: true), child: const Icon(Icons.bug_report, color: Colors.white), ), body: Center( @@ -46,6 +116,7 @@ class DifficultyScreen extends StatelessWidget { fontFamily: 'Courier', ), ), + const SizedBox(height: 40), // --- Difficulty Buttons --- ListView.builder( @@ -64,17 +135,7 @@ class DifficultyScreen extends StatelessWidget { borderRadius: BorderRadius.circular(4), ), ), - onPressed: () { - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => WolfRenderer( - data, - difficulty: difficulty, - isShareware: isShareware, - ), - ), - ); - }, + onPressed: () => _startGame(difficulty), child: Text( difficulty.title, style: const TextStyle(fontSize: 18), diff --git a/lib/main.dart b/lib/main.dart index 53d3dd7..29d4e7f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter_soloud/flutter_soloud.dart'; import 'package:wolf_dart/game_select_screen.dart'; -void main() { +void main() async { + await SoLoud.instance.init( + sampleRate: 44100, // Audio quality + bufferSize: 2048, // Buffer size affects latency + channels: Channels.stereo, + ); + runApp( const MaterialApp( home: GameSelectScreen(), diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..6d02997 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_soloud ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/wolf_3d_data/lib/src/wl_parser.dart b/packages/wolf_3d_data/lib/src/wl_parser.dart index cb2883a..e1c36ad 100644 --- a/packages/wolf_3d_data/lib/src/wl_parser.dart +++ b/packages/wolf_3d_data/lib/src/wl_parser.dart @@ -62,7 +62,7 @@ abstract class WLParser { }) { final isShareware = version == GameVersion.shareware; - final audio = parseAudio(audioHed, audioT); + final audio = parseAudio(audioHed, audioT, version); return WolfensteinData( version: version, @@ -295,9 +295,9 @@ abstract class WLParser { static ({List adLib, List music}) parseAudio( ByteData audioHed, ByteData audioT, + GameVersion version, ) { List offsets = []; - // AUDIOHED is a series of 32-bit unsigned integers for (int i = 0; i < audioHed.lengthInBytes ~/ 4; i++) { offsets.add(audioHed.getUint32(i * 4, Endian.little)); } @@ -308,7 +308,6 @@ abstract class WLParser { int start = offsets[i]; int next = offsets[i + 1]; - // 0xFFFFFFFF (or 4294967295) marks an empty slot if (start == 0xFFFFFFFF || start >= audioT.lengthInBytes) { allAudioChunks.add(Uint8List(0)); continue; @@ -324,16 +323,18 @@ abstract class WLParser { } } - // Wolfenstein 3D split: - // Chunks 0-299: AdLib Sounds - // Chunks 300+: IMF Music + // --- THE FIX: Accurate Historical Chunk Indices --- + // WL1 (Shareware) has exactly 234 sound effects before the music. + // WL6 (Retail) has exactly 261 sound effects before the music. + int musicStartIndex = (version == GameVersion.shareware) ? 234 : 261; + List adLib = allAudioChunks - .take(300) + .take(musicStartIndex) .map((bytes) => AdLibSound(bytes)) .toList(); List music = allAudioChunks - .skip(300) + .skip(musicStartIndex) .where((chunk) => chunk.isNotEmpty) .map((bytes) => ImfMusic.fromBytes(bytes)) .toList(); diff --git a/packages/wolf_3d_data_types/lib/src/sound.dart b/packages/wolf_3d_data_types/lib/src/sound.dart index da124e6..b66b438 100644 --- a/packages/wolf_3d_data_types/lib/src/sound.dart +++ b/packages/wolf_3d_data_types/lib/src/sound.dart @@ -30,13 +30,19 @@ class ImfMusic { factory ImfMusic.fromBytes(Uint8List bytes) { List instructions = []; - // Read the file in 4-byte chunks - for (int i = 0; i < bytes.length - 3; i += 4) { + // Wolfenstein 3D IMF chunks start with a 16-bit length header (little-endian) + int actualSize = bytes[0] | (bytes[1] << 8); + + // Start parsing at index 2 to skip the size header + int limit = 2 + actualSize; + if (limit > bytes.length) limit = bytes.length; // Safety bounds + + for (int i = 2; i < limit - 3; i += 4) { instructions.add( ImfInstruction( register: bytes[i], data: bytes[i + 1], - delay: bytes[i + 2] | (bytes[i + 3] << 8), // 16-bit little-endian + delay: bytes[i + 2] | (bytes[i + 3] << 8), ), ); } diff --git a/packages/wolf_3d_synth/lib/src/imf_renderer.dart b/packages/wolf_3d_synth/lib/src/imf_renderer.dart new file mode 100644 index 0000000..1278700 --- /dev/null +++ b/packages/wolf_3d_synth/lib/src/imf_renderer.dart @@ -0,0 +1,91 @@ +import 'dart:typed_data'; + +import 'package:wolf_3d_data_types/wolf_3d_data_types.dart'; + +import 'opl2_emulator.dart'; + +class ImfRenderer { + /// Renders an ImfMusic track into raw 16-bit PCM samples + static Int16List render(ImfMusic music) { + final emulator = Opl2Emulator(); + + const double samplesPerTick = Opl2Emulator.sampleRate / 700.0; + + // 1. Pre-calculate the total number of audio samples we need + int totalTicks = 0; + for (final inst in music.instructions) { + totalTicks += inst.delay; + } + int totalSamples = (totalTicks * samplesPerTick).round(); + + // 2. Pre-allocate a fixed-size buffer (blazing fast!) + final pcmBuffer = Int16List(totalSamples); + int sampleIndex = 0; + + for (final instruction in music.instructions) { + // Write the instruction to the chip + emulator.writeRegister(instruction.register, instruction.data); + + // Generate the samples for the delay period + int samplesToGenerate = (instruction.delay * samplesPerTick).round(); + + for (int i = 0; i < samplesToGenerate; i++) { + if (sampleIndex >= pcmBuffer.length) break; // Safety bounds + + double sampleFloat = emulator.generateSample(); + int sampleInt = (sampleFloat * 32767).toInt().clamp(-32768, 32767); + + pcmBuffer[sampleIndex++] = sampleInt; + } + } + + return pcmBuffer; + } + + /// Wraps raw 16-bit PCM data in a standard WAV file header + static Uint8List createWavFile(Int16List pcmData) { + int byteRate = Opl2Emulator.sampleRate * 2; // 1 channel * 16-bit (2 bytes) + int dataSize = pcmData.length * 2; + int fileSize = 36 + dataSize; + + final bytes = BytesBuilder(); + + // "RIFF" chunk descriptor + bytes.add('RIFF'.codeUnits); + bytes.add(_int32ToBytes(fileSize)); + bytes.add('WAVE'.codeUnits); + + // "fmt " sub-chunk + bytes.add('fmt '.codeUnits); + bytes.add(_int32ToBytes(16)); // Subchunk1Size (16 for PCM) + bytes.add(_int16ToBytes(1)); // AudioFormat (1 = PCM) + bytes.add(_int16ToBytes(1)); // NumChannels (1 = Mono) + bytes.add(_int32ToBytes(Opl2Emulator.sampleRate)); // SampleRate + bytes.add(_int32ToBytes(byteRate)); // ByteRate + bytes.add(_int16ToBytes(2)); // BlockAlign + bytes.add(_int16ToBytes(16)); // BitsPerSample + + // "data" sub-chunk + bytes.add('data'.codeUnits); + bytes.add(_int32ToBytes(dataSize)); + + // Append the actual raw audio data + for (int sample in pcmData) { + bytes.add(_int16ToBytes(sample)); + } + + return bytes.toBytes(); + } + + static List _int16ToBytes(int value) => [ + value & 0xff, + (value >> 8) & 0xff, + ]; + + static List _int32ToBytes(int value) => [ + value & 0xff, + (value >> 8) & 0xff, + (value >> 16) & 0xff, + (value >> 24) & 0xff, + ]; +} diff --git a/packages/wolf_3d_synth/lib/src/opl2_emulator.dart b/packages/wolf_3d_synth/lib/src/opl2_emulator.dart new file mode 100644 index 0000000..2291954 --- /dev/null +++ b/packages/wolf_3d_synth/lib/src/opl2_emulator.dart @@ -0,0 +1,86 @@ +import 'dart:math' as math; + +class Opl2Channel { + int fNum = 0; + int block = 0; + bool keyOn = false; + + double phase = 0.0; + double phaseIncrement = 0.0; + + void updateFrequency() { + // The magic Yamaha YM3812 frequency formula! + // 49716Hz is the internal sample rate of the original 1980s silicon. + double realFreq = (fNum * math.pow(2, block)) * (49716.0 / 1048576.0); + + // Convert that physical frequency into a phase step for our modern 44100Hz output + phaseIncrement = (realFreq * 2 * math.pi) / Opl2Emulator.sampleRate; + } + + double getSample() { + // If the note isn't being pressed, return silence + if (!keyOn) return 0.0; + + // Generate the raw sine wave output + double out = math.sin(phase); + + // Advance the phase for the next frame + phase += phaseIncrement; + if (phase >= 2 * math.pi) { + phase -= 2 * math.pi; + } + + // Scale the volume down significantly. If 9 channels play at 1.0 volume, + // the audio will severely clip and distort. + return out * 0.1; + } +} + +class Opl2Emulator { + static const int sampleRate = 44100; + + // The 9 independent audio channels + final List channels = List.generate(9, (_) => Opl2Channel()); + + void writeRegister(int reg, int data) { + // --- 0xA0 - 0xA8: F-Number (Lower 8 bits) --- + if (reg >= 0xA0 && reg <= 0xA8) { + int channelIdx = reg - 0xA0; + + // Keep the upper 2 bits intact, replace the lower 8 bits + channels[channelIdx].fNum = (channels[channelIdx].fNum & 0x300) | data; + channels[channelIdx].updateFrequency(); + } + // --- 0xB0 - 0xB8: Key-On, Block, F-Number (Upper 2 bits) --- + else if (reg >= 0xB0 && reg <= 0xB8) { + int channelIdx = reg - 0xB0; + Opl2Channel channel = channels[channelIdx]; + + // Extract the bits using bitwise masks + channel.keyOn = (data & 0x20) != 0; // Bit 5 + channel.block = (data & 0x1C) >> 2; // Bits 2-4 + int fNumHigh = (data & 0x03) << 8; // Bits 0-1 + + // Keep the lower 8 bits intact, replace the upper 2 bits + channel.fNum = (channel.fNum & 0xFF) | fNumHigh; + channel.updateFrequency(); + + // When a new note is struck, we reset the phase counter to 0 + if (channel.keyOn) { + channel.phase = 0.0; + } + } + } + + /// Mixes the 9 channels down into a single PCM audio sample + double generateSample() { + double mixedOutput = 0.0; + + for (var channel in channels) { + mixedOutput += channel.getSample(); + } + + // Hard limiter to prevent audio popping if the mix exceeds 1.0 + return mixedOutput.clamp(-1.0, 1.0); + } +} diff --git a/packages/wolf_3d_synth/lib/src/wolf_3d_synth_base.dart b/packages/wolf_3d_synth/lib/src/wolf_3d_synth_base.dart deleted file mode 100644 index e8a6f15..0000000 --- a/packages/wolf_3d_synth/lib/src/wolf_3d_synth_base.dart +++ /dev/null @@ -1,6 +0,0 @@ -// TODO: Put public facing types in this file. - -/// Checks if you are awesome. Spoiler: you are. -class Awesome { - bool get isAwesome => true; -} diff --git a/packages/wolf_3d_synth/lib/wolf_3d_synth.dart b/packages/wolf_3d_synth/lib/wolf_3d_synth.dart index 8fc6887..5c15b0c 100644 --- a/packages/wolf_3d_synth/lib/wolf_3d_synth.dart +++ b/packages/wolf_3d_synth/lib/wolf_3d_synth.dart @@ -3,6 +3,4 @@ /// More dartdocs go here. library; -export 'src/wolf_3d_synth_base.dart'; - -// TODO: Export any libraries intended for clients of this package. +export 'src/imf_renderer.dart' show ImfRenderer; diff --git a/packages/wolf_3d_synth/pubspec.yaml b/packages/wolf_3d_synth/pubspec.yaml index 09eb946..02ef364 100644 --- a/packages/wolf_3d_synth/pubspec.yaml +++ b/packages/wolf_3d_synth/pubspec.yaml @@ -8,6 +8,9 @@ environment: resolution: workspace +dependencies: + wolf_3d_data_types: + dev_dependencies: lints: ^6.0.0 test: ^1.25.6 diff --git a/pubspec.yaml b/pubspec.yaml index 7773b71..aa48c24 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,8 +9,10 @@ environment: dependencies: wolf_3d_data: any wolf_3d_data_types: any + wolf_3d_synth: any flutter: sdk: flutter + flutter_soloud: ^3.5.1 dev_dependencies: flutter_test: @@ -24,4 +26,6 @@ flutter: - assets/shareware/ workspace: - - packages/* + - packages/wolf_3d_data/ + - packages/wolf_3d_data_types/ + - packages/wolf_3d_synth/ diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..2c0e7b3 --- /dev/null +++ b/web/index.html @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + wolf_dart + + + + + + + + + + + \ No newline at end of file diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..bea0113 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "wolf_dart", + "short_name": "wolf_dart", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}