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:
27
packages/wolf_3d_dart/lib/src/data/data_version.dart
Normal file
27
packages/wolf_3d_dart/lib/src/data/data_version.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
109
packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart
Normal file
109
packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart
Normal 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();
|
||||
}
|
||||
12
packages/wolf_3d_dart/lib/src/data/io/discovery_stub.dart
Normal file
12
packages/wolf_3d_dart/lib/src/data/io/discovery_stub.dart
Normal 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().',
|
||||
);
|
||||
}
|
||||
677
packages/wolf_3d_dart/lib/src/data/wl_parser.dart
Normal file
677
packages/wolf_3d_dart/lib/src/data/wl_parser.dart
Normal 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);
|
||||
}
|
||||
83
packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart
Normal file
83
packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart
Normal 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!,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
262
packages/wolf_3d_dart/lib/src/data_types/color_palette.dart
Normal file
262
packages/wolf_3d_dart/lib/src/data_types/color_palette.dart
Normal 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,
|
||||
]);
|
||||
}
|
||||
69
packages/wolf_3d_dart/lib/src/data_types/coordinate_2d.dart
Normal file
69
packages/wolf_3d_dart/lib/src/data_types/coordinate_2d.dart
Normal 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)';
|
||||
}
|
||||
11
packages/wolf_3d_dart/lib/src/data_types/difficulty.dart
Normal file
11
packages/wolf_3d_dart/lib/src/data_types/difficulty.dart
Normal 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);
|
||||
}
|
||||
25
packages/wolf_3d_dart/lib/src/data_types/enemy_map_data.dart
Normal file
25
packages/wolf_3d_dart/lib/src/data_types/enemy_map_data.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
8
packages/wolf_3d_dart/lib/src/data_types/episode.dart
Normal file
8
packages/wolf_3d_dart/lib/src/data_types/episode.dart
Normal 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});
|
||||
}
|
||||
19
packages/wolf_3d_dart/lib/src/data_types/frame_buffer.dart
Normal file
19
packages/wolf_3d_dart/lib/src/data_types/frame_buffer.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
14
packages/wolf_3d_dart/lib/src/data_types/game_file.dart
Normal file
14
packages/wolf_3d_dart/lib/src/data_types/game_file.dart
Normal 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);
|
||||
}
|
||||
11
packages/wolf_3d_dart/lib/src/data_types/game_version.dart
Normal file
11
packages/wolf_3d_dart/lib/src/data_types/game_version.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
enum GameVersion {
|
||||
shareware("WL1"),
|
||||
retail("WL6"),
|
||||
spearOfDestiny("SOD"),
|
||||
spearOfDestinyDemo("SDM"),
|
||||
;
|
||||
|
||||
final String fileExtension;
|
||||
|
||||
const GameVersion(this.fileExtension);
|
||||
}
|
||||
13
packages/wolf_3d_dart/lib/src/data_types/image.dart
Normal file
13
packages/wolf_3d_dart/lib/src/data_types/image.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
134
packages/wolf_3d_dart/lib/src/data_types/map_objects.dart
Normal file
134
packages/wolf_3d_dart/lib/src/data_types/map_objects.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
108
packages/wolf_3d_dart/lib/src/data_types/sound.dart
Normal file
108
packages/wolf_3d_dart/lib/src/data_types/sound.dart
Normal 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;
|
||||
}
|
||||
21
packages/wolf_3d_dart/lib/src/data_types/sprite.dart
Normal file
21
packages/wolf_3d_dart/lib/src/data_types/sprite.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
15
packages/wolf_3d_dart/lib/src/data_types/wolf_level.dart
Normal file
15
packages/wolf_3d_dart/lib/src/data_types/wolf_level.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
12
packages/wolf_3d_dart/lib/src/engine/audio/engine_audio.dart
Normal file
12
packages/wolf_3d_dart/lib/src/engine/audio/engine_audio.dart
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
22
packages/wolf_3d_dart/lib/src/engine/input/engine_input.dart
Normal file
22
packages/wolf_3d_dart/lib/src/engine/input/engine_input.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
267
packages/wolf_3d_dart/lib/src/engine/player/player.dart
Normal file
267
packages/wolf_3d_dart/lib/src/engine/player/player.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
388
packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart
Normal file
388
packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
356
packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart
Normal file
356
packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
53
packages/wolf_3d_dart/lib/src/entities/entities/door.dart
Normal file
53
packages/wolf_3d_dart/lib/src/entities/entities/door.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
104
packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart
Normal file
104
packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
enum EnemyAnimation { idle, walking, attacking, pain, dying, dead }
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
116
packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart
Normal file
116
packages/wolf_3d_dart/lib/src/entities/entities/enemies/ss.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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!
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
66
packages/wolf_3d_dart/lib/src/entities/entity.dart
Normal file
66
packages/wolf_3d_dart/lib/src/entities/entity.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
60
packages/wolf_3d_dart/lib/src/entities/entity_registry.dart
Normal file
60
packages/wolf_3d_dart/lib/src/entities/entity_registry.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
48
packages/wolf_3d_dart/lib/src/input/cli_input.dart
Normal file
48
packages/wolf_3d_dart/lib/src/input/cli_input.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
27
packages/wolf_3d_dart/lib/src/input/wolf_3d_input.dart
Normal file
27
packages/wolf_3d_dart/lib/src/input/wolf_3d_input.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
108
packages/wolf_3d_dart/lib/src/synth/imf_renderer.dart
Normal file
108
packages/wolf_3d_dart/lib/src/synth/imf_renderer.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
385
packages/wolf_3d_dart/lib/src/synth/opl2_emulator.dart
Normal file
385
packages/wolf_3d_dart/lib/src/synth/opl2_emulator.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
174
packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart
Normal file
174
packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
7
packages/wolf_3d_dart/lib/wolf_3d_data.dart
Normal file
7
packages/wolf_3d_dart/lib/wolf_3d_data.dart
Normal 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;
|
||||
22
packages/wolf_3d_dart/lib/wolf_3d_data_types.dart
Normal file
22
packages/wolf_3d_dart/lib/wolf_3d_data_types.dart
Normal 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;
|
||||
15
packages/wolf_3d_dart/lib/wolf_3d_engine.dart
Normal file
15
packages/wolf_3d_dart/lib/wolf_3d_engine.dart
Normal 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';
|
||||
24
packages/wolf_3d_dart/lib/wolf_3d_entities.dart
Normal file
24
packages/wolf_3d_dart/lib/wolf_3d_entities.dart
Normal 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';
|
||||
4
packages/wolf_3d_dart/lib/wolf_3d_input.dart
Normal file
4
packages/wolf_3d_dart/lib/wolf_3d_input.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
library;
|
||||
|
||||
export 'src/input/cli_input.dart';
|
||||
export 'src/input/wolf_3d_input.dart';
|
||||
6
packages/wolf_3d_dart/lib/wolf_3d_synth.dart
Normal file
6
packages/wolf_3d_dart/lib/wolf_3d_synth.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
/// Support for doing something awesome.
|
||||
///
|
||||
/// More dartdocs go here.
|
||||
library;
|
||||
|
||||
export 'src/synth/wolf_3d_audio.dart' show WolfAudio;
|
||||
Reference in New Issue
Block a user