Migrate all Dart packages to a single wolf_3d_dart package

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-17 10:55:10 +01:00
parent eec1f8f495
commit 0dc75ded62
120 changed files with 364 additions and 763 deletions

View File

@@ -0,0 +1,27 @@
enum DataVersion {
/// V1.0 Retail (VSWAP.WL6)
version10Retail('a6d901dfb455dfac96db5e4705837cdb'),
/// v1.1 Retail (VSWAP.WL6)
version11Retail('a80904e0283a921d88d977b56c279b9d'),
/// v1.4 Shareware (VSWAP.WL1)
version14Shareware('6efa079414b817c97db779cecfb081c9'),
/// v1.4 Retail (VSWAP.WL6) - GOG/Steam version
version14Retail('b8ff4997461bafa5ef2a94c11f9de001'),
unknown('unknown'),
;
final String checksum;
const DataVersion(this.checksum);
static DataVersion fromChecksum(String hash) {
return DataVersion.values.firstWhere(
(v) => v.checksum == hash,
orElse: () => DataVersion.unknown,
);
}
}

View File

@@ -0,0 +1,109 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:wolf_3d_dart/src/data/data_version.dart';
import 'package:wolf_3d_dart/src/data/wl_parser.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// dart:io implementation for directory discovery with version integrity checks.
Future<Map<GameVersion, WolfensteinData>> discoverInDirectory({
String? directoryPath,
bool recursive = false,
}) async {
final dir = Directory(directoryPath ?? Directory.current.path);
if (!await dir.exists()) {
print('Warning: Directory does not exist -> ${dir.path}');
return {};
}
final allFiles = await dir
.list(recursive: recursive)
.where((entity) => entity is File)
.cast<File>()
.toList();
final Map<GameVersion, WolfensteinData> loadedVersions = {};
for (final version in GameVersion.values) {
final ext = version.fileExtension.toUpperCase();
final Map<GameFile, File> foundFiles = {};
for (final requiredFile in GameFile.values) {
final expectedName = '${requiredFile.baseName}.$ext';
var match = allFiles.where((file) {
final fileName = file.uri.pathSegments.last.toUpperCase();
return fileName == expectedName;
}).firstOrNull;
// v1.0 FIX: Search for MAPTEMP if GAMEMAPS is missing
if (match == null && requiredFile == GameFile.gameMaps) {
final altName = 'MAPTEMP.$ext';
match = allFiles.where((file) {
final fileName = file.uri.pathSegments.last.toUpperCase();
return fileName == altName;
}).firstOrNull;
}
if (match != null) {
foundFiles[requiredFile] = match;
}
}
if (foundFiles.isEmpty) continue;
// Validation for missing files
if (foundFiles.length < GameFile.values.length) {
final missingFiles = GameFile.values
.where((f) => !foundFiles.containsKey(f))
.map((f) => '${f.baseName}.$ext')
.join(', ');
print('Found partial data for ${version.name}. Missing: $missingFiles');
continue;
}
try {
// 1. Read VSWAP to determine the precise DataVersion identity
final vswapFile = foundFiles[GameFile.vswap]!;
final vswapBytes = await vswapFile.readAsBytes();
// 2. Generate Checksum and Resolve Identity
final hash = md5.convert(vswapBytes).toString();
final identity = DataVersion.fromChecksum(hash);
print('--- Found ${version.name} ---');
print('MD5 Identity: ${identity.name} ($hash)');
if (identity == DataVersion.version10Retail) {
print(
'Note: Detected v1.0 specific file structure (MAPTEMP support active).',
);
}
// 3. Load the data using the parser
final data = WLParser.load(
version: version,
dataIdentity: identity,
vswap: vswapBytes.buffer.asByteData(),
mapHead: await _readFile(foundFiles[GameFile.mapHead]!),
gameMaps: await _readFile(foundFiles[GameFile.gameMaps]!),
vgaDict: await _readFile(foundFiles[GameFile.vgaDict]!),
vgaHead: await _readFile(foundFiles[GameFile.vgaHead]!),
vgaGraph: await _readFile(foundFiles[GameFile.vgaGraph]!),
audioHed: await _readFile(foundFiles[GameFile.audioHed]!),
audioT: await _readFile(foundFiles[GameFile.audioT]!),
);
loadedVersions[version] = data;
} catch (e) {
print('Error parsing data for ${version.name}: $e');
}
}
return loadedVersions;
}
Future<ByteData> _readFile(File file) async {
final bytes = await file.readAsBytes();
return bytes.buffer.asByteData();
}

View File

@@ -0,0 +1,12 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// Web-safe stub for directory discovery.
Future<Map<GameVersion, WolfensteinData>> discoverInDirectory({
String? directoryPath,
bool recursive = false,
}) async {
throw UnsupportedError(
'Directory scanning is not supported on Web. '
'Please load the files manually using WolfensteinLoader.loadFromBytes().',
);
}

View File

@@ -0,0 +1,677 @@
import 'dart:convert';
import 'dart:typed_data';
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';
abstract class WLParser {
// --- Original Song Lookup Tables ---
static const List<int> _sharewareMusicMap = [
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Episode 1
];
static const List<int> _retailMusicMap = [
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Ep 1
8, 9, 10, 11, 8, 9, 11, 10, 6, 12, // Ep 2
13, 14, 15, 16, 13, 14, 15, 16, 17, 18, // Ep 3
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Ep 4
8, 9, 10, 11, 8, 9, 11, 10, 6, 12, // Ep 5
13, 14, 15, 16, 13, 14, 15, 16, 17, 19, // Ep 6
];
/// 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.
static Future<WolfensteinData> loadAsync(
Future<ByteData> Function(String filename) fileFetcher,
) async {
GameVersion? detectedVersion;
ByteData? vswap;
// 1. Probe the data source for VSWAP to determine the GameVersion
for (final version in GameVersion.values) {
try {
vswap = await fileFetcher('VSWAP.${version.fileExtension}');
detectedVersion = version;
break;
} catch (_) {}
}
if (detectedVersion == null || vswap == null) {
throw Exception('Could not locate a valid VSWAP file.');
}
final ext = detectedVersion.fileExtension;
// 2. Determine DataIdentity (Checksum) immediately
final vswapBytes = vswap.buffer.asUint8List(
vswap.offsetInBytes,
vswap.lengthInBytes,
);
final vswapHash = md5.convert(vswapBytes).toString();
final dataIdentity = DataVersion.fromChecksum(vswapHash);
// 3. Load other required files
// Special Case: v1.0 Retail uses MAPTEMP instead of GAMEMAPS
ByteData gameMapsData;
if (dataIdentity == DataVersion.version10Retail) {
try {
gameMapsData = await fileFetcher('MAPTEMP.$ext');
} catch (_) {
// Fallback in case v1.0 files were renamed to standard convention
gameMapsData = await fileFetcher('GAMEMAPS.$ext');
}
} else {
gameMapsData = await fileFetcher('GAMEMAPS.$ext');
}
final rawFiles = {
'MAPHEAD.$ext': await fileFetcher('MAPHEAD.$ext'),
'VGADICT.$ext': await fileFetcher('VGADICT.$ext'),
'VGAHEAD.$ext': await fileFetcher('VGAHEAD.$ext'),
'VGAGRAPH.$ext': await fileFetcher('VGAGRAPH.$ext'),
'AUDIOHED.$ext': await fileFetcher('AUDIOHED.$ext'),
'AUDIOT.$ext': await fileFetcher('AUDIOT.$ext'),
};
// 4. Final call to load with the required dataIdentity
return load(
version: detectedVersion,
dataIdentity: dataIdentity, // Now correctly passed
vswap: vswap,
mapHead: rawFiles['MAPHEAD.$ext']!,
gameMaps: gameMapsData,
vgaDict: rawFiles['VGADICT.$ext']!,
vgaHead: rawFiles['VGAHEAD.$ext']!,
vgaGraph: rawFiles['VGAGRAPH.$ext']!,
audioHed: rawFiles['AUDIOHED.$ext']!,
audioT: rawFiles['AUDIOT.$ext']!,
);
}
/// Parses all raw ByteData upfront and returns a fully populated
/// WolfensteinData object. By using named parameters, the compiler
/// guarantees no files are missing or misnamed.
static WolfensteinData load({
required GameVersion version,
required ByteData vswap,
required ByteData mapHead,
required ByteData gameMaps,
required ByteData vgaDict,
required ByteData vgaHead,
required ByteData vgaGraph,
required ByteData audioHed,
required ByteData audioT,
required DataVersion dataIdentity,
}) {
final isShareware = version == GameVersion.shareware;
// v1.0/1.1 used different HUD strings and had different secret wall bugs
final isLegacy =
dataIdentity == DataVersion.version10Retail ||
dataIdentity == DataVersion.version11Retail;
final audio = parseAudio(audioHed, audioT, version);
return WolfensteinData(
version: version,
walls: parseWalls(vswap),
sprites: parseSprites(vswap),
sounds: parseSounds(vswap).map((bytes) => PcmSound(bytes)).toList(),
episodes: parseEpisodes(mapHead, gameMaps, isShareware: isShareware),
vgaImages: parseVgaImages(vgaDict, vgaHead, vgaGraph),
adLibSounds: audio.adLib,
music: audio.music,
);
}
/// Extracts the 64x64 wall textures from VSWAP.WL1
static List<Sprite> parseWalls(ByteData vswap) {
final header = _VswapHeader(vswap);
return header.offsets
.take(header.spriteStart)
.where((offset) => offset != 0) // Skip empty chunks
.map((offset) => _parseWallChunk(vswap, offset))
.toList();
}
/// Extracts the compiled scaled sprites from VSWAP.WL1
static List<Sprite> parseSprites(ByteData vswap) {
final header = _VswapHeader(vswap);
final sprites = <Sprite>[];
// Sprites are located between the walls and the sounds
for (int i = header.spriteStart; i < header.soundStart; i++) {
int offset = header.offsets[i];
if (offset != 0) {
sprites.add(_parseSingleSprite(vswap, offset));
}
}
return sprites;
}
/// Extracts digitized sound effects (PCM Audio) from VSWAP.WL1
static List<Uint8List> parseSounds(ByteData vswap) {
final header = _VswapHeader(vswap);
final lengthStart = 6 + (header.chunks * 4);
final sounds = <Uint8List>[];
// Sounds start after the sprites and go to the end of the chunks
for (int i = header.soundStart; i < header.chunks; i++) {
int offset = header.offsets[i];
int length = vswap.getUint16(lengthStart + (i * 2), Endian.little);
if (offset == 0 || length == 0) {
sounds.add(Uint8List(0)); // Empty placeholder
} else {
// Extract the raw 8-bit PCM audio bytes
sounds.add(vswap.buffer.asUint8List(offset, length));
}
}
return sounds;
}
// --- Private Helpers ---
static Sprite _parseWallChunk(ByteData vswap, int offset) {
final pixels = Uint8List(64 * 64);
for (int x = 0; x < 64; x++) {
for (int y = 0; y < 64; y++) {
// Flat 1D index: x * 64 + y
pixels[x * 64 + y] = vswap.getUint8(offset + (x * 64) + y);
}
}
return Sprite(pixels);
}
static Sprite _parseSingleSprite(ByteData vswap, int offset) {
// Initialize the 1D array with 255 (Transparency)
final pixels = Uint8List(64 * 64)..fillRange(0, 4096, 255);
Sprite sprite = Sprite(pixels);
int leftPix = vswap.getUint16(offset, Endian.little);
int rightPix = vswap.getUint16(offset + 2, Endian.little);
for (int x = leftPix; x <= rightPix; x++) {
int colOffset = vswap.getUint16(
offset + 4 + ((x - leftPix) * 2),
Endian.little,
);
if (colOffset != 0) {
_parseSpriteColumn(vswap, sprite, x, offset, offset + colOffset);
}
}
return sprite;
}
static void _parseSpriteColumn(
ByteData vswap,
Sprite sprite,
int x,
int baseOffset,
int cmdOffset,
) {
while (true) {
int endY = vswap.getUint16(cmdOffset, Endian.little);
if (endY == 0) break;
endY ~/= 2;
int pixelOfs = vswap.getUint16(cmdOffset + 2, Endian.little);
int startY = vswap.getUint16(cmdOffset + 4, Endian.little);
startY ~/= 2;
for (int y = startY; y < endY; y++) {
// Write directly to the 1D array
sprite.pixels[x * 64 + y] = vswap.getUint8(baseOffset + pixelOfs + y);
}
cmdOffset += 6;
}
}
/// Extracts and decodes the UI graphics, Title Screens, and Menu items
static List<VgaImage> parseVgaImages(
ByteData vgaDict,
ByteData vgaHead,
ByteData vgaGraph,
) {
// 1. Get all raw decompressed chunks using the Huffman algorithm
List<Uint8List> rawChunks = _parseVgaRaw(vgaDict, vgaHead, vgaGraph);
// 2. Chunk 0 is the Picture Table (Dimensions for all images)
// It contains consecutive 16-bit widths and 16-bit heights
ByteData picTable = ByteData.sublistView(rawChunks[0]);
int numPics = picTable.lengthInBytes ~/ 4;
List<VgaImage> images = [];
// 3. In Wolf3D, Chunk 1 and 2 are fonts. Pictures start at Chunk 3!
int picStartIndex = 3;
for (int i = 0; i < numPics; i++) {
int width = picTable.getUint16(i * 4, Endian.little);
int height = picTable.getUint16(i * 4 + 2, Endian.little);
// Safety check: ensure we don't read out of bounds
if (picStartIndex + i < rawChunks.length) {
images.add(
VgaImage(
width: width,
height: height,
pixels: rawChunks[picStartIndex + i],
),
);
}
}
return images;
}
// --- Episode Names (From the original C Executable) ---
static const List<String> _sharewareEpisodeNames = [
"Episode 1\nEscape from Wolfenstein",
];
static const List<String> _retailEpisodeNames = [
"Episode 1\nEscape from Wolfenstein",
"Episode 2\nOperation: Eisenfaust",
"Episode 3\nDie, Fuhrer, Die!",
"Episode 4\nA Dark Secret",
"Episode 5\nTrail of the Madman",
"Episode 6\nConfrontation",
];
/// Parses MAPHEAD and GAMEMAPS to extract the raw level data.
static List<Episode> parseEpisodes(
ByteData mapHead,
ByteData gameMaps, {
bool isShareware = true,
}) {
List<WolfLevel> allLevels = [];
int rlewTag = mapHead.getUint16(0, Endian.little);
// Select the correct music map based on the version
final activeMusicMap = isShareware ? _sharewareMusicMap : _retailMusicMap;
final episodeNames = isShareware
? _sharewareEpisodeNames
: _retailEpisodeNames;
// The game allows for up to 100 maps per file
for (int i = 0; i < 100; i++) {
int mapOffset = mapHead.getUint32(2 + (i * 4), Endian.little);
if (mapOffset == 0) continue; // Empty map slot
int plane0Offset = gameMaps.getUint32(mapOffset + 0, Endian.little);
int plane1Offset = gameMaps.getUint32(mapOffset + 4, Endian.little);
int plane0Length = gameMaps.getUint16(mapOffset + 12, Endian.little);
int plane1Length = gameMaps.getUint16(mapOffset + 14, Endian.little);
// --- EXTRACT ACTUAL GAME DATA NAME ---
// The name is exactly 16 bytes long, starting at offset 22
List<int> nameBytes = [];
for (int n = 0; n < 16; n++) {
int charCode = gameMaps.getUint8(mapOffset + 22 + n);
if (charCode == 0) break; // Stop at the null-terminator
nameBytes.add(charCode);
}
String parsedName = ascii.decode(nameBytes);
// --- DECOMPRESS PLANES ---
final compressedWallData = gameMaps.buffer.asUint8List(
plane0Offset,
plane0Length,
);
Uint16List carmackExpandedWalls = _expandCarmack(compressedWallData);
List<int> flatWallGrid = _expandRlew(carmackExpandedWalls, rlewTag);
final compressedObjectData = gameMaps.buffer.asUint8List(
plane1Offset,
plane1Length,
);
Uint16List carmackExpandedObjects = _expandCarmack(compressedObjectData);
List<int> flatObjectGrid = _expandRlew(carmackExpandedObjects, rlewTag);
// --- BUILD 64x64 GRIDS ---
List<List<int>> wallGrid = [];
List<List<int>> objectGrid = [];
for (int y = 0; y < 64; y++) {
List<int> wallRow = [];
List<int> objectRow = [];
for (int x = 0; x < 64; x++) {
wallRow.add(flatWallGrid[y * 64 + x]);
objectRow.add(flatObjectGrid[y * 64 + x]);
}
wallGrid.add(wallRow);
objectGrid.add(objectRow);
}
// --- ASSIGN MUSIC ---
int trackIndex = (i < activeMusicMap.length)
? activeMusicMap[i]
: activeMusicMap[i % activeMusicMap.length];
allLevels.add(
WolfLevel(
name: parsedName,
wallGrid: wallGrid,
objectGrid: objectGrid,
musicIndex: trackIndex,
),
);
}
// 2. Group the parsed levels into Episodes!
List<Episode> episodes = [];
// Calculate how many episodes we need (10 levels per episode)
int totalEpisodes = (allLevels.length / 10).ceil();
for (int i = 0; i < totalEpisodes; i++) {
int startIndex = i * 10;
int endIndex = startIndex + 10;
// Safety clamp for incomplete episodes at the end of the file
if (endIndex > allLevels.length) {
endIndex = allLevels.length;
}
// If we run out of hardcoded id Software names, generate a custom one!
String epName = (i < episodeNames.length)
? episodeNames[i]
: "Episode ${i + 1}\nCustom Maps";
episodes.add(
Episode(
name: epName,
levels: allLevels.sublist(startIndex, endIndex),
),
);
}
return episodes;
}
/// Extracts AdLib sounds and IMF music tracks from the audio files.
static ({List<PcmSound> adLib, List<ImfMusic> music}) parseAudio(
ByteData audioHed,
ByteData audioT,
GameVersion version,
) {
List<int> offsets = [];
for (int i = 0; i < audioHed.lengthInBytes ~/ 4; i++) {
offsets.add(audioHed.getUint32(i * 4, Endian.little));
}
List<Uint8List> allAudioChunks = [];
for (int i = 0; i < offsets.length - 1; i++) {
int start = offsets[i];
int next = offsets[i + 1];
if (start == 0xFFFFFFFF || start >= audioT.lengthInBytes) {
allAudioChunks.add(Uint8List(0));
continue;
}
int length = next - start;
if (length <= 0) {
allAudioChunks.add(Uint8List(0));
} else {
allAudioChunks.add(
audioT.buffer.asUint8List(audioT.offsetInBytes + start, length),
);
}
}
// In Wolf3D v1.4 (Shareware and Retail), Music ALWAYS starts at chunk 261.
// Chunks 0-86: PC Sounds
// Chunks 87-173: AdLib Sounds
// Chunks 174-260: Digitized Sounds
int musicStartIndex = 261;
List<PcmSound> adLib = allAudioChunks
.take(musicStartIndex)
.map((bytes) => PcmSound(bytes))
.toList();
List<ImfMusic> music = allAudioChunks
.skip(musicStartIndex)
.where((chunk) => chunk.isNotEmpty)
.map((bytes) => ImfMusic.fromBytes(bytes))
.toList();
return (adLib: adLib, music: music);
}
// --- ALGORITHM 1: CARMACK EXPANSION ---
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;
Uint16List expanded = Uint16List(expandedLengthWords);
int inIdx = 2; // Skip the length word we just read
int outIdx = 0;
while (outIdx < expandedLengthWords && inIdx < compressed.length) {
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
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.
int nextByte = data.getUint8(inIdx++);
expanded[outIdx++] = (nextByte << 8) | highByte;
} else if (highByte == 0xA7) {
// 0xA7 = Near Pointer (look back a few spaces)
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)
int offset = data.getUint16(inIdx, Endian.little);
inIdx += 2;
for (int i = 0; i < lowByte; i++) {
expanded[outIdx++] = expanded[offset++];
}
}
} else {
// Normal, uncompressed word
expanded[outIdx++] = word;
}
}
return expanded;
}
// --- ALGORITHM 2: RLEW EXPANSION ---
static List<int> _expandRlew(Uint16List carmackExpanded, int rlewTag) {
// The first word is the expanded length in BYTES
int expandedLengthBytes = carmackExpanded[0];
int expandedLengthWords = expandedLengthBytes ~/ 2;
List<int> rlewExpanded = List<int>.filled(expandedLengthWords, 0);
int inIdx = 1; // Skip the length word
int outIdx = 0;
while (outIdx < expandedLengthWords && inIdx < carmackExpanded.length) {
int word = carmackExpanded[inIdx++];
if (word == rlewTag) {
// We found an RLEW tag!
// The next word is the count, the word after that is the value.
int count = carmackExpanded[inIdx++];
int value = carmackExpanded[inIdx++];
for (int i = 0; i < count; i++) {
rlewExpanded[outIdx++] = value;
}
} else {
// Normal word
rlewExpanded[outIdx++] = word;
}
}
return rlewExpanded;
}
/// Extracts decompressed VGA data chunks (UI, Fonts, Pictures)
static List<Uint8List> _parseVgaRaw(
ByteData vgaDict,
ByteData vgaHead,
ByteData vgaGraph,
) {
List<Uint8List> vgaChunks = [];
// 1. Parse the Huffman Dictionary from VGADICT
List<_HuffmanNode> dict = [];
for (int i = 0; i < 255; i++) {
dict.add(
_HuffmanNode(
vgaDict.getUint16(i * 4, Endian.little),
vgaDict.getUint16(i * 4 + 2, Endian.little),
),
);
}
// 2. Read VGAHEAD to get the offsets for VGAGRAPH
List<int> offsets = [];
int numChunks = vgaHead.lengthInBytes ~/ 3;
int lastOffset = -1;
for (int i = 0; i < numChunks; i++) {
int offset =
vgaHead.getUint8(i * 3) |
(vgaHead.getUint8(i * 3 + 1) << 8) |
(vgaHead.getUint8(i * 3 + 2) << 16);
if (offset == 0x00FFFFFF) break;
// --- SAFETY FIX ---
// 1. Offsets cannot point beyond the file length.
// 2. Offsets must not go backward (this means we hit the junk padding).
if (offset > vgaGraph.lengthInBytes || offset < lastOffset) {
break;
}
offsets.add(offset);
lastOffset = offset;
}
// 3. Decompress the chunks from VGAGRAPH
for (int i = 0; i < offsets.length; i++) {
int offset = offsets[i];
// --- BOUNDARY CHECK ---
// If the offset is exactly at the end of the file, or doesn't leave
// enough room for a 4-byte length header, it's an empty chunk.
if (offset + 4 > vgaGraph.lengthInBytes) {
vgaChunks.add(Uint8List(0));
continue;
}
// The first 4 bytes of a compressed chunk specify its decompressed size
int expandedLength = vgaGraph.getUint32(offset, Endian.little);
if (expandedLength == 0) {
vgaChunks.add(Uint8List(0));
continue;
}
// Decompress starting immediately after the 4-byte length header.
// We pass the exact slice of bytes remaining so we don't read out of bounds.
Uint8List expandedData = _expandHuffman(
vgaGraph.buffer.asUint8List(
vgaGraph.offsetInBytes + offset + 4,
vgaGraph.lengthInBytes - (offset + 4),
),
dict,
expandedLength,
);
vgaChunks.add(expandedData);
}
return vgaChunks;
}
// --- ALGORITHM 3: HUFFMAN EXPANSION ---
static Uint8List _expandHuffman(
Uint8List compressed,
List<_HuffmanNode> dict,
int expandedLength,
) {
Uint8List expanded = Uint8List(expandedLength);
int outIdx = 0;
int byteIdx = 0;
int bitMask = 1;
int currentNode = 254; // id Software's Huffman root is always node 254
while (outIdx < expandedLength && byteIdx < compressed.length) {
// Read the current bit (LSB to MSB)
int bit = (compressed[byteIdx] & bitMask) == 0 ? 0 : 1;
// Advance to the next bit/byte
bitMask <<= 1;
if (bitMask > 128) {
bitMask = 1;
byteIdx++;
}
// Traverse the tree
int nextVal = bit == 0 ? dict[currentNode].bit0 : dict[currentNode].bit1;
if (nextVal < 256) {
// If the value is < 256, we've hit a leaf node (an actual character byte!)
expanded[outIdx++] = nextVal;
currentNode =
254; // Reset to the root of the tree for the next character
} else {
// If the value is >= 256, it's a pointer to the next internal node.
// Node indexes are offset by 256.
currentNode = nextVal - 256;
}
}
return expanded;
}
}
/// Helper class to parse and store redundant VSWAP header data
class _VswapHeader {
final int chunks;
final int spriteStart;
final int soundStart;
final List<int> offsets;
_VswapHeader(ByteData vswap)
: chunks = vswap.getUint16(0, Endian.little),
spriteStart = vswap.getUint16(2, Endian.little),
soundStart = vswap.getUint16(4, Endian.little),
offsets = List.generate(
vswap.getUint16(0, Endian.little), // total chunks
(i) => vswap.getUint32(6 + (i * 4), Endian.little),
);
}
class _HuffmanNode {
final int bit0;
final int bit1;
_HuffmanNode(this.bit0, this.bit1);
}

View File

@@ -0,0 +1,83 @@
import 'dart:typed_data';
import 'package:crypto/crypto.dart'; // Import for MD5
import 'package:wolf_3d_dart/src/data/data_version.dart'; // Import your enum
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'io/discovery_stub.dart'
if (dart.library.io) 'io/discovery_io.dart'
as platform;
import 'wl_parser.dart';
class WolfensteinLoader {
/// Scans a directory for Wolfenstein 3D data files and loads all available versions.
static Future<Map<GameVersion, WolfensteinData>> discover({
String? directoryPath,
bool recursive = false,
}) {
return platform.discoverInDirectory(
directoryPath: directoryPath,
recursive: recursive,
);
}
/// Parses WolfensteinData from raw ByteData.
/// Throws an [ArgumentError] if any required file is null.
static WolfensteinData loadFromBytes({
required GameVersion version,
required ByteData? vswap,
required ByteData? mapHead,
required ByteData? gameMaps,
required ByteData? vgaDict,
required ByteData? vgaHead,
required ByteData? vgaGraph,
required ByteData? audioHed,
required ByteData? audioT,
}) {
// 1. Validation Check
final Map<String, ByteData?> files = {
'VSWAP': vswap,
'MAPHEAD': mapHead,
'GAMEMAPS': gameMaps,
'VGADICT': vgaDict,
'VGAHEAD': vgaHead,
'VGAGRAPH': vgaGraph,
'AUDIOHED': audioHed,
'AUDIOT': audioT,
};
final missing = files.entries
.where((e) => e.value == null)
.map((e) => "${e.key}.${version.fileExtension}")
.toList();
if (missing.isNotEmpty) {
throw ArgumentError(
'Cannot load ${version.name}: Missing files: ${missing.join(", ")}',
);
}
// 2. Identify the DataVersion via Checksum
final vswapBytes = vswap!.buffer.asUint8List(
vswap.offsetInBytes,
vswap.lengthInBytes,
);
final hash = md5.convert(vswapBytes).toString();
final dataIdentity = DataVersion.fromChecksum(hash);
// 3. Pass-through to parser with the detected identity
return WLParser.load(
version: version,
// Correctly identifies v1.0/1.1/1.4
dataIdentity: dataIdentity,
vswap: vswap,
mapHead: mapHead!,
gameMaps: gameMaps!,
vgaDict: vgaDict!,
vgaHead: vgaHead!,
vgaGraph: vgaGraph!,
audioHed: audioHed!,
audioT: audioT!,
);
}
}

View File

@@ -0,0 +1,28 @@
import 'dart:math' as math;
enum CardinalDirection {
east(0.0),
south(math.pi / 2),
west(math.pi),
north(3 * math.pi / 2)
;
final double radians;
const CardinalDirection(this.radians);
/// Helper to decode Wolf3D enemy directional blocks
static CardinalDirection fromEnemyIndex(int index) {
switch (index % 4) {
case 0:
return CardinalDirection.east;
case 1:
return CardinalDirection.north;
case 2:
return CardinalDirection.west;
case 3:
return CardinalDirection.south;
default:
return CardinalDirection.east;
}
}
}

View File

@@ -0,0 +1,262 @@
import 'dart:typed_data';
abstract class ColorPalette {
static final Uint32List vga32Bit = Uint32List.fromList([
0xFF000000,
0xFFAA0000,
0xFF00AA00,
0xFFAAAA00,
0xFF0000AA,
0xFFAA00AA,
0xFF0055AA,
0xFFAAAAAA,
0xFF555555,
0xFFFF5555,
0xFF55FF55,
0xFFFFFF55,
0xFF5555FF,
0xFFFF55FF,
0xFF55FFFF,
0xFFFFFFFF,
0xFFEEEEEE,
0xFFDEDEDE,
0xFFD2D2D2,
0xFFC2C2C2,
0xFFB6B6B6,
0xFFAAAAAA,
0xFF999999,
0xFF8D8D8D,
0xFF7D7D7D,
0xFF717171,
0xFF656565,
0xFF555555,
0xFF484848,
0xFF383838,
0xFF2C2C2C,
0xFF202020,
0xFF0000FF,
0xFF0000EE,
0xFF0000E2,
0xFF0000D6,
0xFF0000CA,
0xFF0000BE,
0xFF0000B2,
0xFF0000A5,
0xFF000099,
0xFF000089,
0xFF00007D,
0xFF000071,
0xFF000065,
0xFF000059,
0xFF00004C,
0xFF000040,
0xFFDADAFF,
0xFFBABAFF,
0xFF9D9DFF,
0xFF7D7DFF,
0xFF5D5DFF,
0xFF4040FF,
0xFF2020FF,
0xFF0000FF,
0xFF5DAAFF,
0xFF4099FF,
0xFF2089FF,
0xFF0079FF,
0xFF006DE6,
0xFF0061CE,
0xFF0055B6,
0xFF004C9D,
0xFFDAFFFF,
0xFFBAFFFF,
0xFF9DFFFF,
0xFF7DFFFF,
0xFF5DFAFF,
0xFF40F6FF,
0xFF20F6FF,
0xFF00F6FF,
0xFF00DAE6,
0xFF00C6CE,
0xFF00AEB6,
0xFF009D9D,
0xFF008585,
0xFF006D71,
0xFF005559,
0xFF004040,
0xFF5DFFD2,
0xFF40FFC6,
0xFF20FFB6,
0xFF00FFA1,
0xFF00E691,
0xFF00CE81,
0xFF00B675,
0xFF009D61,
0xFFDAFFDA,
0xFFBAFFBE,
0xFF9DFF9D,
0xFF7DFF81,
0xFF5DFF61,
0xFF40FF40,
0xFF20FF20,
0xFF00FF00,
0xFF00FF00,
0xFF00EE00,
0xFF00E200,
0xFF00D600,
0xFF00CA04,
0xFF00BE04,
0xFF00B204,
0xFF00A504,
0xFF009904,
0xFF008904,
0xFF007D04,
0xFF007104,
0xFF006504,
0xFF005904,
0xFF004C04,
0xFF004004,
0xFFFFFFDA,
0xFFFFFFBA,
0xFFFFFF9D,
0xFFFAFF7D,
0xFFFFFF5D,
0xFFFFFF40,
0xFFFFFF20,
0xFFFFFF00,
0xFFE6E600,
0xFFCECE00,
0xFFB6B600,
0xFF9D9D00,
0xFF858500,
0xFF717100,
0xFF595900,
0xFF404000,
0xFFBE5D,
0xFFB240,
0xFFAA20,
0xFF9D00,
0xFFE68D00,
0xFFCE7D00,
0xFFB66D00,
0xFF9D5D00,
0xFFDADADA,
0xFFBEBA,
0xFF9D9D,
0xFF817D,
0xFF615D,
0xFF4040,
0xFF2420,
0xFF0400,
0xFF0000,
0xFFEE0000,
0xFFE20000,
0xFFD60000,
0xFFCA0000,
0xFFBE0000,
0xFFB20000,
0xFFA50000,
0xFF990000,
0xFF890000,
0xFF7D0000,
0xFF710000,
0xFF650000,
0xFF590000,
0xFF4C0000,
0xFF400000,
0xFF282828,
0xFF34E2FF,
0xFF24D6FF,
0xFF18CEFF,
0xFF08C2FF,
0xFF00B6FF,
0xFF20B6,
0xFF00AA,
0xFFE60099,
0xFFCE0081,
0xFFB60075,
0xFF9D0061,
0xFF850050,
0xFF710044,
0xFF590034,
0xFF400028,
0xFFDAFF,
0xFFBAFF,
0xFF9DFF,
0xFF7DFF,
0xFF5DFF,
0xFF40FF,
0xFF20FF,
0xFF00FF,
0xFFE600E2,
0xFFCE00CA,
0xFFB600B6,
0xFF9D009D,
0xFF850085,
0xFF71006D,
0xFF590059,
0xFF400040,
0xFFDEEAFF,
0xFFD2E2FF,
0xFFC6DAFF,
0xFFBED6FF,
0xFFB2CEFF,
0xFFA5C6FF,
0xFF9DBEFF,
0xFF91BAFF,
0xFF81B2FF,
0xFF1F57FA,
0xFF619DFF,
0xFF5D95F2,
0xFF598DEA,
0xFF5589DE,
0xFF5081D2,
0xFF4C7DCA,
0xFF4879BE,
0xFF4471B6,
0xFF4069AA,
0xFF3C65A1,
0xFF38619D,
0xFF345D91,
0xFF305989,
0xFF2C5081,
0xFF284C75,
0xFF24486D,
0xFF20405D,
0xFF1C3C55,
0xFF183848,
0xFF183040,
0xFF142C38,
0xFF0C2028,
0xFF650061,
0xFF656500,
0xFF616100,
0xFF1C0000,
0xFF2C0000,
0xFF102430,
0xFF480048,
0xFF500050,
0xFF340000,
0xFF1C1C1C,
0xFF4C4C4C,
0xFF5D5D5D,
0xFF404040,
0xFF303030,
0xFF343434,
0xFFF6F6DA,
0xFFEAEABA,
0xFFDEDED9,
0xFFCACA75,
0xFFC2C248,
0xFFB6B620,
0xFFB2B220,
0xFFA5A500,
0xFF999900,
0xFF8D8D00,
0xFF858500,
0xFF7D7D00,
0xFF797900,
0xFF757500,
0xFF717100,
0xFF6D6D00,
0xFF890099,
]);
}

View File

@@ -0,0 +1,69 @@
import 'dart:math' as math;
/// A lightweight, immutable 2D Vector/Coordinate system.
class Coordinate2D implements Comparable<Coordinate2D> {
final double x;
final double y;
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.
/// 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].
Coordinate2D rotate(double radians) {
final cos = math.cos(radians);
final sin = math.sin(radians);
return Coordinate2D(
(x * cos) - (y * sin),
(x * sin) + (y * cos),
);
}
/// Linear Interpolation: Slides between this and [target] by [t] (0.0 to 1.0).
/// Perfect for smooth camera follows or "lerping" an object to a new spot.
Coordinate2D lerp(Coordinate2D target, double t) {
return Coordinate2D(
x + (target.x - x) * t,
y + (target.y - y) * t,
);
}
double get magnitude => math.sqrt(x * x + y * y);
Coordinate2D get normalized {
final m = magnitude;
if (m == 0) return const Coordinate2D(0, 0);
return Coordinate2D(x / m, y / m);
}
double dot(Coordinate2D other) => (x * other.x) + (y * other.y);
double distanceTo(Coordinate2D other) => (this - other).magnitude;
Coordinate2D operator +(Coordinate2D other) =>
Coordinate2D(x + other.x, y + other.y);
Coordinate2D operator -(Coordinate2D other) =>
Coordinate2D(x - other.x, y - other.y);
Coordinate2D operator *(double n) => Coordinate2D(x * n, y * n);
Coordinate2D operator /(double n) => Coordinate2D(x / n, y / n);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Coordinate2D && x == other.x && y == other.y;
@override
int get hashCode => Object.hash(x, y);
@override
int compareTo(Coordinate2D other) =>
x != other.x ? x.compareTo(other.x) : y.compareTo(other.y);
@override
String toString() => 'Coordinate2D($x, $y)';
}

View File

@@ -0,0 +1,11 @@
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!");
final String title;
final int level;
const Difficulty(this.level, this.title);
}

View File

@@ -0,0 +1,25 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class EnemyMapData {
final int baseId;
const EnemyMapData(this.baseId);
/// True if the ID falls anywhere within this enemy's 36-ID block
bool claimsId(int id) => id >= baseId && id < baseId + 36;
bool isStaticForDifficulty(int id, Difficulty difficulty) {
int start = baseId + (difficulty.level * 4);
return id >= start && id < start + 4;
}
bool isPatrolForDifficulty(int id, Difficulty difficulty) {
int start = baseId + 12 + (difficulty.level * 4);
return id >= start && id < start + 4;
}
bool isAmbushForDifficulty(int id, Difficulty difficulty) {
int start = baseId + 24 + (difficulty.level * 4);
return id >= start && id < start + 4;
}
}

View File

@@ -0,0 +1,8 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class Episode {
final String name;
final List<WolfLevel> levels;
const Episode({required this.name, required this.levels});
}

View File

@@ -0,0 +1,19 @@
import 'dart:typed_data';
class FrameBuffer {
final int width;
final int height;
// A 1D array representing the 2D screen.
// Length = 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)
void clear(int ceilingColor32, int floorColor32) {
int half = (width * height) ~/ 2;
pixels.fillRange(0, half, ceilingColor32);
pixels.fillRange(half, pixels.length, floorColor32);
}
}

View File

@@ -0,0 +1,14 @@
enum GameFile {
vswap('VSWAP'),
mapHead('MAPHEAD'),
gameMaps('GAMEMAPS'),
vgaDict('VGADICT'),
vgaHead('VGAHEAD'),
vgaGraph('VGAGRAPH'),
audioHed('AUDIOHED'),
audioT('AUDIOT');
final String baseName;
const GameFile(this.baseName);
}

View File

@@ -0,0 +1,11 @@
enum GameVersion {
shareware("WL1"),
retail("WL6"),
spearOfDestiny("SOD"),
spearOfDestinyDemo("SDM"),
;
final String fileExtension;
const GameVersion(this.fileExtension);
}

View File

@@ -0,0 +1,13 @@
import 'dart:typed_data';
class VgaImage {
final int width;
final int height;
final Uint8List pixels; // 8-bit paletted pixel data
VgaImage({
required this.width,
required this.height,
required this.pixels,
});
}

View File

@@ -0,0 +1,134 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
abstract class MapObject {
// --- Player Spawns ---
static const int playerNorth = 19;
static const int playerEast = 20;
static const int playerSouth = 21;
static const int playerWest = 22;
// --- Static Decorations ---
static const int waterPuddle = 23;
static const int greenBarrel = 24;
static const int chairTable = 25;
static const int floorLamp = 26;
static const int chandelier = 27;
static const int hangingSkeleton = 28;
static const int dogFoodDecoration = 29;
static const int whiteColumn = 30;
static const int pottedPlant = 31;
static const int blueSkeleton = 32;
static const int vent = 33;
static const int kitchenCans = 34;
static const int exitSign = 35;
static const int brownPlant = 36;
static const int bowl = 37;
static const int armoredSuit = 38;
static const int emptyCage = 39;
static const int cageWithSkeleton = 40;
static const int bones = 41;
static const int goldenKeyBowl = 42;
// --- Collectibles ---
static const int goldKey = 43;
static const int silverKey = 44;
static const int bed = 45;
static const int basket = 46;
static const int food = 47;
static const int medkit = 48;
static const int ammoClip = 49;
static const int machineGun = 50;
static const int chainGun = 51;
static const int cross = 52;
static const int chalice = 53;
static const int chest = 54;
static const int crown = 55;
static const int extraLife = 56;
// --- Environmental ---
static const int bloodPoolSmall = 57;
static const int barrel = 58;
static const int wellFull = 59;
static const int wellEmpty = 60;
static const int bloodPoolLarge = 61;
static const int flag = 62;
static const int aardwolfSign = 63;
static const int bonesAndSkull = 64;
static const int wallHanging = 65;
static const int stove = 66;
static const int spearRack = 67;
static const int vines = 68;
// --- Logic & Triggers ---
static const int pushwallTrigger = 98;
static const int secretExitTrigger = 99;
static const int normalExitTrigger = 100;
// --- Wall Textures (From VSWAP/MAPHEAD) ---
static const int normalElevatorSwitch = 21;
static const int secretElevatorSwitch = 41;
// Bosses (Shared between WL1 and WL6)
static const int bossHansGrosse = 214;
// WL6 Exclusive Bosses
static const int bossDrSchabbs = 215;
static const int bossTransGrosse = 216;
static const int bossUbermutant = 217;
static const int bossDeathKnight = 218;
static const int bossMechaHitler = 219;
static const int bossHitlerGhost = 220;
static const int bossGretelGrosse = 221;
static const int bossGiftmacher = 222;
static const int bossFettgesicht = 223;
// --- Enemy Range Constants ---
static const int guardStart = 108; // 108-143
static const int dogStart = 144; // 144-179
static const int ssStart = 180; // 180-215
static const int mutantStart = 216; // 216-251
static const int officerStart = 252; // 252-287
// --- Missing Decorative Bodies ---
static const int deadGuard = 124; // Decorative only in WL1
static const int deadAardwolf = 125; // Decorative only in WL1
static double getAngle(int id) {
switch (id) {
case playerNorth:
return CardinalDirection.north.radians;
case playerEast:
return CardinalDirection.east.radians;
case playerSouth:
return CardinalDirection.south.radians;
case playerWest:
return CardinalDirection.west.radians;
}
if (id == bossHansGrosse) return 0.0;
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
return CardinalDirection.fromEnemyIndex(id % 4).radians;
}
/// Determines if an object should be spawned on the current difficulty.
static bool isDifficultyAllowed(int objId, Difficulty difficulty) {
// 1. Dead bodies 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.
if (EnemyType.fromMapId(objId) != null) {
return true;
}
// 3. All non-enemy map objects (keys, ammo, puddles, plants)
// are NOT difficulty-tiered. They always spawn.
return true;
}
}

View File

@@ -0,0 +1,108 @@
import 'dart:typed_data';
class PcmSound {
final Uint8List bytes;
PcmSound(this.bytes);
}
class ImfInstruction {
final int register;
final int data;
final int delay; // Delay in 1/700ths of a second
ImfInstruction({
required this.register,
required this.data,
required this.delay,
});
}
class ImfMusic {
final List<ImfInstruction> instructions;
ImfMusic(this.instructions);
factory ImfMusic.fromBytes(Uint8List bytes) {
List<ImfInstruction> instructions = [];
// Wolfenstein 3D IMF chunks start with a 16-bit length header (little-endian)
int actualSize = bytes[0] | (bytes[1] << 8);
// Start parsing at index 2 to skip the size header
int limit = 2 + actualSize;
if (limit > bytes.length) limit = bytes.length; // Safety bounds
for (int i = 2; i < limit - 3; i += 4) {
instructions.add(
ImfInstruction(
register: bytes[i],
data: bytes[i + 1],
delay: bytes[i + 2] | (bytes[i + 3] << 8),
),
);
}
return ImfMusic(instructions);
}
}
typedef WolfMusicMap = List<int>;
/// Maps to the original sound indices from the Wolfenstein 3D source code.
/// Use these to index into `activeGame.sounds[id]`.
abstract class WolfSound {
// --- Doors & Environment ---
static const int openDoor = 8;
static const int closeDoor = 9;
static const int pushWall = 46; // Secret sliding walls
// --- Weapons & Combat ---
static const int knifeAttack = 23;
static const int pistolFire = 24;
static const int machineGunFire = 26;
static const int gatlingFire = 32; // Historically SHOOTSND in the source
static const int naziFire = 58; // Enemy gunshots
// --- Pickups & Items ---
static const int getMachineGun = 30;
static const int getAmmo = 31;
static const int getGatling = 38;
static const int healthSmall = 33; // Dog food / Meals
static const int healthLarge = 34; // First Aid
static const int treasure1 = 35; // Cross
static const int treasure2 = 36; // Chalice
static const int treasure3 = 37; // Chest
static const int treasure4 = 45; // Crown
static const int extraLife = 44; // 1-Up
// --- Enemies: Standard ---
static const int guardHalt = 21; // "Halt!"
static const int dogBark = 41;
static const int dogDeath = 62;
static const int dogAttack = 68;
static const int deathScream1 = 29;
static const int deathScream2 = 22;
static const int deathScream3 = 25;
static const int ssSchutzstaffel = 51; // "Schutzstaffel!"
static const int ssMeinGott = 63; // SS Death
// --- Enemies: Bosses (Retail Episodes 1-6) ---
static const int bossActive = 49;
static const int mutti = 50; // Hans Grosse Death
static const int ahhhg = 52;
static const int eva = 54; // Dr. Schabbs Death
static const int gutenTag = 55; // Hitler Greeting
static const int leben = 56;
static const int scheist = 57; // Hitler Death
static const int schabbsHas = 64; // Dr. Schabbs
static const int hitlerHas = 65;
static const int spion = 66; // Otto Giftmacher
static const int neinSoVass = 67; // Gretel Grosse Death
static const int mechSteps = 70; // Mecha-Hitler walking
// --- UI & Progression ---
static const int levelDone = 40;
static const int endBonus1 = 42;
static const int endBonus2 = 43;
static const int noBonus = 47;
static const int percent100 = 48;
}

View File

@@ -0,0 +1,21 @@
import 'dart:typed_data';
typedef Matrix<T> = List<List<T>>;
typedef SpriteMap = Matrix<int>;
class Sprite {
final Uint8List pixels;
Sprite(this.pixels);
// Factory to convert your 2D matrices into a 1D array during load time
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++) {
pixels[x * 64 + y] = matrix[x][y];
}
}
return Sprite(pixels);
}
}

View File

@@ -0,0 +1,10 @@
/// Defines the exact start and end sprite indices for an animation state.
class SpriteFrameRange {
final int start;
final int end;
const SpriteFrameRange(this.start, this.end);
int get length => end - start + 1;
bool contains(int index) => index >= start && index <= end;
}

View File

@@ -0,0 +1,15 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class WolfLevel {
final String name;
final SpriteMap wallGrid;
final SpriteMap objectGrid;
final int musicIndex;
const WolfLevel({
required this.name,
required this.wallGrid,
required this.objectGrid,
required this.musicIndex,
});
}

View File

@@ -0,0 +1,23 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class WolfensteinData {
final GameVersion version;
final List<Sprite> walls;
final List<Sprite> sprites;
final List<PcmSound> sounds;
final List<PcmSound> adLibSounds;
final List<ImfMusic> music;
final List<VgaImage> vgaImages;
final List<Episode> episodes;
const WolfensteinData({
required this.version,
required this.walls,
required this.sprites,
required this.sounds,
required this.adLibSounds,
required this.music,
required this.vgaImages,
required this.episodes,
});
}

View File

@@ -0,0 +1,12 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
abstract class EngineAudio {
WolfensteinData? activeGame;
Future<void> debugSoundTest();
void playMenuMusic();
void playLevelMusic(WolfLevel level);
void stopMusic();
void playSoundEffect(int sfxId);
Future<void> init();
void dispose();
}

View File

@@ -0,0 +1,39 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class CliSilentAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
@override
Future<void> init() async {
// No-op for CLI
}
@override
void playMenuMusic() {}
@override
void playLevelMusic(WolfLevel level) {
// Optional: Print a log so you know it's working!
// print("🎵 Playing music for: ${level.name} 🎵");
}
@override
void stopMusic() {}
@override
void playSoundEffect(int sfxId) {
// Optional: You could use the terminal 'bell' character here
// to actually make a system beep when a sound plays!
// stdout.write('\x07');
}
@override
void dispose() {}
@override
Future<void> debugSoundTest() async {
return Future.value(null);
}
}

View File

@@ -0,0 +1,22 @@
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
/// A pure, framework-agnostic snapshot of the player's intended actions for a single frame.
class EngineInput {
final bool isMovingForward;
final bool isMovingBackward;
final bool isTurningLeft;
final bool isTurningRight;
final bool isFiring;
final bool isInteracting;
final WeaponType? requestedWeapon;
const EngineInput({
this.isMovingForward = false,
this.isMovingBackward = false,
this.isTurningLeft = false,
this.isTurningRight = false,
this.isFiring = false,
this.isInteracting = false,
this.requestedWeapon,
});
}

View File

@@ -0,0 +1,79 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
class DoorManager {
// Key is '$x,$y'
final Map<String, Door> doors = {};
// Callback to play sounds without tightly coupling to the audio engine
final void Function(int sfxId) onPlaySound;
DoorManager({required this.onPlaySound});
void initDoors(SpriteMap wallGrid) {
doors.clear();
for (int y = 0; y < wallGrid.length; y++) {
for (int x = 0; x < wallGrid[y].length; x++) {
int id = wallGrid[y][x];
if (id >= 90) {
doors['$x,$y'] = Door(x: x, y: y, mapId: id);
}
}
}
}
void update(Duration elapsed) {
for (final door in doors.values) {
final newState = door.update(elapsed.inMilliseconds);
// The Manager decides: "If a door just started closing, play the close sound."
if (newState == DoorState.closing) {
onPlaySound(WolfSound.closeDoor);
}
}
}
void handleInteraction(double playerX, double playerY, double playerAngle) {
int targetX = (playerX + math.cos(playerAngle)).toInt();
int targetY = (playerY + math.sin(playerAngle)).toInt();
String key = '$targetX,$targetY';
if (doors.containsKey(key)) {
if (doors[key]!.interact()) {
// The Manager decides: "Player successfully opened a door, play the sound."
onPlaySound(WolfSound.openDoor);
}
}
}
void tryOpenDoor(int x, int y) {
String key = '$x,$y';
if (doors.containsKey(key) && doors[key]!.offset == 0.0) {
if (doors[key]!.interact()) {
onPlaySound(WolfSound.openDoor);
}
}
}
// Helper method for the raycaster
Map<String, double> getOffsetsForRenderer() {
Map<String, double> offsets = {};
for (var entry in doors.entries) {
if (entry.value.offset > 0.0) {
offsets[entry.key] = entry.value.offset;
}
}
return offsets;
}
bool isDoorOpenEnough(int x, int y) {
String key = '$x,$y';
if (doors.containsKey(key)) {
// 0.7 offset means 70% open, similar to the original engine's check
return doors[key]!.offset > 0.7;
}
return false; // Not a door we manage
}
}

View File

@@ -0,0 +1,120 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class Pushwall {
int x;
int y;
int mapId;
int dirX = 0;
int dirY = 0;
double offset = 0.0;
int tilesMoved = 0;
Pushwall(this.x, this.y, this.mapId);
}
class PushwallManager {
final Map<String, Pushwall> pushwalls = {};
Pushwall? activePushwall;
void initPushwalls(SpriteMap wallGrid, SpriteMap objectGrid) {
pushwalls.clear();
activePushwall = null;
for (int y = 0; y < objectGrid.length; y++) {
for (int x = 0; x < objectGrid[y].length; x++) {
if (objectGrid[y][x] == MapObject.pushwallTrigger) {
pushwalls['$x,$y'] = Pushwall(x, y, wallGrid[y][x]);
}
}
}
}
void update(Duration elapsed, SpriteMap wallGrid) {
if (activePushwall == null) return;
final pw = activePushwall!;
// Original logic: 1/128 tile per tick.
// At 70 ticks/sec, that is roughly 0.54 tiles per second.
const double originalSpeed = 0.546875;
pw.offset += (elapsed.inMilliseconds / 1000.0) * originalSpeed;
// Once it crosses a full tile boundary, we update the collision grid!
if (pw.offset >= 1.0) {
pw.offset -= 1.0;
pw.tilesMoved++;
int nextX = pw.x + pw.dirX;
int nextY = pw.y + pw.dirY;
// Move the solid block in the physical grid
wallGrid[nextY][nextX] = pw.mapId;
wallGrid[pw.y][pw.x] = 0; // Clear the old space so the player can walk in
// Update the dictionary key
pushwalls.remove('${pw.x},${pw.y}');
pw.x = nextX;
pw.y = nextY;
pushwalls['${pw.x},${pw.y}'] = pw;
// Check if we should keep sliding
bool blocked = false;
int checkX = pw.x + pw.dirX;
int checkY = pw.y + pw.dirY;
if (checkX < 0 ||
checkX >= wallGrid[0].length ||
checkY < 0 ||
checkY >= wallGrid.length) {
blocked = true;
} else if (wallGrid[checkY][checkX] != 0) {
blocked = true; // Blocked by another wall or a door
}
// Standard Wolf3D pushwalls move exactly 2 tiles (or 1 if blocked)
if (pw.tilesMoved >= 2 || blocked) {
activePushwall = null;
pw.offset = 0.0;
}
}
}
void handleInteraction(
double playerX,
double playerY,
double playerAngle,
SpriteMap wallGrid,
) {
// Only one pushwall can move at a time in the original engine!
if (activePushwall != null) return;
int targetX = (playerX + math.cos(playerAngle)).toInt();
int targetY = (playerY + math.sin(playerAngle)).toInt();
String key = '$targetX,$targetY';
if (pushwalls.containsKey(key)) {
final pw = pushwalls[key]!;
// Determine the push direction based on the player's relative position
double dx = (targetX + 0.5) - playerX;
double dy = (targetY + 0.5) - playerY;
if (dx.abs() > dy.abs()) {
pw.dirX = dx > 0 ? 1 : -1;
pw.dirY = 0;
} else {
pw.dirX = 0;
pw.dirY = dy > 0 ? 1 : -1;
}
// Make sure the tile behind the wall is empty before starting the push
int checkX = targetX + pw.dirX;
int checkY = targetY + pw.dirY;
if (wallGrid[checkY][checkX] == 0) {
activePushwall = pw;
}
}
}
}

View File

@@ -0,0 +1,267 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
enum WeaponSwitchState { idle, lowering, raising }
class Player {
// Spatial
double x;
double y;
double angle;
// Stats
int health = 100;
int ammo = 8;
int score = 0;
// Damage flash
double damageFlash = 0.0; // 0.0 is none, 1.0 is maximum red
final double damageFlashFadeSpeed = 0.05; // How fast it fades per tick
// Inventory
bool hasGoldKey = false;
bool hasSilverKey = false;
bool hasMachineGun = false;
bool hasChainGun = false;
// Weapon System
late Weapon currentWeapon;
final Map<WeaponType, Weapon?> weapons = {
WeaponType.knife: Knife(),
WeaponType.pistol: Pistol(),
WeaponType.machineGun: null,
WeaponType.chainGun: null,
};
WeaponSwitchState switchState = WeaponSwitchState.idle;
WeaponType? pendingWeaponType;
// 0.0 is resting, 500.0 is fully off-screen
double weaponAnimOffset = 0.0;
// How fast the weapon drops/raises per tick
final double switchSpeed = 30.0;
Player({required this.x, required this.y, required this.angle}) {
currentWeapon = weapons[WeaponType.pistol]!;
}
// Helper getter to interface with the RaycasterPainter
Coordinate2D get position => Coordinate2D(x, y);
// --- General Update ---
void tick(Duration elapsed) {
// Fade the damage flash over time
if (damageFlash > 0.0) {
// Assuming 60fps, we fade it out
damageFlash = math.max(0.0, damageFlash - damageFlashFadeSpeed);
}
updateWeaponSwitch();
}
// --- Weapon Switching & Animation Logic ---
void updateWeaponSwitch() {
if (switchState == WeaponSwitchState.lowering) {
// If the map doesn't contain the pending weapon, stop immediately
if (weapons[pendingWeaponType] == null) {
switchState = WeaponSwitchState.idle;
return;
}
weaponAnimOffset += switchSpeed;
if (weaponAnimOffset >= 500.0) {
weaponAnimOffset = 500.0;
// We already know it's not null now, but we can keep the
// fallback to pistol just to be extra safe.
currentWeapon = weapons[pendingWeaponType]!;
switchState = WeaponSwitchState.raising;
}
} else if (switchState == WeaponSwitchState.raising) {
weaponAnimOffset -= switchSpeed;
if (weaponAnimOffset <= 0) {
weaponAnimOffset = 0.0;
switchState = WeaponSwitchState.idle;
}
}
}
void requestWeaponSwitch(WeaponType weaponType) {
if (switchState != WeaponSwitchState.idle) return;
if (currentWeapon.state != WeaponState.idle) return;
if (weaponType == currentWeapon.type) return;
if (!weapons.containsKey(weaponType)) return;
if (weaponType != WeaponType.knife && ammo <= 0) return;
pendingWeaponType = weaponType;
switchState = WeaponSwitchState.lowering;
}
// --- Health & Damage ---
void takeDamage(int damage) {
health = math.max(0, health - damage);
// Spike the damage flash based on how much damage was taken
// A 10 damage hit gives a 0.5 flash, a 20 damage hit maxes it out at 1.0
damageFlash = math.min(1.0, damageFlash + (damage * 0.05));
if (health <= 0) {
print("YOU DIED!");
} else {
print("Ouch! ($health)");
}
}
void heal(int amount) {
final int newHealth = math.min(100, health + amount);
if (health < 100) {
print("Feelin' better. ($newHealth)");
}
health = newHealth;
}
void addAmmo(int amount) {
final int newAmmo = math.min(99, ammo + amount);
if (ammo < 99) {
print("Hell yeah. ($newAmmo)");
}
ammo = newAmmo;
}
bool tryPickup(Collectible item) {
bool pickedUp = false;
switch (item.type) {
case CollectibleType.health:
if (health >= 100) return false;
heal(item.mapId == MapObject.dogFoodDecoration ? 4 : 25);
pickedUp = true;
break;
case CollectibleType.ammo:
if (ammo >= 99) return false;
int previousAmmo = ammo;
addAmmo(8);
if (currentWeapon is Knife && previousAmmo <= 0) {
requestWeaponSwitch(WeaponType.pistol);
}
pickedUp = true;
break;
case CollectibleType.treasure:
if (item.mapId == MapObject.cross) score += 100;
if (item.mapId == MapObject.chalice) score += 500;
if (item.mapId == MapObject.chest) score += 1000;
if (item.mapId == MapObject.crown) score += 5000;
if (item.mapId == MapObject.extraLife) {
heal(100);
addAmmo(25);
}
pickedUp = true;
break;
case CollectibleType.weapon:
if (item.mapId == MapObject.machineGun) {
if (weapons[WeaponType.machineGun] == null) {
weapons[WeaponType.machineGun] = MachineGun();
hasMachineGun = true;
}
addAmmo(8);
requestWeaponSwitch(WeaponType.machineGun);
pickedUp = true;
}
if (item.mapId == MapObject.chainGun) {
if (weapons[WeaponType.chainGun] == null) {
weapons[WeaponType.chainGun] = ChainGun();
hasChainGun = true;
}
addAmmo(8);
requestWeaponSwitch(WeaponType.chainGun);
pickedUp = true;
}
break;
case CollectibleType.key:
if (item.mapId == MapObject.goldKey) hasGoldKey = true;
if (item.mapId == MapObject.silverKey) hasSilverKey = true;
pickedUp = true;
break;
}
return pickedUp;
}
void fire(int currentTime) {
if (switchState != WeaponSwitchState.idle) return;
// We pass the isFiring state to handle automatic vs semi-auto behavior
bool shotFired = currentWeapon.fire(currentTime, currentAmmo: ammo);
if (shotFired && currentWeapon.type != WeaponType.knife) {
ammo--;
}
}
void releaseTrigger() {
currentWeapon.releaseTrigger();
}
/// Returns true only on the specific frame where the hit should be calculated
void updateWeapon({
required int currentTime,
required List<Entity> entities,
required bool Function(int x, int y) isWalkable,
}) {
int oldFrame = currentWeapon.frameIndex;
currentWeapon.update(currentTime);
// If we just crossed into the firing frame...
if (currentWeapon.state == WeaponState.firing &&
oldFrame == 0 &&
currentWeapon.frameIndex == 1) {
currentWeapon.performHitscan(
playerX: x,
playerY: y,
playerAngle: angle,
entities: entities,
isWalkable: isWalkable,
currentTime: currentTime,
onEnemyKilled: (Enemy killedEnemy) {
// Dynamic scoring based on the enemy type!
int pointsToAdd = 0;
switch (killedEnemy.runtimeType.toString()) {
case 'BrownGuard':
pointsToAdd = 100;
break;
case 'Dog':
pointsToAdd = 200;
break;
// You can easily plug in future enemies here!
// case 'SSOfficer': pointsToAdd = 500; break;
default:
pointsToAdd = 100; // Fallback
}
score += pointsToAdd;
// Optional: Print to console so you can see it working
print(
"Killed ${killedEnemy.runtimeType}! +$pointsToAdd (Score: $score)",
);
},
);
}
if (currentWeapon.state == WeaponState.idle &&
ammo <= 0 &&
currentWeapon.type != WeaponType.knife) {
requestWeaponSwitch(WeaponType.knife);
}
}
}

View File

@@ -0,0 +1,522 @@
import 'dart:math' as math;
import 'package:arcane_helper_utils/arcane_helper_utils.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class AsciiTheme {
/// The character ramp, ordered from most dense (index 0) to least dense (last index).
final String ramp;
const AsciiTheme(this.ramp);
/// Always returns the densest character (e.g., for walls, UI, floors)
String get solid => ramp[0];
/// Always returns the completely empty character (e.g., for pitch black darkness)
String get empty => ramp[ramp.length - 1];
/// Returns a character based on a 0.0 to 1.0 brightness scale.
/// 1.0 returns the [solid] character, 0.0 returns the [empty] character.
String getByBrightness(double brightness) {
double b = brightness.clamp(0.0, 1.0);
int index = ((1.0 - b) * (ramp.length - 1)).round();
return ramp[index];
}
}
/// A collection of pre-defined character sets
abstract class AsciiThemes {
static const AsciiTheme blocks = AsciiTheme("█▓▒░ ");
static const AsciiTheme classic = AsciiTheme("@%#*+=-:. ");
static const AsciiTheme dense = AsciiTheme("█▇▆▅▄▃▂ ");
}
class ColoredChar {
final String char;
final int rawColor; // Stores the AABBGGRR integer from the palette
ColoredChar(this.char, this.rawColor);
// Safely extract the exact RGB channels regardless of framework
int get r => rawColor & 0xFF;
int get g => (rawColor >> 8) & 0xFF;
int get b => (rawColor >> 16) & 0xFF;
// Outputs standard AARRGGBB for Flutter's Color(int) constructor
int get argb => (0xFF000000) | (r << 16) | (g << 8) | b;
}
class AsciiRasterizer extends Rasterizer {
AsciiRasterizer({
this.activeTheme = AsciiThemes.blocks,
this.aspectMultiplier = 1.0,
this.verticalStretch = 1.0,
});
AsciiTheme activeTheme = AsciiThemes.blocks;
late List<List<ColoredChar>> _screen;
late WolfEngine _engine;
@override
final double aspectMultiplier;
@override
final double verticalStretch;
// Intercept the base render call to initialize our text grid
@override
dynamic render(WolfEngine engine, FrameBuffer buffer) {
_engine = engine;
_screen = List.generate(
buffer.height,
(_) =>
List.filled(buffer.width, ColoredChar(' ', ColorPalette.vga32Bit[0])),
);
return super.render(engine, buffer);
}
@override
void prepareFrame(WolfEngine engine) {
// Just grab the raw ints!
final int ceilingColor = ColorPalette.vga32Bit[25];
final int floorColor = ColorPalette.vga32Bit[29];
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
if (y < viewHeight / 2) {
_screen[y][x] = ColoredChar(activeTheme.solid, ceilingColor);
} else if (y < viewHeight) {
_screen[y][x] = ColoredChar(activeTheme.solid, floorColor);
}
}
}
}
@override
void drawWallColumn(
int x,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
) {
double brightness = calculateDepthBrightness(perpWallDist);
String wallChar = activeTheme.getByBrightness(brightness);
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];
int pixelColor = ColorPalette.vga32Bit[colorByte]; // Raw int
// Faux directional lighting using your new base class method
if (side == 1) {
pixelColor = shadeColor(pixelColor);
}
_screen[y][x] = ColoredChar(wallChar, pixelColor);
}
}
@override
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
) {
double brightness = calculateDepthBrightness(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];
if (colorByte != 255) {
int rawColor = ColorPalette.vga32Bit[colorByte];
// Shade the sprite's actual RGB color based on distance
int r = (rawColor & 0xFF);
int g = ((rawColor >> 8) & 0xFF);
int b = ((rawColor >> 16) & 0xFF);
r = (r * brightness).toInt();
g = (g * brightness).toInt();
b = (b * brightness).toInt();
int shadedColor = (0xFF000000) | (b << 16) | (g << 8) | r;
// Force sprites to be SOLID so they don't vanish into the terminal background
_screen[y][stripeX] = ColoredChar(activeTheme.solid, shadedColor);
}
}
}
@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][drawX] = ColoredChar(
activeTheme.solid,
ColorPalette.vga32Bit[colorByte],
);
}
}
}
}
}
// --- PRIVATE HUD DRAWING HELPER ---
/// Injects a pure text string directly into the rasterizer grid
void _writeString(int startX, int y, String text, int color) {
for (int i = 0; i < text.length; i++) {
int x = startX + i;
if (x >= 0 && x < width && y >= 0 && y < height) {
_screen[y][x] = ColoredChar(text[i], color);
}
}
}
@override
void drawHud(WolfEngine engine) {
// If the terminal is at least 160 columns wide and 50 rows tall,
// there are enough "pixels" to downscale the VGA image clearly.
if (width >= 160 && height >= 50) {
_drawFullVgaHud(engine);
} else {
_drawSimpleHud(engine);
}
}
void _drawSimpleHud(WolfEngine engine) {
// 1. Pull Retro Colors
final int vgaStatusBarBlue = ColorPalette.vga32Bit[153];
final int vgaPanelDark = ColorPalette.vga32Bit[0];
final int white = ColorPalette.vga32Bit[15];
final int yellow = ColorPalette.vga32Bit[11];
final int red = ColorPalette.vga32Bit[4];
// 2. Setup Centered Layout
// The total width of our standard HUD elements is roughly 120 chars
const int hudContentWidth = 120;
final int offsetX = ((width - hudContentWidth) ~/ 2).clamp(0, width);
// 3. Clear HUD Base
_fillRect(0, viewHeight, width, height - viewHeight, ' ', vgaStatusBarBlue);
_writeString(0, viewHeight, "" * width, white);
// 4. Panel Drawing Helper
void drawBorderedPanel(int startX, int startY, int w, int h) {
_fillRect(startX, startY, w, h, ' ', vgaPanelDark);
// Horizontal lines
_writeString(startX, startY, "${"" * (w - 2)}", white);
_writeString(startX, startY + h - 1, "${"" * (w - 2)}", white);
// Vertical sides
for (int i = 1; i < h - 1; i++) {
_writeString(startX, startY + i, "", white);
_writeString(startX + w - 1, startY + i, "", white);
}
}
// 5. Draw the Panels
// FLOOR
drawBorderedPanel(offsetX + 4, viewHeight + 2, 12, 5);
_writeString(offsetX + 7, viewHeight + 3, "FLOOR", white);
_writeString(
offsetX + 9,
viewHeight + 5,
engine.activeLevel.name.split(' ').last,
white,
);
// SCORE
drawBorderedPanel(offsetX + 18, viewHeight + 2, 24, 5);
_writeString(offsetX + 27, viewHeight + 3, "SCORE", white);
_writeString(
offsetX + 27,
viewHeight + 5,
engine.player.score.toString().padLeft(6, '0'),
white,
);
// LIVES
drawBorderedPanel(offsetX + 44, viewHeight + 2, 12, 5);
_writeString(offsetX + 47, viewHeight + 3, "LIVES", white);
_writeString(offsetX + 49, viewHeight + 5, "3", white);
// FACE (With Reactive BJ Logic)
drawBorderedPanel(offsetX + 58, viewHeight + 1, 14, 7);
String face = " :-)";
if (engine.player.health <= 0) {
face = " X-x";
} else if (engine.player.damageFlash > 0.1) {
face = " :-O"; // Mouth open in pain!
} else if (engine.player.health <= 25) {
face = " :-(";
} else if (engine.player.health <= 60) {
face = " :-|";
}
_writeString(offsetX + 63, viewHeight + 4, face, yellow);
// HEALTH
int healthColor = engine.player.health > 25 ? white : red;
drawBorderedPanel(offsetX + 74, viewHeight + 2, 16, 5);
_writeString(offsetX + 78, viewHeight + 3, "HEALTH", white);
_writeString(
offsetX + 79,
viewHeight + 5,
"${engine.player.health}%",
healthColor,
);
// AMMO
drawBorderedPanel(offsetX + 92, viewHeight + 2, 12, 5);
_writeString(offsetX + 95, viewHeight + 3, "AMMO", white);
_writeString(offsetX + 97, viewHeight + 5, "${engine.player.ammo}", white);
// WEAPON
drawBorderedPanel(offsetX + 106, viewHeight + 2, 14, 5);
String weapon = engine.player.currentWeapon.type.name.spacePascalCase!
.toUpperCase();
if (weapon.length > 12) weapon = weapon.substring(0, 12);
_writeString(offsetX + 107, viewHeight + 4, weapon, white);
}
void _drawFullVgaHud(WolfEngine engine) {
int statusBarIndex = engine.data.vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
// 1. Draw Background
_blitVgaImageAscii(engine.data.vgaImages[statusBarIndex], 0, 160);
// 2. Draw Stats
_drawNumberAscii(1, 32, 176, engine.data.vgaImages); // Floor
_drawNumberAscii(
engine.player.score,
96,
176,
engine.data.vgaImages,
); // Score
_drawNumberAscii(3, 120, 176, engine.data.vgaImages); // Lives
_drawNumberAscii(
engine.player.health,
192,
176,
engine.data.vgaImages,
); // Health
_drawNumberAscii(
engine.player.ammo,
232,
176,
engine.data.vgaImages,
); // Ammo
// 3. Draw BJ's Face & Current Weapon
_drawFaceAscii(engine);
_drawWeaponIconAscii(engine);
}
void _drawNumberAscii(
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) {
_blitVgaImageAscii(vgaImages[zeroIndex + digit], currentX, startY);
}
currentX += 8;
}
}
void _drawFaceAscii(WolfEngine engine) {
int health = engine.player.health;
int faceIndex;
if (health <= 0) {
faceIndex = 127;
} else {
int healthTier = ((100 - health) ~/ 16).clamp(0, 6);
faceIndex = 106 + (healthTier * 3);
}
if (faceIndex < engine.data.vgaImages.length) {
_blitVgaImageAscii(engine.data.vgaImages[faceIndex], 136, 164);
}
}
void _drawWeaponIconAscii(WolfEngine engine) {
int weaponIndex = 89;
if (engine.player.hasChainGun) {
weaponIndex = 91;
} else if (engine.player.hasMachineGun) {
weaponIndex = 90;
}
if (weaponIndex < engine.data.vgaImages.length) {
_blitVgaImageAscii(engine.data.vgaImages[weaponIndex], 256, 164);
}
}
/// Helper to fill a rectangular area with a specific char and background color
void _fillRect(int startX, int startY, int w, int h, String char, int color) {
for (int dy = 0; dy < h; dy++) {
for (int dx = 0; dx < w; dx++) {
int x = startX + dx;
int y = startY + dy;
if (x >= 0 && x < width && y >= 0 && y < height) {
_screen[y][x] = ColoredChar(char, color);
}
}
}
}
@override
dynamic finalizeFrame() {
if (_engine.player.damageFlash > 0.0) {
_applyDamageFlash();
}
return _screen;
}
// --- PRIVATE HUD DRAWING HELPERS ---
void _blitVgaImageAscii(VgaImage image, int startX_320, int startY_200) {
int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height;
double scaleX = width / 320.0;
double scaleY = height / 200.0;
int destStartX = (startX_320 * scaleX).toInt();
int destStartY = (startY_200 * scaleY).toInt();
int destWidth = (image.width * scaleX).toInt();
int destHeight = (image.height * scaleY).toInt();
for (int dy = 0; dy < destHeight; dy++) {
for (int dx = 0; dx < destWidth; dx++) {
int drawX = destStartX + dx;
int drawY = destStartY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1);
int srcY = (dy / scaleY).toInt().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][drawX] = ColoredChar(
activeTheme.solid,
ColorPalette.vga32Bit[colorByte],
);
}
}
}
}
}
// --- DAMAGE FLASH ---
void _applyDamageFlash() {
double intensity = _engine.player.damageFlash;
int redBoost = (150 * intensity).toInt();
double colorDrop = 1.0 - (0.5 * intensity);
for (int y = 0; y < viewHeight; y++) {
for (int x = 0; x < width; x++) {
ColoredChar cell = _screen[y][x];
// Use our safe getters!
int r = cell.r;
int g = cell.g;
int b = cell.b;
r = (r + redBoost).clamp(0, 255);
g = (g * colorDrop).toInt().clamp(0, 255);
b = (b * colorDrop).toInt().clamp(0, 255);
// Pack back into the native AABBGGRR format that ColoredChar expects
int newRawColor = (0xFF000000) | (b << 16) | (g << 8) | r;
_screen[y][x] = ColoredChar(cell.char, newRawColor);
}
}
}
/// Converts the current frame to a single printable ANSI string
String toAnsiString() {
StringBuffer buffer = StringBuffer();
int lastR = -1;
int lastG = -1;
int lastB = -1;
for (int y = 0; y < _screen.length; y++) {
List<ColoredChar> row = _screen[y];
for (ColoredChar cell in row) {
if (cell.r != lastR || cell.g != lastG || cell.b != lastB) {
buffer.write('\x1b[38;2;${cell.r};${cell.g};${cell.b}m');
lastR = cell.r;
lastG = cell.g;
lastB = cell.b;
}
buffer.write(cell.char);
}
// Only print a newline if we are NOT on the very last row.
// This stops the terminal from scrolling down!
if (y < _screen.length - 1) {
buffer.write('\n');
}
}
// Reset the terminal color at the very end
buffer.write('\x1b[0m');
return buffer.toString();
}
}

View File

@@ -0,0 +1,388 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
abstract class Rasterizer {
late List<double> zBuffer;
late int width;
late int height;
late int viewHeight;
/// A multiplier to adjust the width of sprites.
/// Pixel renderers usually keep this at 1.0.
/// ASCII renderers can override this (e.g., 0.6) to account for tall characters.
double get aspectMultiplier => 1.0;
/// A multiplier to counteract tall pixel formats (like 1:2 terminal fonts).
/// Defaults to 1.0 (no squish) for standard pixel rendering.
double get verticalStretch => 1.0;
/// The main entry point called by the game loop.
/// Orchestrates the mathematical rendering pipeline.
dynamic render(WolfEngine engine, FrameBuffer buffer) {
width = buffer.width;
height = buffer.height;
// The 3D view typically takes up the top 80% of the screen
viewHeight = (height * 0.8).toInt();
zBuffer = List.filled(width, 0.0);
// 1. Setup the frame (clear screen, draw floor/ceiling)
prepareFrame(engine);
// 2. Do the heavy math for Raycasting Walls
_castWalls(engine);
// 3. Do the heavy math for Projecting Sprites
_castSprites(engine);
// 4. Draw 2D Overlays
drawWeapon(engine);
drawHud(engine);
// 5. Finalize and return the frame data (Buffer or String/List)
return finalizeFrame();
}
// ===========================================================================
// ABSTRACT METHODS (Implemented by the child renderers)
// ===========================================================================
/// Initialize buffers, clear the screen, and draw the floor/ceiling.
void prepareFrame(WolfEngine engine);
/// Draw a single vertical column of a wall.
void drawWallColumn(
int x,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
);
/// Draw a single vertical stripe of a sprite (enemy/item).
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
);
/// Draw the player's weapon overlay at the bottom of the 3D view.
void drawWeapon(WolfEngine engine);
/// Draw the 2D status bar at the bottom 20% of the screen.
void drawHud(WolfEngine engine);
/// Return the finished frame (e.g., the FrameBuffer itself, or an ASCII list).
dynamic finalizeFrame();
// ===========================================================================
// SHARED LIGHTING MATH
// ===========================================================================
/// Calculates depth-based lighting falloff (0.0 to 1.0).
/// While the original Wolf3D didn't use depth fog, this provides a great
/// atmospheric effect for custom renderers (like ASCII dithering).
double calculateDepthBrightness(double distance) {
return (10.0 / (distance + 2.0)).clamp(0.0, 1.0);
}
// ===========================================================================
// CORE ENGINE MATH (Shared across all renderers)
// ===========================================================================
void _castWalls(WolfEngine engine) {
final Player player = engine.player;
final SpriteMap map = engine.currentLevel;
final List<Sprite> wallTextures = engine.data.walls;
final Map<String, double> doorOffsets = engine.doorManager
.getOffsetsForRenderer();
final Pushwall? activePushwall = engine.pushwallManager.activePushwall;
final double fov = math.pi / 3;
Coordinate2D dir = Coordinate2D(
math.cos(player.angle),
math.sin(player.angle),
);
Coordinate2D plane = Coordinate2D(-dir.y, dir.x) * math.tan(fov / 2);
for (int x = 0; x < width; x++) {
double cameraX = 2 * x / width - 1.0;
Coordinate2D rayDir = dir + (plane * cameraX);
int mapX = player.x.toInt();
int mapY = player.y.toInt();
double deltaDistX = (rayDir.x == 0) ? 1e30 : (1.0 / rayDir.x).abs();
double deltaDistY = (rayDir.y == 0) ? 1e30 : (1.0 / rayDir.y).abs();
double sideDistX, sideDistY, perpWallDist = 0.0;
int stepX, stepY, side = 0, hitWallId = 0;
bool hit = false, hitOutOfBounds = false, customDistCalculated = false;
double textureOffset = 0.0;
Set<String> ignoredDoors = {};
if (rayDir.x < 0) {
stepX = -1;
sideDistX = (player.x - mapX) * deltaDistX;
} else {
stepX = 1;
sideDistX = (mapX + 1.0 - player.x) * deltaDistX;
}
if (rayDir.y < 0) {
stepY = -1;
sideDistY = (player.y - mapY) * deltaDistY;
} else {
stepY = 1;
sideDistY = (mapY + 1.0 - player.y) * deltaDistY;
}
// DDA Loop
while (!hit) {
if (sideDistX < sideDistY) {
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
} else {
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
if (mapY < 0 ||
mapY >= map.length ||
mapX < 0 ||
mapX >= map[0].length) {
hit = true;
hitOutOfBounds = true;
} else if (map[mapY][mapX] > 0) {
String mapKey = '$mapX,$mapY';
// DOOR LOGIC
if (map[mapY][mapX] >= 90 && !ignoredDoors.contains(mapKey)) {
double currentOffset = doorOffsets[mapKey] ?? 0.0;
if (currentOffset > 0.0) {
double perpWallDistTemp = (side == 0)
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
double wallXTemp = (side == 0)
? player.y + perpWallDistTemp * rayDir.y
: player.x + perpWallDistTemp * rayDir.x;
wallXTemp -= wallXTemp.floor();
if (wallXTemp < currentOffset) {
ignoredDoors.add(mapKey);
continue; // Ray passes through the open part of the door
}
}
hit = true;
hitWallId = map[mapY][mapX];
textureOffset = currentOffset;
}
// PUSHWALL LOGIC
else if (activePushwall != null &&
mapX == activePushwall.x &&
mapY == activePushwall.y) {
hit = true;
hitWallId = map[mapY][mapX];
double pOffset = activePushwall.offset;
int pDirX = activePushwall.dirX;
int pDirY = activePushwall.dirY;
perpWallDist = (side == 0)
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
if (side == 0 && pDirX != 0) {
if (pDirX == stepX) {
double intersect = perpWallDist + pOffset * deltaDistX;
if (intersect < sideDistY) {
perpWallDist = intersect;
} else {
side = 1;
perpWallDist = sideDistY - deltaDistY;
}
} else {
perpWallDist -= (1.0 - pOffset) * deltaDistX;
}
} else if (side == 1 && pDirY != 0) {
if (pDirY == stepY) {
double intersect = perpWallDist + pOffset * deltaDistY;
if (intersect < sideDistX) {
perpWallDist = intersect;
} else {
side = 0;
perpWallDist = sideDistX - deltaDistX;
}
} else {
perpWallDist -= (1.0 - pOffset) * deltaDistY;
}
} else {
double wallFraction = (side == 0)
? player.y + perpWallDist * rayDir.y
: player.x + perpWallDist * rayDir.x;
wallFraction -= wallFraction.floor();
if (side == 0) {
if (pDirY == 1 && wallFraction < pOffset) hit = false;
if (pDirY == -1 && wallFraction > (1.0 - pOffset)) hit = false;
if (hit) textureOffset = pOffset * pDirY;
} else {
if (pDirX == 1 && wallFraction < pOffset) hit = false;
if (pDirX == -1 && wallFraction > (1.0 - pOffset)) hit = false;
if (hit) textureOffset = pOffset * pDirX;
}
}
if (!hit) continue;
customDistCalculated = true;
} else {
hit = true;
hitWallId = map[mapY][mapX];
}
}
}
if (hitOutOfBounds) continue;
if (!customDistCalculated) {
perpWallDist = (side == 0)
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
}
if (perpWallDist < 0.1) perpWallDist = 0.1;
// Save for sprite depth checks
zBuffer[x] = perpWallDist;
// Calculate Texture X Coordinate
double wallX = (side == 0)
? player.y + perpWallDist * rayDir.y
: player.x + perpWallDist * rayDir.x;
wallX -= wallX.floor();
int texNum;
if (hitWallId >= 90) {
texNum = 98.clamp(0, wallTextures.length - 1);
} else {
texNum = ((hitWallId - 1) * 2).clamp(0, wallTextures.length - 2);
if (side == 1) texNum += 1;
}
Sprite texture = wallTextures[texNum];
// Texture flipping for specific orientations
int texX = (((wallX - textureOffset) % 1.0) * 64).toInt().clamp(0, 63);
if (side == 0 && math.cos(player.angle) > 0) texX = 63 - texX;
if (side == 1 && math.sin(player.angle) < 0) texX = 63 - texX;
// Calculate drawing dimensions
int columnHeight = ((viewHeight / perpWallDist) * verticalStretch)
.toInt();
int drawStart = (-columnHeight ~/ 2 + viewHeight ~/ 2).clamp(
0,
viewHeight,
);
int drawEnd = (columnHeight ~/ 2 + viewHeight ~/ 2).clamp(0, viewHeight);
// Tell the implementation to draw this column
drawWallColumn(
x,
drawStart,
drawEnd,
columnHeight,
texture,
texX,
perpWallDist,
side,
);
}
}
void _castSprites(WolfEngine engine) {
final Player player = engine.player;
final List<Entity> activeSprites = List.from(engine.entities);
// Sort from furthest to closest (Painter's Algorithm)
activeSprites.sort((a, b) {
double distA = player.position.distanceTo(a.position);
double distB = player.position.distanceTo(b.position);
return distB.compareTo(distA);
});
Coordinate2D dir = Coordinate2D(
math.cos(player.angle),
math.sin(player.angle),
);
Coordinate2D plane =
Coordinate2D(-dir.y, dir.x) * math.tan((math.pi / 3) / 2);
for (Entity entity in activeSprites) {
Coordinate2D spritePos = entity.position - player.position;
double invDet = 1.0 / (plane.x * dir.y - dir.x * plane.y);
double transformX = invDet * (dir.y * spritePos.x - dir.x * spritePos.y);
double transformY =
invDet * (-plane.y * spritePos.x + plane.x * spritePos.y);
// Only process if the sprite is in front of the camera
if (transformY > 0) {
int spriteScreenX = ((width / 2) * (1 + transformX / transformY))
.toInt();
int spriteHeight = ((viewHeight / transformY).abs() * verticalStretch)
.toInt();
// Scale width based on the aspectMultiplier (useful for ASCII)
int spriteWidth = (spriteHeight * aspectMultiplier / verticalStretch)
.toInt();
int drawStartY = -spriteHeight ~/ 2 + viewHeight ~/ 2;
int drawEndY = spriteHeight ~/ 2 + viewHeight ~/ 2;
int drawStartX = -spriteWidth ~/ 2 + spriteScreenX;
int drawEndX = spriteWidth ~/ 2 + spriteScreenX;
int clipStartX = math.max(0, drawStartX);
int clipEndX = math.min(width - 1, drawEndX);
int safeIndex = entity.spriteIndex.clamp(
0,
engine.data.sprites.length - 1,
);
Sprite texture = engine.data.sprites[safeIndex];
// Loop through the visible vertical stripes
for (int stripe = clipStartX; stripe < clipEndX; stripe++) {
// Check the Z-Buffer to see if a wall is in front of this stripe
if (transformY < zBuffer[stripe]) {
int texX = ((stripe - drawStartX) * 64 ~/ spriteWidth).clamp(0, 63);
// Tell the implementation to draw this stripe
drawSpriteStripe(
stripe,
drawStartY,
drawEndY,
spriteHeight,
texture,
texX,
transformY,
);
}
}
}
}
}
/// Darkens a 32-bit 0xAABBGGRR color by roughly 30% without touching Alpha
int shadeColor(int color) {
int r = (color & 0xFF) * 7 ~/ 10;
int g = ((color >> 8) & 0xFF) * 7 ~/ 10;
int b = ((color >> 16) & 0xFF) * 7 ~/ 10;
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
}

View File

@@ -0,0 +1,264 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class SoftwareRasterizer extends Rasterizer {
late FrameBuffer _buffer;
late WolfEngine _engine;
// Intercept the base render call to store our references
@override
dynamic render(WolfEngine engine, FrameBuffer buffer) {
_engine = engine;
_buffer = buffer;
return super.render(engine, buffer);
}
@override
void prepareFrame(WolfEngine engine) {
// Top half is ceiling color (25), bottom half is floor color (29)
int ceilingColor = ColorPalette.vga32Bit[25];
int floorColor = ColorPalette.vga32Bit[29];
for (int y = 0; y < viewHeight; y++) {
int color = (y < viewHeight / 2) ? ceilingColor : floorColor;
for (int x = 0; x < width; x++) {
_buffer.pixels[y * width + x] = color;
}
}
}
@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++) {
// Calculate which Y pixel of the texture to sample
double relativeY =
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
int colorByte = texture.pixels[texX * 64 + texY];
int pixelColor = ColorPalette.vga32Bit[colorByte];
// Darken Y-side walls for faux directional lighting
if (side == 1) {
pixelColor = shadeColor(pixelColor);
}
_buffer.pixels[y * width + x] = pixelColor;
}
}
@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 in VGA Wolfenstein
if (colorByte != 255) {
_buffer.pixels[y * width + stripeX] = ColorPalette.vga32Bit[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) {
_buffer.pixels[drawY * width + drawX] =
ColorPalette.vga32Bit[colorByte];
}
}
}
}
}
@override
void drawHud(WolfEngine engine) {
int statusBarIndex = engine.data.vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
// 1. Draw Background
_blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160);
// 2. Draw Stats (100% mathematically accurate right-aligned coordinates)
_drawNumber(1, 32, 176, engine.data.vgaImages); // Floor
_drawNumber(engine.player.score, 96, 176, engine.data.vgaImages); // Score
_drawNumber(3, 120, 176, engine.data.vgaImages); // Lives
_drawNumber(
engine.player.health,
192,
176,
engine.data.vgaImages,
); // Health
_drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages); // Ammo
// 3. Draw BJ's Face & Current Weapon
_drawFace(engine);
_drawWeaponIcon(engine);
}
@override
FrameBuffer finalizeFrame() {
// If the player took damage, overlay a red tint across the 3D view
if (_engine.player.damageFlash > 0) {
_applyDamageFlash();
}
return _buffer; // Return the fully painted pixel array
}
// ===========================================================================
// PRIVATE HELPER METHODS
// ===========================================================================
/// Maps the planar VGA image data directly to 32-bit pixels.
/// (Assuming a 1:1 scale, which is standard for the 320x200 software renderer).
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) {
_buffer.pixels[drawY * width + drawX] =
ColorPalette.vga32Bit[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;
if (health <= 0) {
faceIndex = 127; // Dead face
} else {
int healthTier = ((100 - health) ~/ 16).clamp(0, 6);
faceIndex = 106 + (healthTier * 3);
}
if (faceIndex < engine.data.vgaImages.length) {
_blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164);
}
}
void _drawWeaponIcon(WolfEngine engine) {
int weaponIndex = 89; // Default to Pistol
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);
}
}
/// Tints the top 80% of the screen red based on player.damageFlash intensity
void _applyDamageFlash() {
// Grab the intensity (0.0 to 1.0)
double intensity = _engine.player.damageFlash;
// Calculate how much to boost red and drop green/blue
int redBoost = (150 * intensity).toInt();
double colorDrop = 1.0 - (0.5 * intensity);
for (int y = 0; y < viewHeight; y++) {
for (int x = 0; x < width; x++) {
int index = y * width + x;
int color = _buffer.pixels[index];
int r = color & 0xFF;
int g = (color >> 8) & 0xFF;
int b = (color >> 16) & 0xFF;
r = (r + redBoost).clamp(0, 255);
g = (g * colorDrop).toInt();
b = (b * colorDrop).toInt();
_buffer.pixels[index] = (0xFF000000) | (b << 16) | (g << 8) | r;
}
}
}
}

View File

@@ -0,0 +1,356 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
class WolfEngine {
WolfEngine({
required this.data,
required this.difficulty,
required this.startingEpisode,
required this.onGameWon,
required this.audio,
required this.input,
}) : doorManager = DoorManager(
onPlaySound: (sfxId) => audio.playSoundEffect(sfxId),
);
int _timeAliveMs = 0;
final WolfensteinData data;
final Difficulty difficulty;
final int startingEpisode;
final EngineAudio audio;
// Standard Dart function instead of Flutter's VoidCallback
final void Function() onGameWon;
// Managers
final DoorManager doorManager;
final Wolf3dInput input;
final PushwallManager pushwallManager = PushwallManager();
// State
late Player player;
late SpriteMap currentLevel;
late WolfLevel activeLevel;
List<Entity> entities = [];
int _currentEpisodeIndex = 0;
int _currentLevelIndex = 0;
int? _returnLevelIndex;
bool isInitialized = false;
void init() {
_currentEpisodeIndex = startingEpisode;
_currentLevelIndex = 0;
_loadLevel();
isInitialized = true;
}
// Expect standard Dart Duration. The host app is responsible for the loop.
void tick(Duration elapsed) {
if (!isInitialized) return;
_timeAliveMs += elapsed.inMilliseconds;
input.update();
final currentInput = input.currentInput;
final inputResult = _processInputs(elapsed, currentInput);
doorManager.update(elapsed);
pushwallManager.update(elapsed, currentLevel);
player.tick(elapsed);
player.angle += inputResult.dAngle;
if (player.angle < 0) player.angle += 2 * math.pi;
if (player.angle >= 2 * math.pi) player.angle -= 2 * math.pi;
final Coordinate2D validatedPos = _calculateValidatedPosition(
player.position,
inputResult.movement,
);
player.x = validatedPos.x;
player.y = validatedPos.y;
_updateEntities(elapsed);
player.updateWeapon(
currentTime: _timeAliveMs,
entities: entities,
isWalkable: isWalkable,
);
}
void _loadLevel() {
entities.clear();
final episode = data.episodes[_currentEpisodeIndex];
activeLevel = episode.levels[_currentLevelIndex];
currentLevel = List.generate(64, (y) => List.from(activeLevel.wallGrid[y]));
final SpriteMap objectLevel = activeLevel.objectGrid;
doorManager.initDoors(currentLevel);
pushwallManager.initPushwalls(currentLevel, objectLevel);
audio.playLevelMusic(activeLevel);
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
int objId = objectLevel[y][x];
if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) {
double spawnAngle = 0.0;
if (objId == MapObject.playerNorth) {
spawnAngle = 3 * math.pi / 2;
} else if (objId == MapObject.playerEast) {
spawnAngle = 0.0;
} else if (objId == MapObject.playerSouth) {
spawnAngle = math.pi / 2;
} else if (objId == MapObject.playerWest) {
spawnAngle = math.pi;
}
player = Player(x: x + 0.5, y: y + 0.5, angle: spawnAngle);
} else {
Entity? newEntity = EntityRegistry.spawn(
objId,
x + 0.5,
y + 0.5,
difficulty,
data.sprites.length,
isSharewareMode: data.version == GameVersion.shareware,
);
if (newEntity != null) entities.add(newEntity);
}
}
}
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
int id = currentLevel[y][x];
if (!((id >= 1 && id <= 63) || (id >= 90 && id <= 101))) {
currentLevel[y][x] = 0;
}
}
}
_bumpPlayerIfStuck();
print("Loaded Floor: ${_currentLevelIndex + 1} - ${activeLevel.name}");
}
void _onLevelCompleted({bool isSecretExit = false}) {
audio.stopMusic();
final currentEpisode = data.episodes[_currentEpisodeIndex];
if (isSecretExit) {
_returnLevelIndex = _currentLevelIndex + 1;
_currentLevelIndex = 9;
} else {
if (_currentLevelIndex == 9 && _returnLevelIndex != null) {
_currentLevelIndex = _returnLevelIndex!;
_returnLevelIndex = null;
} else {
_currentLevelIndex++;
}
}
if (_currentLevelIndex >= currentEpisode.levels.length ||
_currentLevelIndex > 9) {
print("Episode Completed! You win!");
onGameWon();
} else {
_loadLevel();
}
}
({Coordinate2D movement, double dAngle}) _processInputs(
Duration elapsed,
EngineInput input,
) {
const double moveSpeed = 0.14;
const double turnSpeed = 0.10;
Coordinate2D movement = const Coordinate2D(0, 0);
double dAngle = 0.0;
// Read directly from the passed-in EngineInput object
if (input.requestedWeapon != null) {
player.requestWeaponSwitch(input.requestedWeapon!);
}
if (input.isFiring) {
player.fire(_timeAliveMs);
} else {
player.releaseTrigger();
}
if (input.isTurningLeft) dAngle -= turnSpeed;
if (input.isTurningRight) dAngle += turnSpeed;
Coordinate2D forwardVec = Coordinate2D(
math.cos(player.angle),
math.sin(player.angle),
);
if (input.isMovingForward) movement += forwardVec * moveSpeed;
if (input.isMovingBackward) movement -= forwardVec * moveSpeed;
if (input.isInteracting) {
int targetX = (player.x + math.cos(player.angle)).toInt();
int targetY = (player.y + math.sin(player.angle)).toInt();
if (targetX >= 0 && targetX < 64 && targetY >= 0 && targetY < 64) {
int wallId = currentLevel[targetY][targetX];
if (wallId == MapObject.normalElevatorSwitch) {
_onLevelCompleted(isSecretExit: false);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0);
} else if (wallId == MapObject.secretElevatorSwitch) {
_onLevelCompleted(isSecretExit: true);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0);
}
int objId = activeLevel.objectGrid[targetY][targetX];
if (objId == MapObject.normalExitTrigger) {
_onLevelCompleted(isSecretExit: false);
return (movement: movement, dAngle: dAngle);
} else if (objId == MapObject.secretExitTrigger) {
_onLevelCompleted(isSecretExit: true);
return (movement: movement, dAngle: dAngle);
}
}
doorManager.handleInteraction(player.x, player.y, player.angle);
pushwallManager.handleInteraction(
player.x,
player.y,
player.angle,
currentLevel,
);
}
return (movement: movement, dAngle: dAngle);
}
Coordinate2D _calculateValidatedPosition(
Coordinate2D currentPos,
Coordinate2D movement,
) {
const double margin = 0.3;
double newX = currentPos.x;
double newY = currentPos.y;
Coordinate2D target = currentPos + movement;
if (movement.x != 0) {
int checkX = (movement.x > 0)
? (target.x + margin).toInt()
: (target.x - margin).toInt();
if (isWalkable(checkX, currentPos.y.toInt())) newX = target.x;
}
if (movement.y != 0) {
int checkY = (movement.y > 0)
? (target.y + margin).toInt()
: (target.y - margin).toInt();
if (isWalkable(newX.toInt(), checkY)) newY = target.y;
}
return Coordinate2D(newX, newY);
}
void _updateEntities(Duration elapsed) {
List<Entity> itemsToRemove = [];
List<Entity> itemsToAdd = [];
for (Entity entity in entities) {
if (entity is Enemy) {
final intent = entity.update(
elapsedMs: _timeAliveMs,
playerPosition: player.position,
isWalkable: isWalkable,
tryOpenDoor: doorManager.tryOpenDoor,
onDamagePlayer: (int damage) {
player.takeDamage(damage);
},
);
entity.angle = intent.newAngle;
entity.x += intent.movement.x;
entity.y += intent.movement.y;
if (entity.state == EntityState.dead &&
entity.isDying &&
!entity.hasDroppedItem) {
entity.hasDroppedItem = true;
Entity? droppedAmmo = EntityRegistry.spawn(
MapObject.ammoClip,
entity.x,
entity.y,
difficulty,
data.sprites.length,
);
if (droppedAmmo != null) itemsToAdd.add(droppedAmmo);
}
} else if (entity is Collectible) {
if (player.position.distanceTo(entity.position) < 0.5) {
if (player.tryPickup(entity)) {
itemsToRemove.add(entity);
}
}
}
}
if (itemsToRemove.isNotEmpty) {
entities.removeWhere((e) => itemsToRemove.contains(e));
}
if (itemsToAdd.isNotEmpty) entities.addAll(itemsToAdd);
}
bool isWalkable(int x, int y) {
if (currentLevel[y][x] == 0) return true;
if (currentLevel[y][x] >= 90) return doorManager.isDoorOpenEnough(x, y);
return false;
}
void _bumpPlayerIfStuck() {
int pX = player.x.toInt();
int pY = player.y.toInt();
if (pY < 0 ||
pY >= currentLevel.length ||
pX < 0 ||
pX >= currentLevel[0].length ||
currentLevel[pY][pX] > 0) {
double shortestDist = double.infinity;
Coordinate2D nearestSafeSpot = Coordinate2D(1.5, 1.5);
for (int y = 0; y < currentLevel.length; y++) {
for (int x = 0; x < currentLevel[y].length; x++) {
if (currentLevel[y][x] == 0) {
Coordinate2D safeSpot = Coordinate2D(x + 0.5, y + 0.5);
double dist = safeSpot.distanceTo(player.position);
if (dist < shortestDist) {
shortestDist = dist;
nearestSafeSpot = safeSpot;
}
}
}
}
player.x = nearestSafeSpot.x;
player.y = nearestSafeSpot.y;
}
}
}

View File

@@ -0,0 +1,49 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
enum CollectibleType { ammo, health, treasure, weapon, key }
class Collectible extends Entity {
final CollectibleType type;
Collectible({
required super.x,
required super.y,
required super.spriteIndex,
required super.mapId,
required this.type,
}) : super(state: EntityState.staticObj);
// Define which Map IDs are actually items you can pick up
static bool isCollectible(int objId) {
return (objId >= 43 && objId <= 44) || // Keys
(objId >= 47 && objId <= 56); // Health, Ammo, Weapons, Treasure, 1-Up
}
static CollectibleType _getType(int objId) {
if (objId == 43 || objId == 44) return CollectibleType.key;
if (objId == 47 || objId == 48) return CollectibleType.health;
if (objId == 49) return CollectibleType.ammo;
if (objId == 50 || objId == 51) return CollectibleType.weapon;
return CollectibleType.treasure; // 52-56
}
static Collectible? trySpawn(
int objId,
double x,
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
}) {
if (isCollectible(objId)) {
return Collectible(
x: x,
y: y,
spriteIndex: objId - 21, // Same VSWAP math as decorations!
mapId: objId,
type: _getType(objId),
);
}
return null;
}
}

View File

@@ -0,0 +1,24 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
class DeadAardwolf extends Decorative {
static const int sprite = 96;
DeadAardwolf({required super.x, required super.y})
: super(spriteIndex: sprite, state: EntityState.staticObj, mapId: 125);
/// This is the self-spawning logic we discussed.
/// It only claims the ID 124.
static DeadAardwolf? trySpawn(
int objId,
double x,
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
}) {
if (objId == 125) {
return DeadAardwolf(x: x, y: y);
}
return null;
}
}

View File

@@ -0,0 +1,30 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
class DeadGuard extends Decorative {
/// The sprite index in VSWAP for the final "dead" frame of a Guard.
static const int deadGuardSprite = 95;
DeadGuard({required super.x, required super.y})
: super(
spriteIndex: deadGuardSprite,
state: EntityState.staticObj,
// We set mapId to 124 so we can identify it if needed
mapId: 124,
);
/// This is the self-spawning logic we discussed.
/// It only claims the ID 124.
static DeadGuard? trySpawn(
int objId,
double x,
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
}) {
if (objId == 124) {
return DeadGuard(x: x, y: y);
}
return null;
}
}

View File

@@ -0,0 +1,61 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
class Decorative extends Entity {
Decorative({
required super.x,
required super.y,
required super.spriteIndex,
required super.mapId,
super.state = EntityState.staticObj, // Defaults to static
});
// Checks if the Map ID belongs to a standard decoration
static bool isDecoration(int objId) {
// ID 124 is a dead guard in WL1, but an SS guard in WL6.
// However, for spawning purposes, if the SS trySpawn fails,
// we only want to treat it as a decoration if it's not a live actor.
if (objId == 124 || objId == 125) return true;
if (objId >= 23 && objId <= 70) {
// Exclude collectibles defined in MapObject
if ((objId >= 43 && objId <= 44) || (objId >= 47 && objId <= 56)) {
return false;
}
return true;
}
return false;
}
static int getSpriteIndex(int objId) {
if (objId == 124) return 95; // Dead guard sprite index
if (objId == 125) return 96; // Dead Aardwolf/Other body
// Standard decorations are typically offset by 21 in the VSWAP
return objId - 21;
}
static Decorative? trySpawn(
int objId,
double x,
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
}) {
// 2. Standard props (Table, Lamp, etc) use the tiered check
if (!MapObject.isDifficultyAllowed(objId, difficulty)) return null;
if (isDecoration(objId)) {
return Decorative(
x: x,
y: y,
spriteIndex: getSpriteIndex(objId),
mapId: objId,
state: objId == 124 ? EntityState.dead : EntityState.staticObj,
);
}
// Not a decoration!
return null;
}
}

View File

@@ -0,0 +1,53 @@
enum DoorState { closed, opening, open, closing }
class Door {
final int x;
final int y;
final int mapId;
DoorState state = DoorState.closed;
double offset = 0.0;
int openTime = 0;
static const int openDurationMs = 3000;
Door({
required this.x,
required this.y,
required this.mapId,
});
/// Updates animation. Returns the NEW state if it changed this frame, else null.
DoorState? update(int currentTimeMs) {
if (state == DoorState.opening) {
offset += 0.02;
if (offset >= 1.0) {
offset = 1.0;
state = DoorState.open;
openTime = currentTimeMs;
return DoorState.open;
}
} else if (state == DoorState.open) {
if (currentTimeMs - openTime > openDurationMs) {
state = DoorState.closing;
return DoorState.closing;
}
} else if (state == DoorState.closing) {
offset -= 0.02;
if (offset <= 0.0) {
offset = 0.0;
state = DoorState.closed;
return DoorState.closed;
}
}
return null;
}
/// Triggers the opening process. Returns true if it successfully started opening.
bool interact() {
if (state == DoorState.closed || state == DoorState.closing) {
state = DoorState.opening;
return true;
}
return false;
}
}

View File

@@ -0,0 +1,154 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
class HansGrosse extends Enemy {
static const double speed = 0.04;
static const int _baseSprite = 291;
bool _hasFiredThisCycle = false;
HansGrosse({
required super.x,
required super.y,
required super.angle,
required super.mapId,
required Difficulty difficulty,
}) : super(spriteIndex: _baseSprite, state: EntityState.idle) {
// Boss health scales heavily with difficulty
health = switch (difficulty.level) {
0 => 850,
1 => 950,
2 => 1050,
_ => 1200,
};
damage = 20; // Dual chainguns hit hard!
}
static HansGrosse? trySpawn(
int objId,
double x,
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
}) {
if (objId == MapObject.bossHansGrosse) {
return HansGrosse(
x: x,
y: y,
angle: MapObject.getAngle(objId),
mapId: objId,
difficulty: difficulty,
);
}
return null;
}
@override
void takeDamage(int amount, int currentTime) {
if (state == EntityState.dead) return;
health -= amount;
lastActionTime = currentTime;
if (health <= 0) {
state = EntityState.dead;
isDying = true;
}
// Note: Bosses do NOT have a pain state! They never flinch.
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
if (isAlerted && state != EntityState.dead) {
newAngle = position.angleTo(playerPosition);
}
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
baseReactionMs: 50,
);
double distance = position.distanceTo(playerPosition);
switch (state) {
case EntityState.idle:
spriteIndex = _baseSprite;
break;
case EntityState.patrolling:
if (!isAlerted || distance > 1.5) {
double currentMoveAngle = isAlerted ? newAngle : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement(
Coordinate2D(moveX, moveY),
isWalkable,
tryOpenDoor,
);
}
int walkFrame = (elapsedMs ~/ 150) % 4;
spriteIndex = (_baseSprite + 1) + walkFrame;
if (isAlerted && distance < 8.0 && elapsedMs - lastActionTime > 1000) {
if (hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
}
}
break;
case EntityState.attacking:
int timeShooting = elapsedMs - lastActionTime;
if (timeShooting < 150) {
spriteIndex = _baseSprite + 5; // Aiming
} else if (timeShooting < 300) {
spriteIndex = _baseSprite + 6; // Firing
if (!_hasFiredThisCycle) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
}
} else if (timeShooting < 450) {
spriteIndex = _baseSprite + 7; // Recoil
} else {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
break;
case EntityState.dead:
if (isDying) {
int deathFrame = (elapsedMs - lastActionTime) ~/ 150;
if (deathFrame < 4) {
spriteIndex = (_baseSprite + 8) + deathFrame;
} else {
spriteIndex = _baseSprite + 11; // Final dead frame
isDying = false;
}
} else {
spriteIndex = _baseSprite + 11;
}
break;
default:
break;
}
return (movement: movement, newAngle: newAngle);
}
}

View File

@@ -0,0 +1,104 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
class Dog extends Enemy {
static const double speed = 0.05;
bool _hasBittenThisCycle = false;
static EnemyType get type => EnemyType.dog;
Dog({
required super.x,
required super.y,
required super.angle,
required super.mapId,
}) : super(spriteIndex: type.animations.idle.start, state: EntityState.idle) {
health = 1;
damage = 5;
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
);
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) {
newAngle = angleToPlayer;
}
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
while (diff > math.pi) {
diff -= 2 * math.pi;
}
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
);
// Dogs attack based on distance, so wrap the movement and attack in alert checks
if (state == EntityState.patrolling) {
if (!isAlerted || distance > 1.0) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement(
Coordinate2D(moveX, moveY),
isWalkable,
tryOpenDoor,
);
}
if (isAlerted && distance < 1.0) {
state = EntityState.attacking;
lastActionTime = elapsedMs;
_hasBittenThisCycle = false;
}
}
if (state == EntityState.attacking) {
int time = elapsedMs - lastActionTime;
if (time >= 200 && !_hasBittenThisCycle) {
onDamagePlayer(damage);
_hasBittenThisCycle = true;
} else if (time >= 400) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
}
return (movement: movement, newAngle: newAngle);
}
}

View File

@@ -0,0 +1,240 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/dog.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/guard.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/mutant.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/officer.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/ss.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
abstract class Enemy extends Entity {
Enemy({
required super.x,
required super.y,
required super.spriteIndex,
super.angle,
super.state,
super.mapId,
super.lastActionTime,
});
int health = 25;
int damage = 10;
bool isDying = false;
bool hasDroppedItem = false;
bool isAlerted = false;
// Replaces ob->temp2 for reaction delays
int reactionTimeMs = 0;
void takeDamage(int amount, int currentTime) {
if (state == EntityState.dead) return;
health -= amount;
lastActionTime = currentTime;
isAlerted = true;
if (health <= 0) {
state = EntityState.dead;
isDying = true;
} else if (math.Random().nextDouble() < 0.5) {
state = EntityState.pain;
} else {
state = EntityState.patrolling;
}
}
void checkWakeUp({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
int baseReactionMs = 200,
int reactionVarianceMs = 600,
}) {
if (!isAlerted && hasLineOfSight(playerPosition, isWalkable)) {
if (reactionTimeMs == 0) {
reactionTimeMs =
elapsedMs +
baseReactionMs +
math.Random().nextInt(reactionVarianceMs);
} else if (elapsedMs >= reactionTimeMs) {
isAlerted = true;
if (state == EntityState.idle) {
state = EntityState.patrolling;
}
lastActionTime = elapsedMs;
reactionTimeMs = 0;
}
}
}
// Matches WL_STATE.C's 'CheckLine' using canonical Integer DDA traversal
bool hasLineOfSight(
Coordinate2D playerPosition,
bool Function(int x, int y) isWalkable,
) {
// 1. Proximity Check (Matches WL_STATE.C 'MINSIGHT')
// If the player is very close, sight is automatic regardless of facing angle.
// This compensates for our lack of a noise/gunshot alert system!
if (position.distanceTo(playerPosition) < 1.2) {
return true;
}
// 2. FOV Check (Matches original sight angles)
double angleToPlayer = position.angleTo(playerPosition);
double diff = angle - angleToPlayer;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
while (diff > math.pi) {
diff -= 2 * math.pi;
}
if (diff.abs() > math.pi / 2) return false;
// 3. Map Check (Corrected Integer Bresenham)
int currentX = position.x.toInt();
int currentY = position.y.toInt();
int targetX = playerPosition.x.toInt();
int targetY = playerPosition.y.toInt();
int dx = (targetX - currentX).abs();
int dy = -(targetY - currentY).abs();
int sx = currentX < targetX ? 1 : -1;
int sy = currentY < targetY ? 1 : -1;
int err = dx + dy;
while (true) {
if (!isWalkable(currentX, currentY)) return false;
if (currentX == targetX && currentY == targetY) break;
int e2 = 2 * err;
if (e2 >= dy) {
err += dy;
currentX += sx;
}
if (e2 <= dx) {
err += dx;
currentY += sy;
}
}
return true;
}
Coordinate2D getValidMovement(
Coordinate2D intendedMovement,
bool Function(int x, int y) isWalkable,
void Function(int x, int y) tryOpenDoor,
) {
double newX = position.x + intendedMovement.x;
double newY = position.y + intendedMovement.y;
int currentTileX = position.x.toInt();
int currentTileY = position.y.toInt();
int targetTileX = newX.toInt();
int targetTileY = newY.toInt();
bool movedX = currentTileX != targetTileX;
bool movedY = currentTileY != targetTileY;
// 1. Check Diagonal Movement
if (movedX && movedY) {
bool canMoveX = isWalkable(targetTileX, currentTileY);
bool canMoveY = isWalkable(currentTileX, targetTileY);
bool canMoveDiag = isWalkable(targetTileX, targetTileY);
if (!canMoveX || !canMoveY || !canMoveDiag) {
// Trigger doors if they are blocking the path
if (!canMoveX) tryOpenDoor(targetTileX, currentTileY);
if (!canMoveY) tryOpenDoor(currentTileX, targetTileY);
if (!canMoveDiag) tryOpenDoor(targetTileX, targetTileY);
if (canMoveX) return Coordinate2D(intendedMovement.x, 0);
if (canMoveY) return Coordinate2D(0, intendedMovement.y);
return const Coordinate2D(0, 0);
}
}
// 2. Check Cardinal Movement
if (movedX && !movedY) {
if (!isWalkable(targetTileX, currentTileY)) {
tryOpenDoor(targetTileX, currentTileY); // Try to open!
return Coordinate2D(0, intendedMovement.y);
}
}
if (movedY && !movedX) {
if (!isWalkable(currentTileX, targetTileY)) {
tryOpenDoor(currentTileX, targetTileY); // Try to open!
return Coordinate2D(intendedMovement.x, 0);
}
}
return intendedMovement;
}
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int x, int y) tryOpenDoor,
required void Function(int damage) onDamagePlayer,
});
static Enemy? spawn(
int objId,
double x,
double y,
Difficulty difficulty, {
bool isSharewareMode = false,
}) {
// 124 (Dead Guard) famously overwrote a patrol ID in the original engine!
if (objId == MapObject.deadGuard || objId == MapObject.deadAardwolf) {
return null;
}
if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) {
return null;
}
// Prevent bosses from accidentally spawning as regular enemies!
if (objId >= MapObject.bossHansGrosse &&
objId <= MapObject.bossFettgesicht) {
return null;
}
final type = EnemyType.fromMapId(objId);
if (type == null) return null;
// Reject enemies that don't exist in the shareware data!
if (isSharewareMode && !type.existsInShareware) return null;
final mapData = type.mapData;
// ALL enemies have explicit directional angles!
double spawnAngle = CardinalDirection.fromEnemyIndex(objId).radians;
EntityState spawnState;
if (mapData.isPatrolForDifficulty(objId, difficulty)) {
spawnState = EntityState.patrolling;
} else if (mapData.isStaticForDifficulty(objId, difficulty)) {
spawnState = EntityState.idle;
} else if (mapData.isAmbushForDifficulty(objId, difficulty)) {
spawnState = EntityState.ambush;
} else {
return null; // ID belongs to this enemy, but not on this difficulty
}
return switch (type) {
EnemyType.guard => Guard(x: x, y: y, angle: spawnAngle, mapId: objId),
EnemyType.dog => Dog(x: x, y: y, angle: spawnAngle, mapId: objId),
EnemyType.ss => SS(x: x, y: y, angle: spawnAngle, mapId: objId),
EnemyType.mutant => Mutant(x: x, y: y, angle: spawnAngle, mapId: objId),
EnemyType.officer => Officer(x: x, y: y, angle: spawnAngle, mapId: objId),
}..state = spawnState;
}
}

View File

@@ -0,0 +1 @@
enum EnemyAnimation { idle, walking, attacking, pain, dying, dead }

View File

@@ -0,0 +1,175 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.dart';
class EnemyAnimationMap {
final SpriteFrameRange idle;
final SpriteFrameRange walking;
final SpriteFrameRange attacking;
final SpriteFrameRange pain;
final SpriteFrameRange dying;
final SpriteFrameRange dead;
const EnemyAnimationMap({
required this.idle,
required this.walking,
required this.attacking,
required this.pain,
required this.dying,
required this.dead,
});
EnemyAnimation? getAnimation(int spriteIndex) {
if (idle.contains(spriteIndex)) return EnemyAnimation.idle;
if (walking.contains(spriteIndex)) return EnemyAnimation.walking;
if (attacking.contains(spriteIndex)) return EnemyAnimation.attacking;
if (pain.contains(spriteIndex)) return EnemyAnimation.pain;
if (dying.contains(spriteIndex)) return EnemyAnimation.dying;
if (dead.contains(spriteIndex)) return EnemyAnimation.dead;
return null;
}
SpriteFrameRange getRange(EnemyAnimation animation) {
return switch (animation) {
EnemyAnimation.idle => idle,
EnemyAnimation.walking => walking,
EnemyAnimation.attacking => attacking,
EnemyAnimation.pain => pain,
EnemyAnimation.dying => dying,
EnemyAnimation.dead => dead,
};
}
}
enum EnemyType {
guard(
mapData: EnemyMapData(MapObject.guardStart),
animations: EnemyAnimationMap(
idle: SpriteFrameRange(50, 57),
walking: SpriteFrameRange(58, 89),
dying: SpriteFrameRange(90, 93),
pain: SpriteFrameRange(94, 94),
dead: SpriteFrameRange(95, 95),
attacking: SpriteFrameRange(96, 98),
),
),
dog(
mapData: EnemyMapData(MapObject.dogStart),
animations: EnemyAnimationMap(
idle: SpriteFrameRange(99, 106),
walking: SpriteFrameRange(107, 130),
attacking: SpriteFrameRange(135, 137),
pain: SpriteFrameRange(131, 131),
dying: SpriteFrameRange(132, 134),
dead: SpriteFrameRange(137, 137),
),
),
ss(
mapData: EnemyMapData(MapObject.ssStart),
animations: EnemyAnimationMap(
idle: SpriteFrameRange(138, 145),
walking: SpriteFrameRange(146, 178),
attacking: SpriteFrameRange(184, 186),
pain: SpriteFrameRange(182, 182),
dying: SpriteFrameRange(179, 181),
dead: SpriteFrameRange(183, 183),
),
),
mutant(
mapData: EnemyMapData(MapObject.mutantStart),
animations: EnemyAnimationMap(
idle: SpriteFrameRange(187, 194),
walking: SpriteFrameRange(195, 226),
attacking: SpriteFrameRange(234, 237),
pain: SpriteFrameRange(231, 231),
dying: SpriteFrameRange(227, 230),
dead: SpriteFrameRange(232, 232),
),
existsInShareware: false,
),
officer(
mapData: EnemyMapData(MapObject.officerStart),
animations: EnemyAnimationMap(
idle: SpriteFrameRange(238, 245),
walking: SpriteFrameRange(246, 277),
attacking: SpriteFrameRange(285, 287),
pain: SpriteFrameRange(282, 282),
dying: SpriteFrameRange(278, 281),
dead: SpriteFrameRange(283, 283),
),
existsInShareware: false,
);
final EnemyMapData mapData;
final EnemyAnimationMap animations;
final bool existsInShareware;
const EnemyType({
required this.mapData,
required this.animations,
this.existsInShareware = true,
});
static EnemyType? fromMapId(int id) {
for (final type in EnemyType.values) {
if (type.mapData.claimsId(id)) return type;
}
return null;
}
/// Returns the animations only if the enemy actually exists in the current version.
EnemyAnimationMap? getAnimations(bool isShareware) {
if (isShareware && !existsInShareware) return null;
return animations;
}
bool claimsSpriteIndex(int index, {bool isShareware = false}) {
return getAnimations(isShareware)?.getAnimation(index) != null;
}
EnemyAnimation? getAnimationFromSprite(
int spriteIndex, {
bool isShareware = false,
}) {
return getAnimations(isShareware)?.getAnimation(spriteIndex);
}
int getSpriteFromAnimation({
required EnemyAnimation animation,
required int elapsedMs,
required int lastActionTime,
double angleDiff = 0,
int? walkFrameOverride,
}) {
// We don't need to check isShareware here, because if the entity exists
// in the game world, it implicitly passed the version check during spawn.
final range = animations.getRange(animation);
int octant = ((angleDiff + (math.pi / 8)) / (math.pi / 4)).floor() % 8;
if (octant < 0) octant += 8;
return switch (animation) {
EnemyAnimation.idle => range.start + octant,
EnemyAnimation.walking => () {
int framesPerAngle = range.length ~/ 8;
if (framesPerAngle < 1) framesPerAngle = 1;
int frame = walkFrameOverride ?? (elapsedMs ~/ 150) % framesPerAngle;
return range.start + (frame * 8) + octant;
}(),
EnemyAnimation.attacking => () {
int time = elapsedMs - lastActionTime;
int mappedFrame = (time ~/ 150).clamp(0, range.length - 1);
return range.start + mappedFrame;
}(),
EnemyAnimation.pain => range.start,
EnemyAnimation.dying => () {
int time = elapsedMs - lastActionTime;
int mappedFrame = (time ~/ 150).clamp(0, range.length - 1);
return range.start + mappedFrame;
}(),
EnemyAnimation.dead => range.start,
};
}
}

View File

@@ -0,0 +1,112 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
class Guard extends Enemy {
static const double speed = 0.03;
bool _hasFiredThisCycle = false;
static EnemyType get type => EnemyType.guard;
Guard({
required super.x,
required super.y,
required super.angle,
required super.mapId,
}) : super(spriteIndex: type.animations.idle.start, state: EntityState.idle);
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
);
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) {
newAngle = angleToPlayer;
}
// Calculate angle diff for the octant logic
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
while (diff > math.pi) {
diff -= 2 * math.pi;
}
// Use the centralized animation logic to avoid manual offset errors
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
);
if (state == EntityState.patrolling) {
if (!isAlerted || distance > 0.8) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement(
Coordinate2D(moveX, moveY),
isWalkable,
tryOpenDoor,
);
}
if (isAlerted && distance < 6.0 && elapsedMs - lastActionTime > 1500) {
if (hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
}
}
}
if (state == EntityState.attacking) {
int timeShooting = elapsedMs - lastActionTime;
// SS-Specific firing logic
if (timeShooting >= 100 && timeShooting < 200 && !_hasFiredThisCycle) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
} else if (timeShooting >= 300) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
}
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
return (movement: movement, newAngle: newAngle);
}
}

View File

@@ -0,0 +1,116 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
class Mutant extends Enemy {
static const double speed = 0.04;
bool _hasFiredThisCycle = false;
static EnemyType get type => EnemyType.mutant;
Mutant({
required super.x,
required super.y,
required super.angle,
required super.mapId,
}) : super(spriteIndex: type.animations.idle.start, state: EntityState.idle) {
health = 45;
damage = 10;
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
);
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) {
newAngle = angleToPlayer;
}
// Calculate angle diff for the octant logic
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
while (diff > math.pi) {
diff -= 2 * math.pi;
}
// Use the centralized animation logic to avoid manual offset errors
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
);
if (state == EntityState.patrolling) {
// FIX 2: Move along patrol angle if unalerted, chase if alerted
if (!isAlerted || distance > 0.8) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement(
Coordinate2D(moveX, moveY),
isWalkable,
tryOpenDoor,
);
}
// FIX 3: Only attack if alerted (Adjust the distance/timing per enemy class!)
if (isAlerted && distance < 6.0 && elapsedMs - lastActionTime > 1500) {
if (hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
}
}
}
if (state == EntityState.attacking) {
int timeShooting = elapsedMs - lastActionTime;
// SS-Specific firing logic
if (timeShooting >= 100 && timeShooting < 200 && !_hasFiredThisCycle) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
} else if (timeShooting >= 300) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
}
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
return (movement: movement, newAngle: newAngle);
}
}

View File

@@ -0,0 +1,114 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
class Officer extends Enemy {
static const double speed = 0.055;
bool _hasFiredThisCycle = false;
static EnemyType get type => EnemyType.officer;
Officer({
required super.x,
required super.y,
required super.angle,
required super.mapId,
}) : super(spriteIndex: type.animations.idle.start, state: EntityState.idle) {
health = 50;
damage = 15;
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
);
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) {
newAngle = angleToPlayer;
}
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
while (diff > math.pi) {
diff -= 2 * math.pi;
}
// Use centralized animation logic
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
);
if (state == EntityState.patrolling) {
// FIX 2: Move along patrol angle if unalerted, chase if alerted
if (!isAlerted || distance > 0.8) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement(
Coordinate2D(moveX, moveY),
isWalkable,
tryOpenDoor,
);
}
// FIX 3: Only attack if alerted (Adjust the distance/timing per enemy class!)
if (isAlerted && distance < 6.0 && elapsedMs - lastActionTime > 1500) {
if (hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
}
}
}
if (state == EntityState.attacking) {
int timeShooting = elapsedMs - lastActionTime;
if (timeShooting >= 150 && timeShooting < 300 && !_hasFiredThisCycle) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
} else if (timeShooting >= 450) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
}
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
return (movement: movement, newAngle: newAngle);
}
}

View File

@@ -0,0 +1,116 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
class SS extends Enemy {
static const double speed = 0.04;
bool _hasFiredThisCycle = false;
static EnemyType get type => EnemyType.ss;
SS({
required super.x,
required super.y,
required super.angle,
required super.mapId,
}) : super(spriteIndex: type.animations.idle.start, state: EntityState.idle) {
health = 100;
damage = 20;
}
@override
({Coordinate2D movement, double newAngle}) update({
required int elapsedMs,
required Coordinate2D playerPosition,
required bool Function(int x, int y) isWalkable,
required void Function(int damage) onDamagePlayer,
required void Function(int x, int y) tryOpenDoor,
}) {
Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle;
checkWakeUp(
elapsedMs: elapsedMs,
playerPosition: playerPosition,
isWalkable: isWalkable,
);
double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition);
if (isAlerted && state != EntityState.dead) {
newAngle = angleToPlayer;
}
// Calculate angle diff for the octant logic
double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) {
diff += 2 * math.pi;
}
while (diff > math.pi) {
diff -= 2 * math.pi;
}
// Use the centralized animation logic to avoid manual offset errors
EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking,
EntityState.pain => EnemyAnimation.pain,
EntityState.dead => isDying ? EnemyAnimation.dying : EnemyAnimation.dead,
_ => EnemyAnimation.idle,
};
spriteIndex = type.getSpriteFromAnimation(
animation: currentAnim,
elapsedMs: elapsedMs,
lastActionTime: lastActionTime,
angleDiff: diff,
);
if (state == EntityState.patrolling) {
// FIX 2: Move along patrol angle if unalerted, chase if alerted
if (!isAlerted || distance > 0.8) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement(
Coordinate2D(moveX, moveY),
isWalkable,
tryOpenDoor,
);
}
// FIX 3: Only attack if alerted (Adjust the distance/timing per enemy class!)
if (isAlerted && distance < 6.0 && elapsedMs - lastActionTime > 1500) {
if (hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking;
lastActionTime = elapsedMs;
_hasFiredThisCycle = false;
}
}
}
if (state == EntityState.attacking) {
int timeShooting = elapsedMs - lastActionTime;
// SS-Specific firing logic
if (timeShooting >= 100 && timeShooting < 200 && !_hasFiredThisCycle) {
onDamagePlayer(damage);
_hasFiredThisCycle = true;
} else if (timeShooting >= 300) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
}
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) {
state = EntityState.patrolling;
lastActionTime = elapsedMs;
}
return (movement: movement, newAngle: newAngle);
}
}

View File

@@ -0,0 +1,138 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
enum WeaponState { idle, firing }
enum WeaponType { knife, pistol, machineGun, chainGun }
abstract class Weapon {
final WeaponType type;
final int idleSprite;
final List<int> fireFrames;
final int damage;
final int msPerFrame;
final bool isAutomatic;
WeaponState state = WeaponState.idle;
int frameIndex = 0;
int lastFrameTime = 0;
bool _triggerReleased = true;
Weapon({
required this.type,
required this.idleSprite,
required this.fireFrames,
required this.damage,
this.msPerFrame = 100,
this.isAutomatic = true,
});
int getCurrentSpriteIndex(int maxSprites) {
int baseSprite = state == WeaponState.idle
? idleSprite
: fireFrames[frameIndex];
// Retail VSWAP typically has exactly 436 sprites (indices 0 to 435).
// The 20 weapon sprites are ALWAYS placed at the very end of the sprite block.
// This dynamically aligns the base index to the end of any VSWAP file!
int dynamicOffset = 436 - maxSprites;
int calculatedIndex = baseSprite - dynamicOffset;
// Safety check!
if (calculatedIndex < 0 || calculatedIndex >= maxSprites) {
print("WARNING: Weapon sprite index $calculatedIndex out of bounds!");
return 0;
}
return calculatedIndex;
}
void releaseTrigger() {
_triggerReleased = true;
}
bool fire(int currentTime, {required int currentAmmo}) {
if (state == WeaponState.idle && currentAmmo > 0) {
if (!isAutomatic && !_triggerReleased) return false;
state = WeaponState.firing;
frameIndex = 0;
lastFrameTime = currentTime;
_triggerReleased = false;
return true;
}
return false;
}
void update(int currentTime) {
if (state == WeaponState.firing) {
if (currentTime - lastFrameTime > msPerFrame) {
frameIndex++;
lastFrameTime = currentTime;
if (frameIndex >= fireFrames.length) {
state = WeaponState.idle;
frameIndex = 0;
}
}
}
}
// NEW: The weapon calculates its own hits and applies damage!
void performHitscan({
required double playerX,
required double playerY,
required double playerAngle,
required List<Entity> entities,
required bool Function(int x, int y) isWalkable,
required int currentTime,
required void Function(Enemy killedEnemy) onEnemyKilled,
}) {
Enemy? closestEnemy;
double minDistance = 15.0;
for (Entity entity in entities) {
if (entity is Enemy && entity.state != EntityState.dead) {
double dx = entity.x - playerX;
double dy = entity.y - playerY;
double angleToEnemy = math.atan2(dy, dx);
double angleDiff = playerAngle - angleToEnemy;
while (angleDiff <= -math.pi) {
angleDiff += 2 * math.pi;
}
while (angleDiff > math.pi) {
angleDiff -= 2 * math.pi;
}
double dist = math.sqrt(dx * dx + dy * dy);
double threshold = 0.2 / dist;
if (angleDiff.abs() < threshold) {
Coordinate2D source = Coordinate2D(playerX, playerY);
if (entity.hasLineOfSightFrom(
source,
playerAngle,
dist,
isWalkable,
)) {
if (dist < minDistance) {
minDistance = dist;
closestEnemy = entity;
}
}
}
}
}
if (closestEnemy != null) {
closestEnemy.takeDamage(damage, currentTime);
// If the shot was fatal, pass the enemy back so the Player class
// can calculate the correct score based on enemy type!
if (closestEnemy.state == EntityState.dead) {
onEnemyKilled(closestEnemy);
}
}
}
}

View File

@@ -0,0 +1,12 @@
import 'package:wolf_3d_dart/src/entities/entities/weapon/weapon.dart';
class ChainGun extends Weapon {
ChainGun()
: super(
type: WeaponType.chainGun,
idleSprite: 432,
fireFrames: [433, 434],
damage: 40,
msPerFrame: 30,
);
}

View File

@@ -0,0 +1,24 @@
import 'package:wolf_3d_dart/src/entities/entities/weapon/weapon.dart';
class Knife extends Weapon {
Knife()
: super(
type: WeaponType.knife,
idleSprite: 416,
fireFrames: [417, 418, 419, 420],
damage: 15,
msPerFrame: 120,
isAutomatic: false,
);
@override
bool fire(int currentTime, {required int currentAmmo}) {
if (state == WeaponState.idle) {
state = WeaponState.firing;
frameIndex = 0;
lastFrameTime = currentTime;
return true;
}
return false;
}
}

View File

@@ -0,0 +1,13 @@
import 'package:wolf_3d_dart/src/entities/entities/weapon/weapon.dart';
class MachineGun extends Weapon {
MachineGun()
: super(
type: WeaponType.machineGun,
idleSprite: 427,
fireFrames: [428, 429, 430],
damage: 20,
msPerFrame: 80, // MG fires faster than the Pistol
isAutomatic: true, // This allows holding the button!
);
}

View File

@@ -0,0 +1,12 @@
import 'package:wolf_3d_dart/src/entities/entities/weapon/weapon.dart';
class Pistol extends Weapon {
Pistol()
: super(
type: WeaponType.pistol,
idleSprite: 421,
fireFrames: [422, 423, 424, 425],
damage: 20,
isAutomatic: false,
);
}

View File

@@ -0,0 +1,66 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
enum EntityState { staticObj, ambush, idle, patrolling, attacking, pain, dead }
abstract class Entity<T> {
double x;
double y;
int spriteIndex;
double angle;
EntityState state;
int mapId;
int lastActionTime;
Entity({
required this.x,
required this.y,
required this.spriteIndex,
this.angle = 0.0,
this.state = EntityState.staticObj,
this.mapId = 0,
this.lastActionTime = 0,
});
set position(Coordinate2D pos) {
x = pos.x;
y = pos.y;
}
Coordinate2D get position => Coordinate2D(x, y);
// NEW: Checks if a projectile or sightline from 'source' can reach this entity
bool hasLineOfSightFrom(
Coordinate2D source,
double sourceAngle,
double distance,
bool Function(int x, int y) isWalkable,
) {
// Corrected Integer Bresenham Algorithm
int currentX = source.x.toInt();
int currentY = source.y.toInt();
int targetX = x.toInt();
int targetY = y.toInt();
int dx = (targetX - currentX).abs();
int dy = -(targetY - currentY).abs();
int sx = currentX < targetX ? 1 : -1;
int sy = currentY < targetY ? 1 : -1;
int err = dx + dy;
while (true) {
if (!isWalkable(currentX, currentY)) return false;
if (currentX == targetX && currentY == targetY) break;
int e2 = 2 * err;
if (e2 >= dy) {
err += dy;
currentX += sx;
}
if (e2 <= dx) {
err += dx;
currentY += sy;
}
}
return true;
}
}

View File

@@ -0,0 +1,60 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/src/entities/entities/collectible.dart';
import 'package:wolf_3d_dart/src/entities/entities/decorations/dead_aardwolf.dart';
import 'package:wolf_3d_dart/src/entities/entities/decorations/dead_guard.dart';
import 'package:wolf_3d_dart/src/entities/entities/decorative.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/bosses/hans_grosse.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
typedef EntitySpawner =
Entity? Function(
int objId,
double x,
double y,
Difficulty difficulty, {
bool isSharewareMode,
});
abstract class EntityRegistry {
static final List<EntitySpawner> _spawners = [
// Special
DeadGuard.trySpawn,
DeadAardwolf.trySpawn,
// Bosses
HansGrosse.trySpawn,
// Decorations
Decorative.trySpawn,
// Enemies need to try to spawn first
Enemy.spawn,
// Collectables
Collectible.trySpawn,
];
static Entity? spawn(
int objId,
double x,
double y,
Difficulty difficulty,
int maxSprites, {
bool isSharewareMode = false,
}) {
if (objId == 0) return null;
for (final spawner in _spawners) {
Entity? entity = spawner(
objId,
x,
y,
difficulty,
isSharewareMode: isSharewareMode,
);
if (entity != null) return entity;
}
return null;
}
}

View File

@@ -0,0 +1,48 @@
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
import 'package:wolf_3d_dart/src/input/wolf_3d_input.dart';
class CliInput extends Wolf3dInput {
// Pending buffer for asynchronous stdin events
bool _pForward = false;
bool _pBackward = false;
bool _pLeft = false;
bool _pRight = false;
bool _pFire = false;
bool _pInteract = false;
WeaponType? _pWeapon;
/// Call this directly from the stdin listener to queue inputs for the next frame
void handleKey(List<int> bytes) {
String char = String.fromCharCodes(bytes).toLowerCase();
if (char == 'w') _pForward = true;
if (char == 's') _pBackward = true;
if (char == 'a') _pLeft = true;
if (char == 'd') _pRight = true;
// --- NEW MAPPINGS ---
if (char == 'j') _pFire = true;
if (char == ' ') _pInteract = true;
if (char == '1') _pWeapon = WeaponType.knife;
if (char == '2') _pWeapon = WeaponType.pistol;
if (char == '3') _pWeapon = WeaponType.machineGun;
if (char == '4') _pWeapon = WeaponType.chainGun;
}
@override
void update() {
// 1. Move pending inputs to the active state
isMovingForward = _pForward;
isMovingBackward = _pBackward;
isTurningLeft = _pLeft;
isTurningRight = _pRight;
isFiring = _pFire;
isInteracting = _pInteract;
requestedWeapon = _pWeapon;
// 2. Wipe the pending slate clean for the next frame
_pForward = _pBackward = _pLeft = _pRight = _pFire = _pInteract = false;
_pWeapon = null;
}
}

View File

@@ -0,0 +1,27 @@
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
abstract class Wolf3dInput {
bool isMovingForward = false;
bool isMovingBackward = false;
bool isTurningLeft = false;
bool isTurningRight = false;
bool isInteracting = false;
bool isFiring = false;
WeaponType? requestedWeapon;
/// Called once per frame by the game loop to refresh the state.
void update();
/// Exports the current state as a clean DTO for the engine.
/// Subclasses do not need to override this.
EngineInput get currentInput => EngineInput(
isMovingForward: isMovingForward,
isMovingBackward: isMovingBackward,
isTurningLeft: isTurningLeft,
isTurningRight: isTurningRight,
isFiring: isFiring,
isInteracting: isInteracting,
requestedWeapon: requestedWeapon,
);
}

View File

@@ -0,0 +1,108 @@
import 'dart:typed_data';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'opl2_emulator.dart';
class ImfRenderer {
/// Renders music in Stereo with a 10ms Haas widening effect.
static Int16List render(ImfMusic music) {
final emulator = Opl2Emulator();
const double samplesPerTick = Opl2Emulator.sampleRate / 700.0;
int totalTicks = 0;
for (final inst in music.instructions) {
totalTicks += inst.delay;
}
// Allocate buffer for Stereo (2 channels per sample)
int totalMonoSamples = (totalTicks * samplesPerTick).round();
final pcmBuffer = Int16List(totalMonoSamples * 2);
int sampleIndex = 0;
// --- Stereo Widening (Haas Effect) ---
// 10ms at 44.1kHz = 441 samples.
// This is tight enough to sound like 'width' rather than 'echo'.
const int delaySamples = 441;
final delayLine = Float32List(delaySamples);
int delayPtr = 0;
for (final instruction in music.instructions) {
emulator.writeRegister(instruction.register, instruction.data);
int samplesToGenerate = (instruction.delay * samplesPerTick).round();
for (int i = 0; i < samplesToGenerate; i++) {
if (sampleIndex + 1 >= pcmBuffer.length) break;
double monoSample = emulator.generateSample();
// Left channel: Immediate
double left = monoSample;
// Right channel: Delayed
double right = delayLine[delayPtr];
delayLine[delayPtr] = monoSample;
delayPtr = (delayPtr + 1) % delaySamples;
// Interleave L and R
pcmBuffer[sampleIndex++] = (left * 32767).toInt().clamp(-32768, 32767);
pcmBuffer[sampleIndex++] = (right * 32767).toInt().clamp(-32768, 32767);
}
}
return pcmBuffer;
}
/// Wraps raw PCM data into a standard Stereo WAV file header.
static Uint8List createWavFile(
Int16List pcmData, {
int sampleRate = Opl2Emulator.sampleRate,
}) {
const int numChannels = 2;
const int bytesPerSample = 2;
const int blockAlign = numChannels * bytesPerSample;
final int dataSize = pcmData.length * bytesPerSample;
final int byteRate = sampleRate * blockAlign;
final int fileSize = 36 + dataSize;
final bytes = BytesBuilder();
// RIFF header
bytes.add('RIFF'.codeUnits);
bytes.add(_int32ToBytes(fileSize));
bytes.add('WAVE'.codeUnits);
// Format chunk
bytes.add('fmt '.codeUnits);
bytes.add(_int32ToBytes(16));
bytes.add(_int16ToBytes(1));
bytes.add(_int16ToBytes(numChannels));
bytes.add(_int32ToBytes(sampleRate));
bytes.add(_int32ToBytes(byteRate));
bytes.add(_int16ToBytes(blockAlign));
bytes.add(_int16ToBytes(16));
// Data chunk
bytes.add('data'.codeUnits);
bytes.add(_int32ToBytes(dataSize));
final byteData = ByteData(dataSize);
for (int i = 0; i < pcmData.length; i++) {
byteData.setInt16(i * 2, pcmData[i], Endian.little);
}
bytes.add(byteData.buffer.asUint8List());
return bytes.toBytes();
}
static List<int> _int16ToBytes(int value) => [
value & 0xff,
(value >> 8) & 0xff,
];
static List<int> _int32ToBytes(int value) => [
value & 0xff,
(value >> 8) & 0xff,
(value >> 16) & 0xff,
(value >> 24) & 0xff,
];
}

View File

@@ -0,0 +1,385 @@
import 'dart:math' as math;
enum EnvelopeState { off, attack, decay, sustain, release }
class Opl2Operator {
double phase = 0.0;
double phaseIncrement = 0.0;
double attackRate = 0.01;
double decayRate = 0.005;
double sustainLevel = 0.5;
double releaseRate = 0.005;
EnvelopeState envState = EnvelopeState.off;
double envVolume = 0.0;
double multiplier = 1.0;
double volume = 1.0;
// Waveform Selection (0-3)
int waveform = 0;
void updateFrequency(double baseFreq) {
phaseIncrement =
(baseFreq * multiplier * 2 * math.pi) / Opl2Emulator.sampleRate;
}
void triggerOn() {
phase = 0.0;
envState = EnvelopeState.attack;
}
void triggerOff() {
envState = EnvelopeState.release;
}
// Applies the OPL2 hardware waveform math
double _getOscillatorOutput(double currentPhase) {
// Normalize phase between 0 and 2*pi
double p = currentPhase % (2 * math.pi);
if (p < 0) p += 2 * math.pi;
switch (waveform) {
case 1: // Half-Sine (silence for the second half)
return p < math.pi ? math.sin(p) : 0.0;
case 2: // Absolute-Sine (fully rectified)
return math.sin(p).abs();
case 3: // Quarter-Sine / Pulse (raspy pulse)
return (p % math.pi) < (math.pi / 2.0) ? math.sin(p) : 0.0;
case 0:
default: // Standard Sine
return math.sin(p);
}
}
double getSample(double phaseOffset) {
switch (envState) {
case EnvelopeState.attack:
envVolume += attackRate;
if (envVolume >= 1.0) {
envVolume = 1.0;
envState = EnvelopeState.decay;
}
break;
case EnvelopeState.decay:
envVolume -= decayRate;
if (envVolume <= sustainLevel) {
envVolume = sustainLevel;
envState = EnvelopeState.sustain;
}
break;
case EnvelopeState.sustain:
envVolume = sustainLevel;
break;
case EnvelopeState.release:
envVolume -= releaseRate;
if (envVolume <= 0.0) {
envVolume = 0.0;
envState = EnvelopeState.off;
}
break;
case EnvelopeState.off:
envVolume = 0.0;
return 0.0;
}
// Pass the phase + modulation offset into our waveform generator!
double out = _getOscillatorOutput(phase + phaseOffset);
phase += phaseIncrement;
if (phase >= 2 * math.pi) phase -= 2 * math.pi;
return out * envVolume * volume;
}
}
class Opl2Channel {
Opl2Operator modulator = Opl2Operator();
Opl2Operator carrier = Opl2Operator();
int fNum = 0;
int block = 0;
bool keyOn = false;
bool isAdditive = false;
int feedbackStrength = 0;
double _prevModOutput1 = 0.0;
double _prevModOutput2 = 0.0;
void updateFrequency() {
double baseFreq = (fNum * math.pow(2, block)) * (49716.0 / 1048576.0);
modulator.updateFrequency(baseFreq);
carrier.updateFrequency(baseFreq);
}
double getSample() {
if (!keyOn &&
carrier.envState == EnvelopeState.off &&
modulator.envState == EnvelopeState.off) {
return 0.0;
}
double feedbackPhase = 0.0;
if (feedbackStrength > 0) {
double averageMod = (_prevModOutput1 + _prevModOutput2) / 2.0;
double feedbackFactor =
math.pow(2, feedbackStrength - 1) * (math.pi / 16.0);
feedbackPhase = averageMod * feedbackFactor;
}
double modOutput = modulator.getSample(feedbackPhase);
_prevModOutput2 = _prevModOutput1;
_prevModOutput1 = modOutput;
double channelOutput = 0.0;
if (isAdditive) {
double carOutput = carrier.getSample(0.0);
channelOutput = modOutput + carOutput;
} else {
double carOutput = carrier.getSample(modOutput * 2.0);
channelOutput = carOutput;
}
return channelOutput * 0.1;
}
}
class Opl2Noise {
int _seed = 0xFFFF;
// Simple 16-bit LFSR to match OPL2 noise characteristics
double next() {
int bit = ((_seed >> 0) ^ (_seed >> 2) ^ (_seed >> 3) ^ (_seed >> 5)) & 1;
_seed = (_seed >> 1) | (bit << 15);
return ((_seed & 1) == 1) ? 1.0 : -1.0;
}
}
class Opl2Emulator {
static const int sampleRate = 44100;
bool rhythmMode = false;
// Key states for the 5 drums
bool bassDrumKey = false;
bool snareDrumKey = false;
bool tomTomKey = false;
bool topCymbalKey = false;
bool hiHatKey = false;
final Opl2Noise _noise = Opl2Noise();
final List<Opl2Channel> channels = List.generate(9, (_) => Opl2Channel());
// The master lock for waveforms
bool _waveformSelectionEnabled = false;
static const List<int> _operatorMap = [
0,
1,
2,
0,
1,
2,
-1,
-1,
3,
4,
5,
3,
4,
5,
-1,
-1,
6,
7,
8,
6,
7,
8,
];
Opl2Operator? _getOperator(int offset) {
if (offset < 0 ||
offset >= _operatorMap.length ||
_operatorMap[offset] == -1) {
return null;
}
int channelIdx = _operatorMap[offset];
bool isCarrier = (offset % 8) >= 3;
return isCarrier
? channels[channelIdx].carrier
: channels[channelIdx].modulator;
}
void writeRegister(int reg, int data) {
// --- 0x01: Test / Waveform Enable ---
if (reg == 0x01) {
// Bit 5 must be set to 1 to allow waveform changes
_waveformSelectionEnabled = (data & 0x20) != 0;
}
// --- 0x20 - 0x35: Multiplier ---
else if (reg >= 0x20 && reg <= 0x35) {
var op = _getOperator(reg - 0x20);
if (op != null) {
int mult = data & 0x0F;
op.multiplier = mult == 0 ? 0.5 : mult.toDouble();
}
}
// --- 0x40 - 0x55: Total Level (Volume) ---
else if (reg >= 0x40 && reg <= 0x55) {
var op = _getOperator(reg - 0x40);
if (op != null) {
op.volume = 1.0 - ((data & 0x3F) / 63.0);
}
}
// --- 0x60 - 0x75: Attack & Decay ---
else if (reg >= 0x60 && reg <= 0x75) {
var op = _getOperator(reg - 0x60);
if (op != null) {
int attack = (data & 0xF0) >> 4;
int decay = data & 0x0F;
op.attackRate = attack == 0 ? 0.0 : (attack / 15.0) * 0.05;
op.decayRate = decay == 0 ? 0.0 : (decay / 15.0) * 0.005;
}
}
// --- 0x80 - 0x95: Sustain & Release ---
else if (reg >= 0x80 && reg <= 0x95) {
var op = _getOperator(reg - 0x80);
if (op != null) {
int sustain = (data & 0xF0) >> 4;
int release = data & 0x0F;
op.sustainLevel = 1.0 - (sustain / 15.0);
op.releaseRate = release == 0 ? 0.0 : (release / 15.0) * 0.005;
}
}
// --- 0xA0 - 0xA8: F-Number (Lower 8 bits) ---
else if (reg >= 0xA0 && reg <= 0xA8) {
int channelIdx = reg - 0xA0;
channels[channelIdx].fNum = (channels[channelIdx].fNum & 0x300) | data;
channels[channelIdx].updateFrequency();
}
// --- 0xB0 - 0xB8: Key-On, Block, F-Number (Upper 2 bits) ---
else if (reg >= 0xB0 && reg <= 0xB8) {
int channelIdx = reg - 0xB0;
Opl2Channel channel = channels[channelIdx];
bool newKeyOn = (data & 0x20) != 0;
channel.block = (data & 0x1C) >> 2;
channel.fNum = (channel.fNum & 0xFF) | ((data & 0x03) << 8);
channel.updateFrequency();
if (newKeyOn && !channel.keyOn) {
channel.keyOn = true;
channel.modulator.triggerOn();
channel.carrier.triggerOn();
} else if (!newKeyOn && channel.keyOn) {
channel.keyOn = false;
channel.modulator.triggerOff();
channel.carrier.triggerOff();
}
}
// --- 0xC0 - 0xC8: Feedback & Connection ---
else if (reg >= 0xC0 && reg <= 0xC8) {
int channelIdx = reg - 0xC0;
channels[channelIdx].isAdditive = (data & 0x01) != 0;
channels[channelIdx].feedbackStrength = (data & 0x0E) >> 1;
}
// --- 0xE0 - 0xF5: Waveform Selection ---
else if (reg >= 0xE0 && reg <= 0xF5) {
// The chip ignores this register if the master enable bit wasn't set in 0x01
if (_waveformSelectionEnabled) {
var op = _getOperator(reg - 0xE0);
if (op != null) {
// Bottom 2 bits determine the waveform (0-3)
op.waveform = data & 0x03;
}
}
} else if (reg == 0xBD) {
rhythmMode = (data & 0x20) != 0; // Bit 5: Rhythm Enable
// Bits 0-4: Key-On for individual drums
bassDrumKey = (data & 0x10) != 0;
snareDrumKey = (data & 0x08) != 0;
tomTomKey = (data & 0x04) != 0;
topCymbalKey = (data & 0x02) != 0;
hiHatKey = (data & 0x01) != 0;
}
}
double generateSample() {
double mixedOutput = 0.0;
// Channels 0-5 always act as normal melodic FM channels
for (int i = 0; i < 6; i++) {
mixedOutput += channels[i].getSample();
}
if (!rhythmMode) {
// Standard mode: play channels 6, 7, and 8 normally
for (int i = 6; i < 9; i++) {
mixedOutput += channels[i].getSample();
}
} else {
// RHYTHM MODE: The last 3 channels are re-routed
mixedOutput += _generateBassDrum();
mixedOutput += _generateSnareAndHiHat();
mixedOutput += _generateTomAndCymbal();
}
return mixedOutput.clamp(-1.0, 1.0);
}
// Example of Bass Drum logic (Channel 6)
double _generateBassDrum() {
if (!bassDrumKey) return 0.0;
// Bass drum uses standard FM (Mod -> Car) but usually with very low frequency
return channels[6].getSample();
}
double _generateSnareAndHiHat() {
double snareOut = 0.0;
double hiHatOut = 0.0;
// Snare uses Channel 7 Modulator
if (snareDrumKey) {
// The Snare is a mix of a periodic tone and white noise
double noise = _noise.next();
snareOut = channels[7].modulator.getSample(0.0) * noise;
}
// Hi-Hat uses Channel 7 Carrier
if (hiHatKey) {
// Hi-Hats are almost pure high-frequency noise
hiHatOut =
_noise.next() *
channels[7].carrier.envVolume *
channels[7].carrier.volume;
}
return (snareOut + hiHatOut) * 0.1;
}
double _generateTomAndCymbal() {
double tomOut = 0.0;
double cymbalOut = 0.0;
// Tom-tom uses Channel 8 Modulator
if (tomTomKey) {
// Toms are basically just a melodic sine wave with a fast decay
tomOut = channels[8].modulator.getSample(0.0);
}
// Top Cymbal uses Channel 8 Carrier
if (topCymbalKey) {
// Cymbals use the carrier but are usually phase-modulated by noise
double noise = _noise.next();
cymbalOut = channels[8].carrier.getSample(noise * 2.0);
}
return (tomOut + cymbalOut) * 0.1;
}
}

View File

@@ -0,0 +1,174 @@
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/src/synth/imf_renderer.dart';
class WolfAudio implements EngineAudio {
@override
Future<void> debugSoundTest() async {
// Play the first 50 sounds with a 2-second gap to identify them
for (int i = 0; i < 50; i++) {
Future.delayed(Duration(seconds: i * 2), () {
print("Testing Sound ID: $i");
playSoundEffect(i);
});
}
}
bool _isInitialized = false;
// --- Music State ---
final AudioPlayer _musicPlayer = AudioPlayer();
// --- SFX State ---
// A pool of players to allow overlapping sound effects.
static const int _maxSfxChannels = 8;
final List<AudioPlayer> _sfxPlayers = [];
int _currentSfxIndex = 0;
@override
WolfensteinData? activeGame;
/// Initializes the audio engine and pre-allocates the SFX pool.
@override
Future<void> init() async {
if (_isInitialized) return;
try {
// Set music player mode
await _musicPlayer.setPlayerMode(PlayerMode.mediaPlayer);
// Initialize the SFX pool
for (int i = 0; i < _maxSfxChannels; i++) {
final player = AudioPlayer();
// lowLatency mode is highly recommended for short game sounds
await player.setPlayerMode(PlayerMode.lowLatency);
await player.setReleaseMode(ReleaseMode.stop);
_sfxPlayers.add(player);
}
_isInitialized = true;
print(
"WolfAudio: AudioPlayers initialized successfully with $_maxSfxChannels SFX channels.",
);
} catch (e) {
print("WolfAudio: Failed to initialize AudioPlayers - $e");
}
}
/// Disposes of the audio engine and frees resources.
@override
void dispose() {
stopMusic();
_musicPlayer.dispose();
for (final player in _sfxPlayers) {
player.stop();
player.dispose();
}
_sfxPlayers.clear();
_isInitialized = false;
}
// ==========================================
// MUSIC MANAGEMENT
// ==========================================
Future<void> playMusic(ImfMusic track, {bool looping = true}) async {
if (!_isInitialized) return;
await stopMusic();
try {
final pcmSamples = ImfRenderer.render(track);
final wavBytes = ImfRenderer.createWavFile(pcmSamples);
await _musicPlayer.setReleaseMode(
looping ? ReleaseMode.loop : ReleaseMode.stop,
);
await _musicPlayer.play(BytesSource(wavBytes));
} catch (e) {
print("WolfAudio: Error playing music track - $e");
}
}
@override
Future<void> stopMusic() async {
if (!_isInitialized) return;
await _musicPlayer.stop();
}
Future<void> pauseMusic() async {
if (_isInitialized) await _musicPlayer.pause();
}
Future<void> resumeMusic() async {
if (_isInitialized) await _musicPlayer.resume();
}
@override
Future<void> playMenuMusic() async {
final data = activeGame;
if (data == null || data.music.length <= 1) return;
await playMusic(data.music[1]);
}
@override
Future<void> playLevelMusic(WolfLevel level) async {
final data = activeGame;
if (data == null || data.music.isEmpty) return;
final index = level.musicIndex;
if (index < data.music.length) {
await playMusic(data.music[index]);
} else {
print("WolfAudio: Warning - Track index $index out of bounds.");
}
}
// ==========================================
// SFX MANAGEMENT
// ==========================================
@override
Future<void> playSoundEffect(int sfxId) async {
print("Playing sfx id $sfxId");
// The original engine uses a specific starting chunk for digitized sounds.
// In many loaders, the 'sounds' list is already just the digitized ones.
// If your list contains EVERYTHING, you need to add the offset (174).
// If it's JUST digitized sounds, sfxId should work directly.
final soundsList = activeGame!.sounds;
if (sfxId < 0 || sfxId >= soundsList.length) return;
final raw8bitBytes = soundsList[sfxId].bytes;
if (raw8bitBytes.isEmpty) return;
// 2. Convert 8-bit Unsigned PCM -> 16-bit Signed PCM
// Wolf3D raw sounds are biased at 128.
final Int16List converted16bit = Int16List(raw8bitBytes.length);
for (int i = 0; i < raw8bitBytes.length; i++) {
// (sample - 128) shifts it to signed 8-bit range (-128 to 127)
// Multiplying by 256 scales it to 16-bit range (-32768 to 32512)
converted16bit[i] = (raw8bitBytes[i] - 128) * 256;
}
// 3. Wrap in a WAV header (Wolf3D digitized sounds are 7000Hz Mono)
final wavBytes = ImfRenderer.createWavFile(
converted16bit,
sampleRate: 7000,
);
try {
final player = _sfxPlayers[_currentSfxIndex];
_currentSfxIndex = (_currentSfxIndex + 1) % _maxSfxChannels;
// Note: We use BytesSource because createWavFile returns Uint8List (the file bytes)
await player.play(BytesSource(wavBytes));
} catch (e) {
print("WolfAudio: SFX Error - $e");
}
}
}

View File

@@ -0,0 +1,7 @@
/// Support for doing something awesome.
///
/// More dartdocs go here.
library;
export 'src/data/wl_parser.dart' show WLParser;
export 'src/data/wolfenstein_loader.dart' show WolfensteinLoader;

View File

@@ -0,0 +1,22 @@
/// Support for doing something awesome.
///
/// More dartdocs go here.
library;
export 'src/data_types/cardinal_direction.dart' show CardinalDirection;
export 'src/data_types/color_palette.dart' show ColorPalette;
export 'src/data_types/coordinate_2d.dart' show Coordinate2D;
export 'src/data_types/difficulty.dart' show Difficulty;
export 'src/data_types/enemy_map_data.dart' show EnemyMapData;
export 'src/data_types/episode.dart' show Episode;
export 'src/data_types/frame_buffer.dart' show FrameBuffer;
export 'src/data_types/game_file.dart' show GameFile;
export 'src/data_types/game_version.dart' show GameVersion;
export 'src/data_types/image.dart' show VgaImage;
export 'src/data_types/map_objects.dart' show MapObject;
export 'src/data_types/sound.dart'
show PcmSound, ImfMusic, ImfInstruction, WolfMusicMap, WolfSound;
export 'src/data_types/sprite.dart' hide Matrix;
export 'src/data_types/sprite_frame_range.dart' show SpriteFrameRange;
export 'src/data_types/wolf_level.dart' show WolfLevel;
export 'src/data_types/wolfenstein_data.dart' show WolfensteinData;

View File

@@ -0,0 +1,15 @@
/// Support for doing something awesome.
///
/// More dartdocs go here.
library;
export 'src/engine/audio/engine_audio.dart';
export 'src/engine/audio/silent_renderer.dart';
export 'src/engine/input/engine_input.dart';
export 'src/engine/managers/door_manager.dart';
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/software_rasterizer.dart';
export 'src/engine/wolf_3d_engine_base.dart';

View File

@@ -0,0 +1,24 @@
/// Support for doing something awesome.
///
/// More dartdocs go here.
library;
export 'src/entities/entities/collectible.dart';
export 'src/entities/entities/decorative.dart';
export 'src/entities/entities/door.dart';
export 'src/entities/entities/enemies/bosses/hans_grosse.dart';
export 'src/entities/entities/enemies/dog.dart';
export 'src/entities/entities/enemies/enemy.dart';
export 'src/entities/entities/enemies/enemy_animation.dart';
export 'src/entities/entities/enemies/enemy_type.dart';
export 'src/entities/entities/enemies/guard.dart';
export 'src/entities/entities/enemies/mutant.dart';
export 'src/entities/entities/enemies/officer.dart';
export 'src/entities/entities/enemies/ss.dart';
export 'src/entities/entities/weapon/weapon.dart';
export 'src/entities/entities/weapon/weapons/chain_gun.dart';
export 'src/entities/entities/weapon/weapons/knife.dart';
export 'src/entities/entities/weapon/weapons/machine_gun.dart';
export 'src/entities/entities/weapon/weapons/pistol.dart';
export 'src/entities/entity.dart';
export 'src/entities/entity_registry.dart';

View File

@@ -0,0 +1,4 @@
library;
export 'src/input/cli_input.dart';
export 'src/input/wolf_3d_input.dart';

View File

@@ -0,0 +1,6 @@
/// Support for doing something awesome.
///
/// More dartdocs go here.
library;
export 'src/synth/wolf_3d_audio.dart' show WolfAudio;

View File

@@ -0,0 +1,16 @@
name: wolf_3d_dart
description: A combined package for all non-Flutter components of wolf 3D
version: 0.0.1
resolution: workspace
environment:
sdk: ^3.11.1
dependencies:
arcane_helper_utils: ^1.4.7
audioplayers: ^6.6.0
crypto: ^3.0.7
dev_dependencies:
lints: ^6.0.0
test: ^1.24.0