Added some dartdoc comments and an (untested) sixel rasterizer

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-17 12:37:11 +01:00
parent 552a80ecc8
commit a778e0f1fa
23 changed files with 593 additions and 75 deletions

1
.gitignore vendored
View File

@@ -33,6 +33,7 @@
**/build/
**/coverage/
**/flutter/ephemeral/
**/generated_plugin*
# Symbolication related
**/app.*.symbols

View File

@@ -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,

View File

@@ -37,7 +37,9 @@ Future<Map<GameVersion, WolfensteinData>> 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) {

View File

@@ -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<int> _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<WolfensteinData> loadAsync(
Future<ByteData> 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<Sprite> 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<Sprite> parseSprites(ByteData vswap) {
final header = _VswapHeader(vswap);
final sprites = <Sprite>[];
@@ -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<Uint8List> 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<VgaImage> 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<Episode> 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<PcmSound> adLib, List<ImfMusic> 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<int> _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<Uint8List> _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;
}
}

View File

@@ -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<Map<GameVersion, WolfensteinData>> 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,

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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<Coordinate2D> {
final double x;
final double y;
@@ -8,13 +10,18 @@ class Coordinate2D implements Comparable<Coordinate2D> {
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<Coordinate2D> {
);
}
/// 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<Coordinate2D> {
);
}
/// 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) =>

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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<WolfLevel> levels;
const Episode({required this.name, required this.levels});

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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<ImfInstruction> 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<ImfInstruction> 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<int>;
/// 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;

View File

@@ -3,16 +3,22 @@ import 'dart:typed_data';
typedef Matrix<T> = List<List<T>>;
typedef SpriteMap = Matrix<int>;
/// 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<int> 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];
}
}

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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<Sprite> walls;
/// The collection of billboarding sprites (enemies, items, decorations).
final List<Sprite> sprites;
/// Digitized 8-bit PCM sound effects.
final List<PcmSound> sounds;
/// AdLib (FM Synthesis) sound effects for the OPL2 chip.
final List<PcmSound> adLibSounds;
/// The collection of IMF music tracks.
final List<ImfMusic> music;
/// UI elements, fonts, and full-screen pictures extracted from VGA assets.
final List<VgaImage> vgaImages;
/// The game's levels, organized into episodes (e.g., Episode 1-6).
final List<Episode> episodes;
const WolfensteinData({

View File

@@ -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<int, Uint8List> 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: !<count><char> (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<VgaImage> 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);
}
}
}

View File

@@ -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';