diff --git a/assets/AUDIOHED.WL6 b/assets/retail/AUDIOHED.WL6 similarity index 100% rename from assets/AUDIOHED.WL6 rename to assets/retail/AUDIOHED.WL6 diff --git a/assets/AUDIOT.WL6 b/assets/retail/AUDIOT.WL6 similarity index 100% rename from assets/AUDIOT.WL6 rename to assets/retail/AUDIOT.WL6 diff --git a/assets/CONFIG.WL6 b/assets/retail/CONFIG.WL6 similarity index 100% rename from assets/CONFIG.WL6 rename to assets/retail/CONFIG.WL6 diff --git a/assets/GAMEMAPS.WL6 b/assets/retail/GAMEMAPS.WL6 similarity index 100% rename from assets/GAMEMAPS.WL6 rename to assets/retail/GAMEMAPS.WL6 diff --git a/assets/MAPHEAD.WL6 b/assets/retail/MAPHEAD.WL6 similarity index 100% rename from assets/MAPHEAD.WL6 rename to assets/retail/MAPHEAD.WL6 diff --git a/assets/SAVEGAM0.WL6 b/assets/retail/SAVEGAM0.WL6 similarity index 100% rename from assets/SAVEGAM0.WL6 rename to assets/retail/SAVEGAM0.WL6 diff --git a/assets/VGADICT.WL6 b/assets/retail/VGADICT.WL6 similarity index 100% rename from assets/VGADICT.WL6 rename to assets/retail/VGADICT.WL6 diff --git a/assets/VGAGRAPH.WL6 b/assets/retail/VGAGRAPH.WL6 similarity index 100% rename from assets/VGAGRAPH.WL6 rename to assets/retail/VGAGRAPH.WL6 diff --git a/assets/VGAHEAD.WL6 b/assets/retail/VGAHEAD.WL6 similarity index 100% rename from assets/VGAHEAD.WL6 rename to assets/retail/VGAHEAD.WL6 diff --git a/assets/VSWAP.WL6 b/assets/retail/VSWAP.WL6 similarity index 100% rename from assets/VSWAP.WL6 rename to assets/retail/VSWAP.WL6 diff --git a/assets/shareware/AUDIOHED.WL1 b/assets/shareware/AUDIOHED.WL1 new file mode 100644 index 0000000..90eb8c7 Binary files /dev/null and b/assets/shareware/AUDIOHED.WL1 differ diff --git a/assets/shareware/AUDIOT.WL1 b/assets/shareware/AUDIOT.WL1 new file mode 100644 index 0000000..5d84d11 Binary files /dev/null and b/assets/shareware/AUDIOT.WL1 differ diff --git a/assets/shareware/CONFIG.WL1 b/assets/shareware/CONFIG.WL1 new file mode 100644 index 0000000..dad6ee6 Binary files /dev/null and b/assets/shareware/CONFIG.WL1 differ diff --git a/assets/GAMEMAPS.WL1 b/assets/shareware/GAMEMAPS.WL1 similarity index 100% rename from assets/GAMEMAPS.WL1 rename to assets/shareware/GAMEMAPS.WL1 diff --git a/assets/MAPHEAD.WL1 b/assets/shareware/MAPHEAD.WL1 similarity index 100% rename from assets/MAPHEAD.WL1 rename to assets/shareware/MAPHEAD.WL1 diff --git a/assets/shareware/VGADICT.WL1 b/assets/shareware/VGADICT.WL1 new file mode 100644 index 0000000..8bddb72 Binary files /dev/null and b/assets/shareware/VGADICT.WL1 differ diff --git a/assets/shareware/VGAGRAPH.WL1 b/assets/shareware/VGAGRAPH.WL1 new file mode 100644 index 0000000..60046ec Binary files /dev/null and b/assets/shareware/VGAGRAPH.WL1 differ diff --git a/assets/shareware/VGAHEAD.WL1 b/assets/shareware/VGAHEAD.WL1 new file mode 100644 index 0000000..2a4ad0b Binary files /dev/null and b/assets/shareware/VGAHEAD.WL1 differ diff --git a/assets/VSWAP.WL1 b/assets/shareware/VSWAP.WL1 similarity index 100% rename from assets/VSWAP.WL1 rename to assets/shareware/VSWAP.WL1 diff --git a/lib/features/map/door.dart b/lib/features/entities/door.dart similarity index 100% rename from lib/features/map/door.dart rename to lib/features/entities/door.dart diff --git a/lib/features/entities/door_manager.dart b/lib/features/entities/door_manager.dart index 34ead83..4466a16 100644 --- a/lib/features/entities/door_manager.dart +++ b/lib/features/entities/door_manager.dart @@ -1,7 +1,7 @@ import 'dart:math' as math; import 'package:wolf_3d_data/wolf_3d_data.dart'; -import 'package:wolf_dart/features/map/door.dart'; +import 'package:wolf_dart/features/entities/door.dart'; class DoorManager { // Key is '$x,$y' diff --git a/lib/features/map/wolf_map.dart b/lib/features/map/wolf_map.dart deleted file mode 100644 index bef3d60..0000000 --- a/lib/features/map/wolf_map.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:wolf_3d_data/wolf_3d_data.dart'; - -class WolfMap { - /// The fully parsed and decompressed levels from the game files. - final List levels; - final List textures; - final List sprites; - - // A private constructor so we can only instantiate this from the async loader - WolfMap._( - this.levels, - this.textures, - this.sprites, - ); - - /// Asynchronously loads the map files and parses them into a new WolfMap instance. - static Future loadShareware() async { - // 1. Load the binary data - final mapHead = await rootBundle.load("assets/MAPHEAD.WL1"); - final gameMaps = await rootBundle.load("assets/GAMEMAPS.WL1"); - final vswap = await rootBundle.load("assets/VSWAP.WL1"); - - // 2. Parse the data using the parser we just built - final parsedLevels = WLParser.parseMaps( - mapHead, - gameMaps, - isShareware: true, - ); - final parsedTextures = WLParser.parseWalls(vswap); - final parsedSprites = WLParser.parseSprites(vswap); - - // 3. Return the populated instance! - return WolfMap._( - parsedLevels, - parsedTextures, - parsedSprites, - ); - } - - /// Asynchronously loads the map files and parses them into a new WolfMap instance. - static Future loadRetail() async { - // 1. Load the binary data - final mapHead = await rootBundle.load("assets/MAPHEAD.WL6"); - final gameMaps = await rootBundle.load("assets/GAMEMAPS.WL6"); - final vswap = await rootBundle.load("assets/VSWAP.WL6"); - - // 2. Parse the data using the parser we just built - final parsedLevels = WLParser.parseMaps(mapHead, gameMaps); - final parsedTextures = WLParser.parseWalls(vswap); - final parsedSprites = WLParser.parseSprites(vswap); - - // 3. Return the populated instance! - return WolfMap._( - parsedLevels, - parsedTextures, - parsedSprites, - ); - } -} diff --git a/lib/features/renderer/renderer.dart b/lib/features/renderer/renderer.dart index 4f54bec..8a9a941 100644 --- a/lib/features/renderer/renderer.dart +++ b/lib/features/renderer/renderer.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; import 'package:wolf_3d_data/wolf_3d_data.dart'; import 'package:wolf_dart/classes/coordinate_2d.dart'; import 'package:wolf_dart/features/difficulty/difficulty.dart'; @@ -13,7 +14,6 @@ import 'package:wolf_dart/features/entities/entity_registry.dart'; import 'package:wolf_dart/features/entities/map_objects.dart'; import 'package:wolf_dart/features/entities/pushwall_manager.dart'; import 'package:wolf_dart/features/input/input_manager.dart'; -import 'package:wolf_dart/features/map/wolf_map.dart'; import 'package:wolf_dart/features/player/player.dart'; import 'package:wolf_dart/features/renderer/raycast_painter.dart'; import 'package:wolf_dart/features/renderer/weapon_painter.dart'; @@ -44,8 +44,9 @@ class _WolfRendererState extends State late Ticker _gameLoop; final FocusNode _focusNode = FocusNode(); - late WolfMap gameMap; + late WolfensteinData gameData; late Level currentLevel; + late WolfLevel activeLevel; final double fov = math.pi / 3; @@ -64,15 +65,22 @@ class _WolfRendererState extends State } Future _initGame(bool isShareware) async { - gameMap = isShareware - ? await WolfMap.loadShareware() - : await WolfMap.loadRetail(); - currentLevel = gameMap.levels[0].wallGrid; + gameData = await WLParser.loadAsync( + (filename) => rootBundle.load('assets/retail/$filename'), + ); + + print('Detected Game Version: ${gameData.version.name}'); + print('Loaded ${gameData.levels.length} levels!'); + print('Loaded ${gameData.vgaImages.length} images!'); + + // Get the first level out of the data class + activeLevel = gameData.levels.first; + + // Set up your grids directly from the active level + currentLevel = activeLevel.wallGrid; + final Level objectLevel = activeLevel.objectGrid; doorManager.initDoors(currentLevel); - - final Level objectLevel = gameMap.levels[0].objectGrid; - pushwallManager.initPushwalls(currentLevel, objectLevel); for (int y = 0; y < 64; y++) { @@ -108,7 +116,7 @@ class _WolfRendererState extends State x + 0.5, y + 0.5, widget.difficulty, - gameMap.sprites.length, + gameData.sprites.length, isSharewareMode: isShareware, ); @@ -354,7 +362,7 @@ class _WolfRendererState extends State entity.x, entity.y, widget.difficulty, - gameMap.sprites.length, + gameData.sprites.length, ); if (droppedAmmo != null) { @@ -403,7 +411,7 @@ class _WolfRendererState extends State } if (widget.showSpriteGallery) { - return SpriteGallery(sprites: gameMap.sprites); + return SpriteGallery(sprites: gameData.sprites); } return Scaffold( @@ -429,12 +437,12 @@ class _WolfRendererState extends State ), painter: RaycasterPainter( map: currentLevel, - textures: gameMap.textures, + textures: gameData.walls, player: player, fov: fov, doorOffsets: doorManager.getOffsetsForRenderer(), entities: entities, - sprites: gameMap.sprites, + sprites: gameData.sprites, activePushwall: pushwallManager.activePushwall, ), ), @@ -451,9 +459,9 @@ class _WolfRendererState extends State child: CustomPaint( painter: WeaponPainter( sprite: - gameMap.sprites[player.currentWeapon + gameData.sprites[player.currentWeapon .getCurrentSpriteIndex( - gameMap.sprites.length, + gameData.sprites.length, )], ), ), diff --git a/packages/wolf_3d_data/lib/src/classes/game_version.dart b/packages/wolf_3d_data/lib/src/classes/game_version.dart index ba1b2d1..d108ba8 100644 --- a/packages/wolf_3d_data/lib/src/classes/game_version.dart +++ b/packages/wolf_3d_data/lib/src/classes/game_version.dart @@ -1,8 +1,8 @@ enum GameVersion { shareware("WL1"), retail("WL6"), - spearOfDestinyDemo("SDM"), spearOfDestiny("SOD"), + spearOfDestinyDemo("SDM"), ; final String fileExtension; diff --git a/packages/wolf_3d_data/lib/src/classes/image.dart b/packages/wolf_3d_data/lib/src/classes/image.dart new file mode 100644 index 0000000..cfb36ed --- /dev/null +++ b/packages/wolf_3d_data/lib/src/classes/image.dart @@ -0,0 +1,13 @@ +import 'dart:typed_data'; + +class VgaImage { + final int width; + final int height; + final Uint8List pixels; // 8-bit paletted pixel data + + VgaImage({ + required this.width, + required this.height, + required this.pixels, + }); +} diff --git a/packages/wolf_3d_data/lib/src/classes/sound.dart b/packages/wolf_3d_data/lib/src/classes/sound.dart new file mode 100644 index 0000000..8aae77d --- /dev/null +++ b/packages/wolf_3d_data/lib/src/classes/sound.dart @@ -0,0 +1,6 @@ +import 'dart:typed_data'; + +class PcmSound { + final Uint8List bytes; + PcmSound(this.bytes); +} diff --git a/packages/wolf_3d_data/lib/src/classes/wolfenstein_data.dart b/packages/wolf_3d_data/lib/src/classes/wolfenstein_data.dart index 8e3e0a3..b0fbd8d 100644 --- a/packages/wolf_3d_data/lib/src/classes/wolfenstein_data.dart +++ b/packages/wolf_3d_data/lib/src/classes/wolfenstein_data.dart @@ -1,81 +1,19 @@ -import 'dart:typed_data'; - -import 'package:wolf_3d_data/src/wl_parser.dart'; - -import 'game_version.dart'; -import 'sprite.dart'; -import 'wolf_level.dart'; +import 'package:wolf_3d_data/wolf_3d_data.dart'; class WolfensteinData { final GameVersion version; + final List walls; + final List sprites; + final List sounds; + final List levels; + final List vgaImages; - // Raw file data references - final ByteData _vswap; - final ByteData _mapHead; - final ByteData _gameMaps; - - // Backing fields for lazy loading - List? _walls; - List? _sprites; - List? _levels; - - WolfensteinData._({ + const WolfensteinData({ required this.version, - required ByteData vswap, - required ByteData mapHead, - required ByteData gameMaps, - }) : _vswap = vswap, - _mapHead = mapHead, - _gameMaps = gameMaps; - - /// Initializes the data from a map of filenames to their byte contents. - /// Automatically detects the game version from the file extensions. - factory WolfensteinData.fromFiles(Map files) { - if (files.isEmpty) throw ArgumentError('File map cannot be empty'); - - // 1. Detect Game Version from the first file's extension - final sampleFilename = files.keys.first.toUpperCase(); - final extension = sampleFilename.split('.').last; - - final version = GameVersion.values.firstWhere( - (v) => v.fileExtension == extension, - orElse: () => - throw FormatException('Unsupported file extension: $extension'), - ); - - // 2. Extract the required files using the detected extension - final vswap = _getFile(files, 'VSWAP.$extension'); - final mapHead = _getFile(files, 'MAPHEAD.$extension'); - final gameMaps = _getFile(files, 'GAMEMAPS.$extension'); - - return WolfensteinData._( - version: version, - vswap: vswap, - mapHead: mapHead, - gameMaps: gameMaps, - ); - } - - // --- Lazy Getters --- - - List get walls => _walls ??= WLParser.parseWalls(_vswap); - - List get sprites => _sprites ??= WLParser.parseSprites(_vswap); - - List get levels => _levels ??= WLParser.parseMaps( - _mapHead, - _gameMaps, - isShareware: version == GameVersion.shareware, - ); - - // --- Helpers --- - - static ByteData _getFile(Map files, String name) { - // Case-insensitive lookup - final key = files.keys.firstWhere( - (k) => k.toUpperCase() == name.toUpperCase(), - orElse: () => throw FormatException('Missing required file: $name'), - ); - return files[key]!; - } + required this.walls, + required this.sprites, + required this.sounds, + required this.levels, + required this.vgaImages, + }); } diff --git a/packages/wolf_3d_data/lib/src/wl_parser.dart b/packages/wolf_3d_data/lib/src/wl_parser.dart index 289b5bf..bdb2a07 100644 --- a/packages/wolf_3d_data/lib/src/wl_parser.dart +++ b/packages/wolf_3d_data/lib/src/wl_parser.dart @@ -1,11 +1,79 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:wolf_3d_data/src/classes/game_version.dart'; +import 'package:wolf_3d_data/src/classes/image.dart'; +import 'package:wolf_3d_data/src/classes/sound.dart'; import 'package:wolf_3d_data/src/classes/wolf_level.dart'; +import 'package:wolf_3d_data/src/classes/wolfenstein_data.dart'; import 'classes/sprite.dart'; -class WLParser { +abstract class WLParser { + /// Asynchronously discovers the game version and loads all necessary files. + /// Provide a [fileFetcher] callback (e.g., Flutter's rootBundle.load) that + /// takes a filename and returns its ByteData. + static Future loadAsync( + Future Function(String filename) fileFetcher, + ) async { + GameVersion? detectedVersion; + ByteData? vswap; + + // 1. Probe the data source to figure out which version we have + for (final version in GameVersion.values) { + try { + vswap = await fileFetcher('VSWAP.${version.fileExtension}'); + detectedVersion = version; + break; // We found the version! + } catch (_) { + // File wasn't found, try the next version extension + } + } + + if (detectedVersion == null || vswap == null) { + throw Exception( + 'Could not locate a valid VSWAP file for any game version.', + ); + } + + final ext = detectedVersion.fileExtension; + + // 2. Now that we know the version, confidently load the rest of the files + return load( + version: detectedVersion, + vswap: vswap, + mapHead: await fileFetcher('MAPHEAD.$ext'), + gameMaps: await fileFetcher('GAMEMAPS.$ext'), + vgaDict: await fileFetcher('VGADICT.$ext'), + vgaHead: await fileFetcher('VGAHEAD.$ext'), + vgaGraph: await fileFetcher('VGAGRAPH.$ext'), + ); + } + + /// Parses all raw ByteData upfront and returns a fully populated + /// WolfensteinData object. By using named parameters, the compiler + /// guarantees no files are missing or misnamed. + static WolfensteinData load({ + required GameVersion version, + required ByteData vswap, + required ByteData mapHead, + required ByteData gameMaps, + required ByteData vgaDict, + required ByteData vgaHead, + required ByteData vgaGraph, + }) { + final isShareware = version == GameVersion.shareware; + + return WolfensteinData( + version: version, + walls: parseWalls(vswap), + sprites: parseSprites(vswap), + sounds: parseSounds(vswap).map((bytes) => PcmSound(bytes)).toList(), + levels: parseMaps(mapHead, gameMaps, isShareware: isShareware), + vgaImages: parseVgaImages(vgaDict, vgaHead, vgaGraph), + ); + } + /// Extracts the 64x64 wall textures from VSWAP.WL1 static List parseWalls(ByteData vswap) { final header = _VswapHeader(vswap); @@ -114,6 +182,44 @@ class WLParser { } } + /// Extracts and decodes the UI graphics, Title Screens, and Menu items + static List parseVgaImages( + ByteData vgaDict, + ByteData vgaHead, + ByteData vgaGraph, + ) { + // 1. Get all raw decompressed chunks using the Huffman algorithm + List rawChunks = _parseVgaRaw(vgaDict, vgaHead, vgaGraph); + + // 2. Chunk 0 is the Picture Table (Dimensions for all images) + // It contains consecutive 16-bit widths and 16-bit heights + ByteData picTable = ByteData.sublistView(rawChunks[0]); + int numPics = picTable.lengthInBytes ~/ 4; + + List images = []; + + // 3. In Wolf3D, Chunk 1 and 2 are fonts. Pictures start at Chunk 3! + int picStartIndex = 3; + + for (int i = 0; i < numPics; i++) { + int width = picTable.getUint16(i * 4, Endian.little); + int height = picTable.getUint16(i * 4 + 2, Endian.little); + + // Safety check: ensure we don't read out of bounds + if (picStartIndex + i < rawChunks.length) { + images.add( + VgaImage( + width: width, + height: height, + pixels: rawChunks[picStartIndex + i], + ), + ); + } + } + + return images; + } + /// Parses MAPHEAD and GAMEMAPS to extract the raw level data. static List parseMaps( ByteData mapHead, @@ -260,6 +366,128 @@ class WLParser { } return rlewExpanded; } + + /// Extracts decompressed VGA data chunks (UI, Fonts, Pictures) + static List _parseVgaRaw( + ByteData vgaDict, + ByteData vgaHead, + ByteData vgaGraph, + ) { + List vgaChunks = []; + + // 1. Parse the Huffman Dictionary from VGADICT + List<_HuffmanNode> dict = []; + for (int i = 0; i < 255; i++) { + dict.add( + _HuffmanNode( + vgaDict.getUint16(i * 4, Endian.little), + vgaDict.getUint16(i * 4 + 2, Endian.little), + ), + ); + } + + // 2. Read VGAHEAD to get the offsets for VGAGRAPH + List offsets = []; + int numChunks = vgaHead.lengthInBytes ~/ 3; + int lastOffset = -1; + + for (int i = 0; i < numChunks; i++) { + int offset = + vgaHead.getUint8(i * 3) | + (vgaHead.getUint8(i * 3 + 1) << 8) | + (vgaHead.getUint8(i * 3 + 2) << 16); + + if (offset == 0x00FFFFFF) break; + + // --- SAFETY FIX --- + // 1. Offsets cannot point beyond the file length. + // 2. Offsets must not go backward (this means we hit the junk padding). + if (offset > vgaGraph.lengthInBytes || offset < lastOffset) { + break; + } + + offsets.add(offset); + lastOffset = offset; + } + + // 3. Decompress the chunks from VGAGRAPH + for (int i = 0; i < offsets.length; i++) { + int offset = offsets[i]; + + // --- BOUNDARY CHECK --- + // If the offset is exactly at the end of the file, or doesn't leave + // enough room for a 4-byte length header, it's an empty chunk. + if (offset + 4 > vgaGraph.lengthInBytes) { + vgaChunks.add(Uint8List(0)); + continue; + } + + // The first 4 bytes of a compressed chunk specify its decompressed size + int expandedLength = vgaGraph.getUint32(offset, Endian.little); + + if (expandedLength == 0) { + vgaChunks.add(Uint8List(0)); + continue; + } + + // Decompress starting immediately after the 4-byte length header. + // We pass the exact slice of bytes remaining so we don't read out of bounds. + Uint8List expandedData = _expandHuffman( + vgaGraph.buffer.asUint8List( + vgaGraph.offsetInBytes + offset + 4, + vgaGraph.lengthInBytes - (offset + 4), + ), + dict, + expandedLength, + ); + + vgaChunks.add(expandedData); + } + + return vgaChunks; + } + + // --- ALGORITHM 3: HUFFMAN EXPANSION --- + static Uint8List _expandHuffman( + Uint8List compressed, + List<_HuffmanNode> dict, + int expandedLength, + ) { + Uint8List expanded = Uint8List(expandedLength); + + int outIdx = 0; + int byteIdx = 0; + int bitMask = 1; + int currentNode = 254; // id Software's Huffman root is always node 254 + + while (outIdx < expandedLength && byteIdx < compressed.length) { + // Read the current bit (LSB to MSB) + int bit = (compressed[byteIdx] & bitMask) == 0 ? 0 : 1; + + // Advance to the next bit/byte + bitMask <<= 1; + if (bitMask > 128) { + bitMask = 1; + byteIdx++; + } + + // Traverse the tree + int nextVal = bit == 0 ? dict[currentNode].bit0 : dict[currentNode].bit1; + + if (nextVal < 256) { + // If the value is < 256, we've hit a leaf node (an actual character byte!) + expanded[outIdx++] = nextVal; + currentNode = + 254; // Reset to the root of the tree for the next character + } else { + // If the value is >= 256, it's a pointer to the next internal node. + // Node indexes are offset by 256. + currentNode = nextVal - 256; + } + } + + return expanded; + } } /// Helper class to parse and store redundant VSWAP header data @@ -278,3 +506,10 @@ class _VswapHeader { (i) => vswap.getUint32(6 + (i * 4), Endian.little), ); } + +class _HuffmanNode { + final int bit0; + final int bit1; + + _HuffmanNode(this.bit0, this.bit1); +} diff --git a/packages/wolf_3d_data/lib/wolf_3d_data.dart b/packages/wolf_3d_data/lib/wolf_3d_data.dart index 1d42f16..ec85972 100644 --- a/packages/wolf_3d_data/lib/wolf_3d_data.dart +++ b/packages/wolf_3d_data/lib/wolf_3d_data.dart @@ -4,6 +4,9 @@ library; export 'src/classes/game_version.dart' show GameVersion; +export 'src/classes/image.dart' show VgaImage; +export 'src/classes/sound.dart' show PcmSound; export 'src/classes/sprite.dart' hide Matrix; export 'src/classes/wolf_level.dart' show WolfLevel; +export 'src/classes/wolfenstein_data.dart' show WolfensteinData; export 'src/wl_parser.dart' show WLParser; diff --git a/pubspec.yaml b/pubspec.yaml index f61a7e9..69038ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,8 @@ dev_dependencies: flutter: uses-material-design: true assets: - - assets/ + - assets/retail/ + - assets/shareware/ workspace: - packages/wolf_3d_data