Added some dartdoc comments and an (untested) sixel rasterizer
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@
|
||||
**/build/
|
||||
**/coverage/
|
||||
**/flutter/ephemeral/
|
||||
**/generated_plugin*
|
||||
|
||||
# Symbolication related
|
||||
**/app.*.symbols
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user