diff --git a/.gitignore b/.gitignore index 7c6eb58..c444754 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ **/build/ **/coverage/ **/flutter/ephemeral/ +**/generated_plugin* # Symbolication related **/app.*.symbols diff --git a/packages/wolf_3d_dart/lib/src/data/data_version.dart b/packages/wolf_3d_dart/lib/src/data/data_version.dart index 90941b5..0183d52 100644 --- a/packages/wolf_3d_dart/lib/src/data/data_version.dart +++ b/packages/wolf_3d_dart/lib/src/data/data_version.dart @@ -1,23 +1,31 @@ +/// Represents the specific version identity of the Wolfenstein 3D data files. +/// +/// Since files like 'VSWAP.WL6' exist across multiple releases (v1.0, v1.1, v1.4), +/// we use MD5 checksums of the VSWAP file to determine specific engine behaviors, +/// such as MAPTEMP support or HUD differences. enum DataVersion { - /// V1.0 Retail (VSWAP.WL6) + /// Original v1.0 Retail release. version10Retail('a6d901dfb455dfac96db5e4705837cdb'), - /// v1.1 Retail (VSWAP.WL6) + /// v1.1 Retail update. version11Retail('a80904e0283a921d88d977b56c279b9d'), - /// v1.4 Shareware (VSWAP.WL1) + /// v1.4 Shareware release (VSWAP.WL1). version14Shareware('6efa079414b817c97db779cecfb081c9'), - /// v1.4 Retail (VSWAP.WL6) - GOG/Steam version + /// v1.4 Retail release (found on GOG and Steam). version14Retail('b8ff4997461bafa5ef2a94c11f9de001'), - unknown('unknown'), + /// Default state if the file hash is unrecognized. + unknown('unknown') ; + /// The MD5 hash of the VSWAP file associated with this version. final String checksum; const DataVersion(this.checksum); + /// Matches a provided [hash] string to a known [DataVersion]. static DataVersion fromChecksum(String hash) { return DataVersion.values.firstWhere( (v) => v.checksum == hash, diff --git a/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart b/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart index b582912..9207884 100644 --- a/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart +++ b/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart @@ -37,7 +37,9 @@ Future> discoverInDirectory({ return fileName == expectedName; }).firstOrNull; - // v1.0 FIX: Search for MAPTEMP if GAMEMAPS is missing + // v1.0 FIX: In the very first retail release, the maps were named + // MAPTEMP.WL6 instead of GAMEMAPS.WL6. We check for this alias + // to ensure v1.0 data is properly discovered. if (match == null && requiredFile == GameFile.gameMaps) { final altName = 'MAPTEMP.$ext'; match = allFiles.where((file) { diff --git a/packages/wolf_3d_dart/lib/src/data/wl_parser.dart b/packages/wolf_3d_dart/lib/src/data/wl_parser.dart index ca20daf..4e2d845 100644 --- a/packages/wolf_3d_dart/lib/src/data/wl_parser.dart +++ b/packages/wolf_3d_dart/lib/src/data/wl_parser.dart @@ -5,6 +5,12 @@ import 'package:crypto/crypto.dart' show md5; import 'package:wolf_3d_dart/src/data/data_version.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +/// The primary parser for Wolfenstein 3D data formats. +/// +/// This abstract class serves as the extraction and decompression engine for +/// legacy id Software game assets. It processes raw byte streams into usable +/// objects (walls, sprites, audio, levels, and UI elements) using original +/// algorithms like Carmackization, RLEW, and Huffman expansion. abstract class WLParser { // --- Original Song Lookup Tables --- static const List _sharewareMusicMap = [ @@ -21,10 +27,11 @@ 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. - /// Asynchronously discovers the game version and loads all necessary files. - /// Asynchronously discovers the game version and loads all necessary files. + /// + /// Provide a [fileFetcher] callback (e.g., Flutter's `rootBundle.load` or + /// standard `File.readAsBytes`) that takes a filename string and returns + /// its [ByteData]. The parser will probe for the `VSWAP` file to determine + /// the game version and then fetch the corresponding data files. static Future loadAsync( Future Function(String filename) fileFetcher, ) async { @@ -92,9 +99,11 @@ abstract class WLParser { ); } - /// 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. + /// 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. + /// The [dataIdentity] parameter is used to apply version-specific bug fixes or + /// legacy parsing adjustments (e.g., HUD string differences in v1.0). static WolfensteinData load({ required GameVersion version, required ByteData vswap, @@ -128,7 +137,9 @@ abstract class WLParser { ); } - /// Extracts the 64x64 wall textures from VSWAP.WL1 + /// Extracts the 64x64 wall textures from the VSWAP file. + /// + /// Wall textures are stored sequentially at the beginning of the chunks array. static List parseWalls(ByteData vswap) { final header = _VswapHeader(vswap); @@ -139,7 +150,10 @@ abstract class WLParser { .toList(); } - /// Extracts the compiled scaled sprites from VSWAP.WL1 + /// Extracts the compiled scaled sprites from the VSWAP file. + /// + /// Sprites use a column-based "sparse" format to allow for transparency + /// and efficient vertical scaling in the raycaster. static List parseSprites(ByteData vswap) { final header = _VswapHeader(vswap); final sprites = []; @@ -155,7 +169,9 @@ abstract class WLParser { return sprites; } - /// Extracts digitized sound effects (PCM Audio) from VSWAP.WL1 + /// Extracts digitized sound effects (PCM Audio) from the VSWAP file. + /// + /// Sounds are stored as raw 8-bit PCM audio bytes. static List parseSounds(ByteData vswap) { final header = _VswapHeader(vswap); final lengthStart = 6 + (header.chunks * 4); @@ -235,7 +251,10 @@ abstract class WLParser { } } - /// Extracts and decodes the UI graphics, Title Screens, and Menu items + /// Extracts and decodes the UI graphics, Title Screens, and Menu items. + /// + /// The images are built using a dictionary (`VGADICT`), metadata (`VGAHEAD`), + /// and Huffman-compressed image chunks (`VGAGRAPH`). static List parseVgaImages( ByteData vgaDict, ByteData vgaHead, @@ -287,7 +306,10 @@ abstract class WLParser { "Episode 6\nConfrontation", ]; - /// Parses MAPHEAD and GAMEMAPS to extract the raw level data. + /// Parses `MAPHEAD` and `GAMEMAPS` to extract the raw level data. + /// + /// Level files undergo two layers of decompression (Carmack + RLEW). The map + /// data is then translated into 64x64 grids representing the walls and objects. static List parseEpisodes( ByteData mapHead, ByteData gameMaps, { @@ -400,6 +422,9 @@ abstract class WLParser { } /// Extracts AdLib sounds and IMF music tracks from the audio files. + /// + /// Splits the sequential chunks in `AUDIOT` based on the offsets provided + /// in `AUDIOHED`. Converts raw music bytes into structured [ImfMusic] objects. static ({List adLib, List music}) parseAudio( ByteData audioHed, ByteData audioT, @@ -452,40 +477,41 @@ abstract class WLParser { } // --- ALGORITHM 1: CARMACK EXPANSION --- + /// Expands data using the "Carmackization" near/far pointer compression. + /// + /// This is the first layer of decompression used for level data in GAMEMAPS. + /// It works similarly to LZSS, matching near (`0xA7`) and far (`0xA8`) pointers + /// to replay previously decompressed data words. static Uint16List _expandCarmack(Uint8List compressed) { ByteData data = ByteData.sublistView(compressed); - - // The first 16-bit word is the total length of the expanded data in BYTES. - int expandedLengthBytes = data.getUint16(0, Endian.little); - int expandedLengthWords = expandedLengthBytes ~/ 2; + int expandedLengthWords = data.getUint16(0, Endian.little) ~/ 2; Uint16List expanded = Uint16List(expandedLengthWords); - int inIdx = 2; // Skip the length word we just read + int inIdx = 2; // Word-based index int outIdx = 0; - while (outIdx < expandedLengthWords && inIdx < compressed.length) { + while (outIdx < expandedLengthWords) { int word = data.getUint16(inIdx, Endian.little); inIdx += 2; int highByte = word >> 8; int lowByte = word & 0xFF; - // 0xA7 and 0xA8 are the Carmack Pointer Tags + // Carmack pointers: 0xA7 (Near) and 0xA8 (Far) if (highByte == 0xA7 || highByte == 0xA8) { if (lowByte == 0) { - // Exception Rule: If the length (lowByte) is 0, it's not a pointer. - // It's literally just the tag byte followed by another byte. + // Edge case: if low byte is 0, it's a literal tag byte int nextByte = data.getUint8(inIdx++); expanded[outIdx++] = (nextByte << 8) | highByte; } else if (highByte == 0xA7) { - // 0xA7 = Near Pointer (look back a few spaces) + // Near pointer: Copy [lowByte] words from [offset] back in the output int offset = data.getUint8(inIdx++); int copyFrom = outIdx - offset; for (int i = 0; i < lowByte; i++) { expanded[outIdx++] = expanded[copyFrom++]; } - } else if (highByte == 0xA8) { - // 0xA8 = Far Pointer (absolute offset from the very beginning) + } else { + // Far pointer: Copy [lowByte] words from absolute [offset] in the output int offset = data.getUint16(inIdx, Endian.little); inIdx += 2; for (int i = 0; i < lowByte; i++) { @@ -493,7 +519,6 @@ abstract class WLParser { } } } else { - // Normal, uncompressed word expanded[outIdx++] = word; } } @@ -501,6 +526,10 @@ abstract class WLParser { } // --- ALGORITHM 2: RLEW EXPANSION --- + /// Expands data using Run-Length Encoding on Word boundaries. + /// + /// This is the second layer of decompression for level maps. It identifies a + /// special marker ([rlewTag]) and expands the subsequent count and value. static List _expandRlew(Uint16List carmackExpanded, int rlewTag) { // The first word is the expanded length in BYTES int expandedLengthBytes = carmackExpanded[0]; @@ -530,6 +559,9 @@ abstract class WLParser { } /// Extracts decompressed VGA data chunks (UI, Fonts, Pictures) + /// + /// Safely manages offsets and handles out-of-bounds safety checks when + /// reading from the `VGAGRAPH` blob. static List _parseVgaRaw( ByteData vgaDict, ByteData vgaHead, @@ -610,6 +642,11 @@ abstract class WLParser { } // --- ALGORITHM 3: HUFFMAN EXPANSION --- + /// Decompresses data using id Software's Huffman algorithm. + /// + /// Traverses a binary tree derived from `VGADICT`. Bits from the [compressed] + /// data stream dictate left/right traversal until a leaf node is found, at which + /// point the data byte is appended to the output buffer. static Uint8List _expandHuffman( Uint8List compressed, List<_HuffmanNode> dict, @@ -620,34 +657,31 @@ abstract class WLParser { int outIdx = 0; int byteIdx = 0; int bitMask = 1; - int currentNode = 254; // id Software's Huffman root is always node 254 + int currentNode = 254; // Root node is always 254 in id's implementation while (outIdx < expandedLength && byteIdx < compressed.length) { - // Read the current bit (LSB to MSB) + // Extract bits from Least Significant Bit (LSB) to Most (MSB) int bit = (compressed[byteIdx] & bitMask) == 0 ? 0 : 1; - // Advance to the next bit/byte + // Move to next bit/byte bitMask <<= 1; if (bitMask > 128) { bitMask = 1; byteIdx++; } - // Traverse the tree + // If bit is 0, take the left path; if 1, take the right path 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!) + // Found a leaf node: write the byte and reset to root expanded[outIdx++] = nextVal; - currentNode = - 254; // Reset to the root of the tree for the next character + currentNode = 254; } else { - // If the value is >= 256, it's a pointer to the next internal node. - // Node indexes are offset by 256. + // Internal node: pointer to the next branch (offset by 256) currentNode = nextVal - 256; } } - return expanded; } } diff --git a/packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart b/packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart index eeae4db..be1c379 100644 --- a/packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart +++ b/packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart @@ -9,8 +9,15 @@ import 'io/discovery_stub.dart' as platform; import 'wl_parser.dart'; +/// The main entry point for loading Wolfenstein 3D data. +/// +/// This class provides high-level methods to either scan a local filesystem +/// (on supported platforms) or load data directly from memory (Web/Flutter). class WolfensteinLoader { - /// Scans a directory for Wolfenstein 3D data files and loads all available versions. + /// Scans [directoryPath] for Wolfenstein data files. + /// + /// On Web, this will throw an [UnsupportedError]. On Desktop/Mobile, it will + /// attempt to identify and load all valid game versions found in the path. static Future> discover({ String? directoryPath, bool recursive = false, @@ -21,8 +28,11 @@ class WolfensteinLoader { ); } - /// Parses WolfensteinData from raw ByteData. - /// Throws an [ArgumentError] if any required file is null. + /// Manually loads game data from provided [ByteData] buffers. + /// + /// Use this for Web or Flutter assets where automated directory scanning + /// is not possible. It automatically detects the [DataVersion] (v1.0, v1.4, etc.) + /// by hashing the [vswap] buffer. static WolfensteinData loadFromBytes({ required GameVersion version, required ByteData? vswap, @@ -57,7 +67,7 @@ class WolfensteinLoader { ); } - // 2. Identify the DataVersion via Checksum + // 2. Identify identity for specialized parsing (e.g., MAPTEMP vs GAMEMAPS) final vswapBytes = vswap!.buffer.asUint8List( vswap.offsetInBytes, vswap.lengthInBytes, diff --git a/packages/wolf_3d_dart/lib/src/data_types/cardinal_direction.dart b/packages/wolf_3d_dart/lib/src/data_types/cardinal_direction.dart index b34eca4..a252c29 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/cardinal_direction.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/cardinal_direction.dart @@ -1,17 +1,34 @@ import 'dart:math' as math; +/// Represents the four primary compass directions used for movement and orientation. +/// +/// The [radians] values are aligned with standard 2D Cartesian coordinates where +/// East is 0.0 and rotations proceed clockwise. enum CardinalDirection { + /// 0 degrees (pointing Right) east(0.0), + + /// 90 degrees (pointing Down) south(math.pi / 2), + + /// 180 degrees (pointing Left) west(math.pi), + + /// 270 degrees (pointing Up) north(3 * math.pi / 2) ; + /// The rotation value in radians associated with this direction. final double radians; + const CardinalDirection(this.radians); - /// Helper to decode Wolf3D enemy directional blocks + /// Decodes Wolf3D enemy directional orientation from map tile indices. + /// + /// In the original Wolfenstein 3D map format, enemies are stored in + /// blocks where the specific index (modulo 4) determines their starting facing. static CardinalDirection fromEnemyIndex(int index) { + // The engine uses a specific pattern: 0=East, 1=North, 2=West, 3=South switch (index % 4) { case 0: return CardinalDirection.east; @@ -22,6 +39,7 @@ enum CardinalDirection { case 3: return CardinalDirection.south; default: + // Fallback safety, though mathematically unreachable with % 4 return CardinalDirection.east; } } diff --git a/packages/wolf_3d_dart/lib/src/data_types/color_palette.dart b/packages/wolf_3d_dart/lib/src/data_types/color_palette.dart index d0957c1..be08a71 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/color_palette.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/color_palette.dart @@ -1,6 +1,8 @@ import 'dart:typed_data'; +/// Provides the standard VGA color palette used by Wolfenstein 3D. abstract class ColorPalette { + /// The 256-color palette converted to 32-bit ARGB values for modern rendering. static final Uint32List vga32Bit = Uint32List.fromList([ 0xFF000000, 0xFFAA0000, diff --git a/packages/wolf_3d_dart/lib/src/data_types/coordinate_2d.dart b/packages/wolf_3d_dart/lib/src/data_types/coordinate_2d.dart index 6332f9d..9c6ed79 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/coordinate_2d.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/coordinate_2d.dart @@ -1,6 +1,8 @@ import 'dart:math' as math; /// A lightweight, immutable 2D Vector/Coordinate system. +/// +/// Used for entity positioning, raycasting calculations, and direction vectors. class Coordinate2D implements Comparable { final double x; final double y; @@ -8,13 +10,18 @@ class Coordinate2D implements Comparable { const Coordinate2D(this.x, this.y); /// Returns the angle in radians between this coordinate and [other]. - /// Useful for "Look At" logic or determining steering direction. + /// + /// Useful for "Look At" logic or determining steering direction for AI. /// Result is between -pi and pi. double angleTo(Coordinate2D other) { return math.atan2(other.y - y, other.x - x); } - /// Rotates the coordinate around (0,0) by [radians]. + /// Rotates the coordinate around the origin (0,0) by [radians]. + /// + /// This uses a standard rotation matrix: + /// x' = x*cos(θ) - y*sin(θ) + /// y' = x*sin(θ) + y*cos(θ) Coordinate2D rotate(double radians) { final cos = math.cos(radians); final sin = math.sin(radians); @@ -24,7 +31,9 @@ class Coordinate2D implements Comparable { ); } - /// Linear Interpolation: Slides between this and [target] by [t] (0.0 to 1.0). + /// Linear Interpolation (LERP): Slides between this and [target] by [t]. + /// + /// [t] should be a value between 0.0 and 1.0. /// Perfect for smooth camera follows or "lerping" an object to a new spot. Coordinate2D lerp(Coordinate2D target, double t) { return Coordinate2D( @@ -33,18 +42,24 @@ class Coordinate2D implements Comparable { ); } + /// Returns the length of the vector. double get magnitude => math.sqrt(x * x + y * y); + /// Returns a vector with the same direction but a magnitude of 1.0. Coordinate2D get normalized { final m = magnitude; if (m == 0) return const Coordinate2D(0, 0); return Coordinate2D(x / m, y / m); } + /// Calculates the Dot Product between this and [other]. double dot(Coordinate2D other) => (x * other.x) + (y * other.y); + /// Calculates the Euclidean distance between two points. double distanceTo(Coordinate2D other) => (this - other).magnitude; + // --- Operator Overloads for Vector Math --- + Coordinate2D operator +(Coordinate2D other) => Coordinate2D(x + other.x, y + other.y); Coordinate2D operator -(Coordinate2D other) => diff --git a/packages/wolf_3d_dart/lib/src/data_types/difficulty.dart b/packages/wolf_3d_dart/lib/src/data_types/difficulty.dart index 7a95eae..3f6fd75 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/difficulty.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/difficulty.dart @@ -1,10 +1,15 @@ +/// Defines the game difficulty levels, matching the original titles. enum Difficulty { canIPlayDaddy(0, "Can I play, Daddy?"), dontHurtMe(0, "Don't hurt me."), bringEmOn(1, "Bring em' on!"), - iAmDeathIncarnate(2, "I am Death incarnate!"); + iAmDeathIncarnate(2, "I am Death incarnate!"), + ; + /// The friendly string shown in menus. final String title; + + /// The numeric level used for map object filtering logic. final int level; const Difficulty(this.level, this.title); diff --git a/packages/wolf_3d_dart/lib/src/data_types/enemy_map_data.dart b/packages/wolf_3d_dart/lib/src/data_types/enemy_map_data.dart index c35839d..bc96d13 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/enemy_map_data.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/enemy_map_data.dart @@ -1,24 +1,36 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +/// Handles identity and difficulty-based logic for enemies in map data. +/// +/// In Wolf3D, each enemy type (Guard, SS, etc.) occupies a block of 36 IDs +/// in the map object layer to account for behavior, direction, and difficulty. class EnemyMapData { + /// The starting ID for this specific enemy type (e.g., 108 for Guards). final int baseId; const EnemyMapData(this.baseId); - /// True if the ID falls anywhere within this enemy's 36-ID block + /// Returns true if the provided [id] belongs to this enemy type's 36-ID block. bool claimsId(int id) => id >= baseId && id < baseId + 36; + /// Returns true if the ID represents a standing (static) version for the [difficulty]. bool isStaticForDifficulty(int id, Difficulty difficulty) { + // Standing enemies occupy the first 12 IDs (4 per difficulty level) int start = baseId + (difficulty.level * 4); return id >= start && id < start + 4; } + /// Returns true if the ID represents a patrolling version for the [difficulty]. bool isPatrolForDifficulty(int id, Difficulty difficulty) { + // Patrolling enemies occupy the next 12 IDs int start = baseId + 12 + (difficulty.level * 4); return id >= start && id < start + 4; } + /// Returns true if the ID represents an "Ambush" version for the [difficulty]. + /// Ambush enemies wait for the player to enter their line of sight before moving. bool isAmbushForDifficulty(int id, Difficulty difficulty) { + // Ambush enemies occupy the final 12 IDs of the block int start = baseId + 24 + (difficulty.level * 4); return id >= start && id < start + 4; } diff --git a/packages/wolf_3d_dart/lib/src/data_types/episode.dart b/packages/wolf_3d_dart/lib/src/data_types/episode.dart index 94866f5..0c69abd 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/episode.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/episode.dart @@ -1,7 +1,11 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +/// Represents a collection of levels grouped into an Episode. class Episode { + /// The display name (e.g., "Episode 1: Escape from Wolfenstein"). final String name; + + /// The list of levels associated with this episode. final List levels; const Episode({required this.name, required this.levels}); diff --git a/packages/wolf_3d_dart/lib/src/data_types/frame_buffer.dart b/packages/wolf_3d_dart/lib/src/data_types/frame_buffer.dart index ba7fc37..c6d7d25 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/frame_buffer.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/frame_buffer.dart @@ -1,19 +1,32 @@ import 'dart:typed_data'; +/// A 32-bit pixel buffer representing the raw screen output. +/// +/// This buffer is intended to be filled by the raycaster and then +/// pushed to the GPU (via Texture) or displayed on a Canvas. class FrameBuffer { + /// Screen width in pixels. final int width; + + /// Screen height in pixels. final int height; - // A 1D array representing the 2D screen. - // Length = width * height. + /// A 1D array representing the 2D screen in ABGR/RGBA format. + /// Length is always [width] * [height]. final Uint32List pixels; FrameBuffer(this.width, this.height) : pixels = Uint32List(width * height); - // Helper to clear the screen (e.g., draw ceiling and floor) + /// Clears the screen by painting the top half as the ceiling and the bottom half as the floor. + /// + /// [ceilingColor32] and [floorColor32] should be 32-bit hex colors (e.g., 0xFF383838). void clear(int ceilingColor32, int floorColor32) { int half = (width * height) ~/ 2; + + // Fill the ceiling (indices 0 to middle) pixels.fillRange(0, half, ceilingColor32); + + // Fill the floor (indices middle to end) pixels.fillRange(half, pixels.length, floorColor32); } } diff --git a/packages/wolf_3d_dart/lib/src/data_types/game_file.dart b/packages/wolf_3d_dart/lib/src/data_types/game_file.dart index be593f4..ec5fe2c 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/game_file.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/game_file.dart @@ -1,3 +1,4 @@ +/// Standard internal filenames for Wolfenstein 3D data components. enum GameFile { vswap('VSWAP'), mapHead('MAPHEAD'), @@ -6,9 +7,10 @@ enum GameFile { vgaHead('VGAHEAD'), vgaGraph('VGAGRAPH'), audioHed('AUDIOHED'), - audioT('AUDIOT'); + audioT('AUDIOT') + ; + /// The filename without the extension (e.g., 'VSWAP'). final String baseName; - const GameFile(this.baseName); } diff --git a/packages/wolf_3d_dart/lib/src/data_types/game_version.dart b/packages/wolf_3d_dart/lib/src/data_types/game_version.dart index d108ba8..fa6da0e 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/game_version.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/game_version.dart @@ -1,11 +1,18 @@ +/// Supported game releases and their associated file extensions. enum GameVersion { + /// Wolfenstein 3D Shareware (.WL1) shareware("WL1"), + + /// Wolfenstein 3D Full Retail (.WL6) retail("WL6"), + + /// Spear of Destiny Full Version (.SOD) spearOfDestiny("SOD"), - spearOfDestinyDemo("SDM"), + + /// Spear of Destiny Demo (.SDM) + spearOfDestinyDemo("SDM") ; final String fileExtension; - const GameVersion(this.fileExtension); } diff --git a/packages/wolf_3d_dart/lib/src/data_types/image.dart b/packages/wolf_3d_dart/lib/src/data_types/image.dart index cfb36ed..c75cc9f 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/image.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/image.dart @@ -1,9 +1,23 @@ import 'dart:typed_data'; +/// Represents a graphical asset decoded from the VGA data chunks. +/// +/// Unlike walls or sprites which are fixed at 64x64, [VgaImage] objects +/// have variable dimensions and are used for UI elements like the +/// status bar, fonts, title screens, and end-of-episode pictures. class VgaImage { + /// The horizontal width of the image in pixels. final int width; + + /// The vertical height of the image in pixels. final int height; - final Uint8List pixels; // 8-bit paletted pixel data + + /// The raw 8-bit paletted pixel data. + /// + /// Each byte in this list represents an index into the game's + /// 256-color VGA palette. To render this image, these indices + /// must be mapped to 32-bit colors using a [ColorPalette]. + final Uint8List pixels; VgaImage({ required this.width, diff --git a/packages/wolf_3d_dart/lib/src/data_types/map_objects.dart b/packages/wolf_3d_dart/lib/src/data_types/map_objects.dart index 0f66386..d2ea75b 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/map_objects.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/map_objects.dart @@ -1,6 +1,10 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart'; +/// Static definitions and logic for objects found in the map's second plane (Object Layer). +/// +/// This includes player start positions, collectibles, static decorations, +/// and interactive triggers like level exits. abstract class MapObject { // --- Player Spawns --- static const int playerNorth = 19; @@ -9,6 +13,7 @@ abstract class MapObject { static const int playerWest = 22; // --- Static Decorations --- + // These are purely visual sprites that don't move or interact. static const int waterPuddle = 23; static const int greenBarrel = 24; static const int chairTable = 25; @@ -84,6 +89,8 @@ abstract class MapObject { static const int bossFettgesicht = 223; // --- Enemy Range Constants --- + // Every enemy type occupies a block of 36 IDs. + // Modulo math is used on these ranges to determine orientation and patrol status. static const int guardStart = 108; // 108-143 static const int dogStart = 144; // 144-179 static const int ssStart = 180; // 180-215 @@ -94,6 +101,7 @@ abstract class MapObject { static const int deadGuard = 124; // Decorative only in WL1 static const int deadAardwolf = 125; // Decorative only in WL1 + /// Calculates the rotation in radians for a player or enemy based on their Map ID. static double getAngle(int id) { switch (id) { case playerNorth: @@ -111,24 +119,23 @@ abstract class MapObject { final EnemyType? type = EnemyType.fromMapId(id); if (type == null) return 0.0; - // Because all enemies are in blocks of 4, modulo 4 gets the exact angle + // Because all enemies are grouped in blocks of 4 (one for each CardinalDirection), + // modulo 4 extracts the specific orientation index. return CardinalDirection.fromEnemyIndex(id % 4).radians; } - /// Determines if an object should be spawned on the current difficulty. + /// Validates if an object should exist on the map given the current [difficulty]. static bool isDifficultyAllowed(int objId, Difficulty difficulty) { - // 1. Dead bodies always spawn + // 1. Decorative dead bodies (used in WL1) always spawn. if (objId == deadGuard || objId == deadAardwolf) return true; - // 2. If it's an enemy, we return true to let it pass through to the - // Enemy.spawn factory. The factory will safely return null if the - // enemy does not belong on this difficulty. + // 2. Enemy filtering is handled by the Enemy factory logic, + // so we pass them through here. if (EnemyType.fromMapId(objId) != null) { return true; } - // 3. All non-enemy map objects (keys, ammo, puddles, plants) - // are NOT difficulty-tiered. They always spawn. + // 3. Static items (ammo, health, plants) are constant across all difficulties. return true; } } diff --git a/packages/wolf_3d_dart/lib/src/data_types/sound.dart b/packages/wolf_3d_dart/lib/src/data_types/sound.dart index 8ea101e..fcdec1a 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/sound.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/sound.dart @@ -1,14 +1,21 @@ import 'dart:typed_data'; +/// Represents a raw 8-bit PCM digitized sound effect. class PcmSound { final Uint8List bytes; PcmSound(this.bytes); } +/// A single instruction for the OPL2 (AdLib) FM synthesis chip. class ImfInstruction { + /// The OPL2 register to write to. final int register; + + /// The data value to write to the register. final int data; - final int delay; // Delay in 1/700ths of a second + + /// The wait time (delay) before the next instruction, measured in 1/700ths of a second. + final int delay; ImfInstruction({ required this.register, @@ -17,21 +24,27 @@ class ImfInstruction { }); } +/// Represents a music track in the iD Music Format (IMF). class ImfMusic { final List instructions; ImfMusic(this.instructions); + /// Decodes IMF data from a raw byte buffer. + /// + /// IMF files in Wolfenstein 3D are prefixed with a 2-byte little-endian + /// length header followed by 4-byte instruction chunks. factory ImfMusic.fromBytes(Uint8List bytes) { List instructions = []; - // Wolfenstein 3D IMF chunks start with a 16-bit length header (little-endian) + // The first 2 bytes are the length of the data portion (excluding header). int actualSize = bytes[0] | (bytes[1] << 8); - // Start parsing at index 2 to skip the size header + // Calculate limit and ensure we don't overflow the source buffer. int limit = 2 + actualSize; - if (limit > bytes.length) limit = bytes.length; // Safety bounds + if (limit > bytes.length) limit = bytes.length; + // Instructions are 4 bytes each: [Reg] [Data] [Delay Low] [Delay High] for (int i = 2; i < limit - 3; i += 4) { instructions.add( ImfInstruction( @@ -47,8 +60,7 @@ class ImfMusic { typedef WolfMusicMap = List; -/// Maps to the original sound indices from the Wolfenstein 3D source code. -/// Use these to index into `activeGame.sounds[id]`. +/// Map indices to original sound effects as defined in the Wolfenstein 3D source. abstract class WolfSound { // --- Doors & Environment --- static const int openDoor = 8; diff --git a/packages/wolf_3d_dart/lib/src/data_types/sprite.dart b/packages/wolf_3d_dart/lib/src/data_types/sprite.dart index e63c02e..733df01 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/sprite.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/sprite.dart @@ -3,16 +3,22 @@ import 'dart:typed_data'; typedef Matrix = List>; typedef SpriteMap = Matrix; +/// A 64x64 graphical asset (wall texture or enemy sprite). class Sprite { + /// 1D array of 8-bit palette indices (4096 total pixels). final Uint8List pixels; Sprite(this.pixels); - // Factory to convert your 2D matrices into a 1D array during load time + /// Factory to convert a 2D [matrix] (x,y) into a 1D column-major array. + /// + /// In Wolf3D, textures are often stored/processed in columns (x * 64 + y) + /// to optimize the vertical scaling used in raycasting. factory Sprite.fromMatrix(Matrix matrix) { final pixels = Uint8List(64 * 64); for (int y = 0; y < 64; y++) { for (int x = 0; x < 64; x++) { + // Linear index for vertical-strip rendering pixels[x * 64 + y] = matrix[x][y]; } } diff --git a/packages/wolf_3d_dart/lib/src/data_types/sprite_frame_range.dart b/packages/wolf_3d_dart/lib/src/data_types/sprite_frame_range.dart index 937238c..95a87d7 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/sprite_frame_range.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/sprite_frame_range.dart @@ -1,10 +1,13 @@ -/// Defines the exact start and end sprite indices for an animation state. +/// Represents a slice of indices within a sprite list for animation states. class SpriteFrameRange { final int start; final int end; const SpriteFrameRange(this.start, this.end); + /// Total number of frames in this animation range. int get length => end - start + 1; + + /// Checks if a specific sprite [index] is part of this animation range. bool contains(int index) => index >= start && index <= end; } diff --git a/packages/wolf_3d_dart/lib/src/data_types/wolf_level.dart b/packages/wolf_3d_dart/lib/src/data_types/wolf_level.dart index 7c515b6..ad889ec 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/wolf_level.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/wolf_level.dart @@ -1,9 +1,22 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +/// Represents a single playable level (map) in the Wolfenstein 3D engine. +/// +/// A level consists of two primary 64x64 grids: one for structural walls +/// and one for dynamic objects (entities, pickups, and player starts). class WolfLevel { + /// The internal name of the level (e.g., "Level 1" or "Floor 1"). final String name; + + /// A 64x64 grid of indices pointing to [WolfensteinData.walls]. + /// + /// In the original engine, a value of 0 usually indicates an empty space. final SpriteMap wallGrid; + + /// A 64x64 grid of indices pointing to game objects, entities, and triggers. final SpriteMap objectGrid; + + /// The index of the [ImfMusic] track to play while this level is active. final int musicIndex; const WolfLevel({ diff --git a/packages/wolf_3d_dart/lib/src/data_types/wolfenstein_data.dart b/packages/wolf_3d_dart/lib/src/data_types/wolfenstein_data.dart index f7ce4df..c1be21d 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/wolfenstein_data.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/wolfenstein_data.dart @@ -1,13 +1,34 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +/// A complete, immutable container for all assets belonging to a specific +/// version of Wolfenstein 3D. +/// +/// This object holds the extracted textures, sounds, music, and levels, +/// serving as the primary data source for the game engine's renderer +/// and audio system. class WolfensteinData { + /// The version of the game these assets belong to (e.g., Shareware or Retail). final GameVersion version; + + /// The collection of 64x64 wall textures extracted from VSWAP. final List walls; + + /// The collection of billboarding sprites (enemies, items, decorations). final List sprites; + + /// Digitized 8-bit PCM sound effects. final List sounds; + + /// AdLib (FM Synthesis) sound effects for the OPL2 chip. final List adLibSounds; + + /// The collection of IMF music tracks. final List music; + + /// UI elements, fonts, and full-screen pictures extracted from VGA assets. final List vgaImages; + + /// The game's levels, organized into episodes (e.g., Episode 1-6). final List episodes; const WolfensteinData({ diff --git a/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart b/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart new file mode 100644 index 0000000..cd12dd5 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart @@ -0,0 +1,308 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; + +class SixelRasterizer extends Rasterizer { + late Uint8List _screen; + late WolfEngine _engine; + + @override + dynamic render(WolfEngine engine, FrameBuffer buffer) { + _engine = engine; + // We only need 8-bit indices for the 256 VGA colors + _screen = Uint8List(buffer.width * buffer.height); + return super.render(engine, buffer); + } + + @override + void prepareFrame(WolfEngine engine) { + // Top half is ceiling color index (25), bottom half is floor color index (29) + for (int y = 0; y < viewHeight; y++) { + int colorIndex = (y < viewHeight / 2) ? 25 : 29; + for (int x = 0; x < width; x++) { + _screen[y * width + x] = colorIndex; + } + } + } + + @override + void drawWallColumn( + int x, + int drawStart, + int drawEnd, + int columnHeight, + Sprite texture, + int texX, + double perpWallDist, + int side, + ) { + for (int y = drawStart; y < drawEnd; y++) { + double relativeY = + (y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + + // Note: Directional shading is omitted here to preserve strict VGA palette indices. + // Sixel uses a fixed 256-color palette, so real-time shading requires a lookup table. + _screen[y * width + x] = colorByte; + } + } + + @override + void drawSpriteStripe( + int stripeX, + int drawStartY, + int drawEndY, + int spriteHeight, + Sprite texture, + int texX, + double transformY, + ) { + for ( + int y = math.max(0, drawStartY); + y < math.min(viewHeight, drawEndY); + y++ + ) { + double relativeY = (y - drawStartY) / spriteHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + + // 255 is the "transparent" color index + if (colorByte != 255) { + _screen[y * width + stripeX] = colorByte; + } + } + } + + @override + void drawWeapon(WolfEngine engine) { + int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex( + engine.data.sprites.length, + ); + Sprite weaponSprite = engine.data.sprites[spriteIndex]; + + int weaponWidth = (width * 0.5).toInt(); + int weaponHeight = (viewHeight * 0.8).toInt(); + + int startX = (width ~/ 2) - (weaponWidth ~/ 2); + int startY = + viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4); + + for (int dy = 0; dy < weaponHeight; dy++) { + for (int dx = 0; dx < weaponWidth; dx++) { + int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63); + int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63); + + int colorByte = weaponSprite.pixels[texX * 64 + texY]; + if (colorByte != 255) { + int drawX = startX + dx; + int drawY = startY + dy; + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) { + _screen[drawY * width + drawX] = colorByte; + } + } + } + } + } + + @override + void drawHud(WolfEngine engine) { + int statusBarIndex = engine.data.vgaImages.indexWhere( + (img) => img.width == 320 && img.height == 40, + ); + if (statusBarIndex == -1) return; + + _blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160); + + _drawNumber(1, 32, 176, engine.data.vgaImages); + _drawNumber(engine.player.score, 96, 176, engine.data.vgaImages); + _drawNumber(3, 120, 176, engine.data.vgaImages); + _drawNumber(engine.player.health, 192, 176, engine.data.vgaImages); + _drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages); + + _drawFace(engine); + _drawWeaponIcon(engine); + } + + @override + dynamic finalizeFrame() { + return toSixelString(); + } + + // =========================================================================== + // SIXEL ENCODER + // =========================================================================== + + /// Converts the 8-bit index buffer into a standard Sixel sequence + String toSixelString() { + StringBuffer sb = StringBuffer(); + + // Start Sixel sequence (q = Sixel format) + sb.write('\x1bPq'); + + // 1. Define the Palette (and apply damage flash directly to the palette!) + double damageIntensity = _engine.player.damageFlash; + int redBoost = (150 * damageIntensity).toInt(); + double colorDrop = 1.0 - (0.5 * damageIntensity); + + for (int i = 0; i < 256; i++) { + int color = ColorPalette.vga32Bit[i]; + int r = color & 0xFF; + int g = (color >> 8) & 0xFF; + int b = (color >> 16) & 0xFF; + + if (damageIntensity > 0) { + r = (r + redBoost).clamp(0, 255); + g = (g * colorDrop).toInt().clamp(0, 255); + b = (b * colorDrop).toInt().clamp(0, 255); + } + + // Sixel RGB ranges from 0 to 100 + int sixelR = (r * 100) ~/ 255; + int sixelG = (g * 100) ~/ 255; + int sixelB = (b * 100) ~/ 255; + + sb.write('#$i;2;$sixelR;$sixelG;$sixelB'); + } + + // 2. Encode Image in 6-pixel vertical bands + for (int band = 0; band < height; band += 6) { + Map colorMap = {}; + + // Map out which pixels use which color in this 6px high band + for (int x = 0; x < width; x++) { + for (int yOffset = 0; yOffset < 6; yOffset++) { + int y = band + yOffset; + if (y >= height) break; + + int colorIdx = _screen[y * width + x]; + if (!colorMap.containsKey(colorIdx)) { + colorMap[colorIdx] = Uint8List(width); + } + // Set the bit corresponding to the vertical position (0-5) + colorMap[colorIdx]![x] |= (1 << yOffset); + } + } + + // Write the encoded Sixel characters for each color present in the band + bool firstColor = true; + for (var entry in colorMap.entries) { + if (!firstColor) + sb.write('\$'); // Carriage return to overlay colors on the same band + firstColor = false; + + // Select color index + sb.write('#${entry.key}'); + + Uint8List cols = entry.value; + int currentVal = -1; + int runLength = 0; + + // Run-Length Encoding (RLE) loop + for (int x = 0; x < width; x++) { + int val = cols[x]; + if (val == currentVal) { + runLength++; + } else { + if (runLength > 0) _writeSixelRle(sb, currentVal, runLength); + currentVal = val; + runLength = 1; + } + } + if (runLength > 0) _writeSixelRle(sb, currentVal, runLength); + } + + sb.write('-'); // Move down to the next 6-pixel band + } + + // End Sixel sequence + sb.write('\x1b\\'); + return sb.toString(); + } + + void _writeSixelRle(StringBuffer sb, int value, int runLength) { + String char = String.fromCharCode(value + 63); + // Sixel RLE format: ! (only worth it if count > 3) + if (runLength > 3) { + sb.write('!$runLength$char'); + } else { + sb.write(char * runLength); + } + } + + // =========================================================================== + // PRIVATE HUD HELPERS (Adapted for 8-bit index buffer) + // =========================================================================== + + void _blitVgaImage(VgaImage image, int startX, int startY) { + int planeWidth = image.width ~/ 4; + int planeSize = planeWidth * image.height; + + for (int dy = 0; dy < image.height; dy++) { + for (int dx = 0; dx < image.width; dx++) { + int drawX = startX + dx; + int drawY = startY + dy; + + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { + int srcX = dx.clamp(0, image.width - 1); + int srcY = dy.clamp(0, image.height - 1); + + int plane = srcX % 4; + int sx = srcX ~/ 4; + int index = (plane * planeSize) + (srcY * planeWidth) + sx; + + int colorByte = image.pixels[index]; + if (colorByte != 255) { + _screen[drawY * width + drawX] = colorByte; + } + } + } + } + } + + void _drawNumber( + int value, + int rightAlignX, + int startY, + List vgaImages, + ) { + const int zeroIndex = 96; + String numStr = value.toString(); + int currentX = rightAlignX - (numStr.length * 8); + + for (int i = 0; i < numStr.length; i++) { + int digit = int.parse(numStr[i]); + if (zeroIndex + digit < vgaImages.length) { + _blitVgaImage(vgaImages[zeroIndex + digit], currentX, startY); + } + currentX += 8; + } + } + + void _drawFace(WolfEngine engine) { + int health = engine.player.health; + int faceIndex = (health <= 0) + ? 127 + : 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3); + if (faceIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164); + } + } + + void _drawWeaponIcon(WolfEngine engine) { + int weaponIndex = 89; + if (engine.player.hasChainGun) { + weaponIndex = 91; + } else if (engine.player.hasMachineGun) { + weaponIndex = 90; + } + + if (weaponIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164); + } + } +} diff --git a/packages/wolf_3d_dart/lib/wolf_3d_engine.dart b/packages/wolf_3d_dart/lib/wolf_3d_engine.dart index 95a4e4e..c8084af 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_engine.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_engine.dart @@ -11,5 +11,6 @@ export 'src/engine/managers/pushwall_manager.dart'; export 'src/engine/player/player.dart'; export 'src/engine/rasterizer/ascii_rasterizer.dart'; export 'src/engine/rasterizer/rasterizer.dart'; +export 'src/engine/rasterizer/sixel_rasterizer.dart'; export 'src/engine/rasterizer/software_rasterizer.dart'; export 'src/engine/wolf_3d_engine_base.dart';