Fixed sprite rendering bug and death animations

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-17 13:41:54 +01:00
parent ff051d1f34
commit f282cb277f
12 changed files with 338 additions and 101 deletions

View File

@@ -214,7 +214,18 @@ abstract class WLParser {
int leftPix = vswap.getUint16(offset, Endian.little); int leftPix = vswap.getUint16(offset, Endian.little);
int rightPix = vswap.getUint16(offset + 2, Endian.little); int rightPix = vswap.getUint16(offset + 2, Endian.little);
// --- SAFETY CHECK ---
// If the bounds are outside 0-63, this is a dummy or corrupted chunk.
if (leftPix < 0 ||
leftPix > 63 ||
rightPix < 0 ||
rightPix > 63 ||
leftPix > rightPix) {
return sprite;
}
for (int x = leftPix; x <= rightPix; x++) { for (int x = leftPix; x <= rightPix; x++) {
// REVERTED to your original, correct math!
int colOffset = vswap.getUint16( int colOffset = vswap.getUint16(
offset + 4 + ((x - leftPix) * 2), offset + 4 + ((x - leftPix) * 2),
Endian.little, Endian.little,
@@ -238,12 +249,17 @@ abstract class WLParser {
if (endY == 0) break; if (endY == 0) break;
endY ~/= 2; endY ~/= 2;
int pixelOfs = vswap.getUint16(cmdOffset + 2, Endian.little); // THE FIX: This MUST be a signed integer (getInt16) to handle DOS wraparound
int pixelOfs = vswap.getInt16(cmdOffset + 2, Endian.little);
int startY = vswap.getUint16(cmdOffset + 4, Endian.little); int startY = vswap.getUint16(cmdOffset + 4, Endian.little);
startY ~/= 2; startY ~/= 2;
for (int y = startY; y < endY; y++) { // Keep the safety clamps for retail version
// Write directly to the 1D array int safeStartY = startY.clamp(0, 63);
int safeEndY = endY.clamp(0, 64);
for (int y = safeStartY; y < safeEndY; y++) {
sprite.pixels[x * 64 + y] = vswap.getUint8(baseOffset + pixelOfs + y); sprite.pixels[x * 64 + y] = vswap.getUint8(baseOffset + pixelOfs + y);
} }

View File

@@ -191,8 +191,10 @@ class SixelRasterizer extends Rasterizer {
// Write the encoded Sixel characters for each color present in the band // Write the encoded Sixel characters for each color present in the band
bool firstColor = true; bool firstColor = true;
for (var entry in colorMap.entries) { for (var entry in colorMap.entries) {
if (!firstColor) if (!firstColor) {
sb.write('\$'); // Carriage return to overlay colors on the same band // Carriage return to overlay colors on the same band
sb.write('\$');
}
firstColor = false; firstColor = false;
// Select color index // Select color index

View File

@@ -5,6 +5,11 @@ 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_entities.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart'; import 'package:wolf_3d_dart/wolf_3d_input.dart';
/// The core orchestration class for the Wolfenstein 3D engine.
///
/// [WolfEngine] manages the game loop, level transitions, entity updates,
/// and collision detection. It serves as the bridge between raw data,
/// input systems, and the world state.
class WolfEngine { class WolfEngine {
WolfEngine({ WolfEngine({
required this.data, required this.data,
@@ -17,35 +22,60 @@ class WolfEngine {
onPlaySound: (sfxId) => audio.playSoundEffect(sfxId), onPlaySound: (sfxId) => audio.playSoundEffect(sfxId),
); );
/// Total milliseconds elapsed since the engine was initialized.
int _timeAliveMs = 0; int _timeAliveMs = 0;
/// The static game data (textures, sounds, maps) parsed from original files.
final WolfensteinData data; final WolfensteinData data;
/// The active difficulty level, affecting enemy spawning and behavior.
final Difficulty difficulty; final Difficulty difficulty;
/// The episode index where the game session begins.
final int startingEpisode; final int startingEpisode;
/// Handles music and sound effect playback.
final EngineAudio audio; final EngineAudio audio;
// Standard Dart function instead of Flutter's VoidCallback /// Callback triggered when the final level of an episode is completed.
final void Function() onGameWon; final void Function() onGameWon;
// Managers // --- State Managers ---
/// Manages the state and animation of doors throughout the level.
final DoorManager doorManager; final DoorManager doorManager;
/// Polls and processes raw user input into actionable engine commands.
final Wolf3dInput input; final Wolf3dInput input;
/// Handles the detection and movement of secret "Pushwalls".
final PushwallManager pushwallManager = PushwallManager(); final PushwallManager pushwallManager = PushwallManager();
// State // --- World State ---
/// The player's current position, stats, and inventory.
late Player player; late Player player;
/// The mutable 64x64 grid representing the current world.
/// This grid is modified in real-time by doors and pushwalls.
late SpriteMap currentLevel; late SpriteMap currentLevel;
/// The static level data source used for reloading or reference.
late WolfLevel activeLevel; late WolfLevel activeLevel;
/// All dynamic entities currently in the level (Enemies, Pickups).
List<Entity> entities = []; List<Entity> entities = [];
int _currentEpisodeIndex = 0; int _currentEpisodeIndex = 0;
int _currentLevelIndex = 0; int _currentLevelIndex = 0;
/// Stores the previous level index when entering a secret floor,
/// allowing the player to return to the correct spot.
int? _returnLevelIndex; int? _returnLevelIndex;
bool isInitialized = false; bool isInitialized = false;
/// Initializes the engine, sets the starting episode, and loads the first level.
void init() { void init() {
_currentEpisodeIndex = startingEpisode; _currentEpisodeIndex = startingEpisode;
_currentLevelIndex = 0; _currentLevelIndex = 0;
@@ -53,23 +83,29 @@ class WolfEngine {
isInitialized = true; isInitialized = true;
} }
// Expect standard Dart Duration. The host app is responsible for the loop. /// The primary heartbeat of the engine.
///
/// Updates all world subsystems based on the [elapsed] time.
/// This should be called once per frame by the host application.
void tick(Duration elapsed) { void tick(Duration elapsed) {
if (!isInitialized) return; if (!isInitialized) return;
_timeAliveMs += elapsed.inMilliseconds; _timeAliveMs += elapsed.inMilliseconds;
// 1. Process User Input
input.update(); input.update();
final currentInput = input.currentInput; final currentInput = input.currentInput;
final inputResult = _processInputs(elapsed, currentInput); final inputResult = _processInputs(elapsed, currentInput);
// 2. Update Environment (Doors & Pushwalls)
doorManager.update(elapsed); doorManager.update(elapsed);
pushwallManager.update(elapsed, currentLevel); pushwallManager.update(elapsed, currentLevel);
player.tick(elapsed);
// 3. Update Physics & Movement
player.tick(elapsed);
player.angle += inputResult.dAngle; player.angle += inputResult.dAngle;
// Normalize angle to [0, 2π]
if (player.angle < 0) player.angle += 2 * math.pi; if (player.angle < 0) player.angle += 2 * math.pi;
if (player.angle >= 2 * math.pi) player.angle -= 2 * math.pi; if (player.angle >= 2 * math.pi) player.angle -= 2 * math.pi;
@@ -81,6 +117,7 @@ class WolfEngine {
player.x = validatedPos.x; player.x = validatedPos.x;
player.y = validatedPos.y; player.y = validatedPos.y;
// 4. Update Dynamic World (Enemies & Combat)
_updateEntities(elapsed); _updateEntities(elapsed);
player.updateWeapon( player.updateWeapon(
@@ -90,38 +127,33 @@ class WolfEngine {
); );
} }
/// Wipes the current world state and builds a new floor from map data.
void _loadLevel() { void _loadLevel() {
entities.clear(); entities.clear();
final episode = data.episodes[_currentEpisodeIndex]; final episode = data.episodes[_currentEpisodeIndex];
activeLevel = episode.levels[_currentLevelIndex]; activeLevel = episode.levels[_currentLevelIndex];
// Create a mutable copy of the wall grid so pushwalls can modify it
currentLevel = List.generate(64, (y) => List.from(activeLevel.wallGrid[y])); currentLevel = List.generate(64, (y) => List.from(activeLevel.wallGrid[y]));
final SpriteMap objectLevel = activeLevel.objectGrid; final SpriteMap objectLevel = activeLevel.objectGrid;
doorManager.initDoors(currentLevel); doorManager.initDoors(currentLevel);
pushwallManager.initPushwalls(currentLevel, objectLevel); pushwallManager.initPushwalls(currentLevel, objectLevel);
audio.playLevelMusic(activeLevel); audio.playLevelMusic(activeLevel);
// Spawn Player and Entities from the Object Grid
for (int y = 0; y < 64; y++) { for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) { for (int x = 0; x < 64; x++) {
int objId = objectLevel[y][x]; int objId = objectLevel[y][x];
// Map IDs 19-22 are Reserved for Player Starts
if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) { if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) {
double spawnAngle = 0.0; player = Player(
if (objId == MapObject.playerNorth) { x: x + 0.5,
spawnAngle = 3 * math.pi / 2; y: y + 0.5,
} else if (objId == MapObject.playerEast) { angle: MapObject.getAngle(objId),
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 { } else {
Entity? newEntity = EntityRegistry.spawn( Entity? newEntity = EntityRegistry.spawn(
objId, objId,
@@ -136,6 +168,7 @@ class WolfEngine {
} }
} }
// Sanitize the level grid to ensure only valid walls/doors remain
for (int y = 0; y < 64; y++) { for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) { for (int x = 0; x < 64; x++) {
int id = currentLevel[y][x]; int id = currentLevel[y][x];
@@ -146,18 +179,19 @@ class WolfEngine {
} }
_bumpPlayerIfStuck(); _bumpPlayerIfStuck();
print("Loaded Floor: ${_currentLevelIndex + 1} - ${activeLevel.name}");
} }
/// Handles floor transitions, including the "Level 10" secret floor logic.
void _onLevelCompleted({bool isSecretExit = false}) { void _onLevelCompleted({bool isSecretExit = false}) {
audio.stopMusic(); audio.stopMusic();
final currentEpisode = data.episodes[_currentEpisodeIndex]; final currentEpisode = data.episodes[_currentEpisodeIndex];
if (isSecretExit) { if (isSecretExit) {
// Secret exits jump to map index 9 (Level 10)
_returnLevelIndex = _currentLevelIndex + 1; _returnLevelIndex = _currentLevelIndex + 1;
_currentLevelIndex = 9; _currentLevelIndex = 9;
} else { } else {
// Returning from Level 10 or moving to the next sequential floor
if (_currentLevelIndex == 9 && _returnLevelIndex != null) { if (_currentLevelIndex == 9 && _returnLevelIndex != null) {
_currentLevelIndex = _returnLevelIndex!; _currentLevelIndex = _returnLevelIndex!;
_returnLevelIndex = null; _returnLevelIndex = null;
@@ -168,13 +202,13 @@ class WolfEngine {
if (_currentLevelIndex >= currentEpisode.levels.length || if (_currentLevelIndex >= currentEpisode.levels.length ||
_currentLevelIndex > 9) { _currentLevelIndex > 9) {
print("Episode Completed! You win!");
onGameWon(); onGameWon();
} else { } else {
_loadLevel(); _loadLevel();
} }
} }
/// Translates [EngineInput] into movement vectors and rotation.
({Coordinate2D movement, double dAngle}) _processInputs( ({Coordinate2D movement, double dAngle}) _processInputs(
Duration elapsed, Duration elapsed,
EngineInput input, EngineInput input,
@@ -185,11 +219,9 @@ class WolfEngine {
Coordinate2D movement = const Coordinate2D(0, 0); Coordinate2D movement = const Coordinate2D(0, 0);
double dAngle = 0.0; double dAngle = 0.0;
// Read directly from the passed-in EngineInput object
if (input.requestedWeapon != null) { if (input.requestedWeapon != null) {
player.requestWeaponSwitch(input.requestedWeapon!); player.requestWeaponSwitch(input.requestedWeapon!);
} }
if (input.isFiring) { if (input.isFiring) {
player.fire(_timeAliveMs); player.fire(_timeAliveMs);
} else { } else {
@@ -203,16 +235,17 @@ class WolfEngine {
math.cos(player.angle), math.cos(player.angle),
math.sin(player.angle), math.sin(player.angle),
); );
if (input.isMovingForward) movement += forwardVec * moveSpeed; if (input.isMovingForward) movement += forwardVec * moveSpeed;
if (input.isMovingBackward) movement -= forwardVec * moveSpeed; if (input.isMovingBackward) movement -= forwardVec * moveSpeed;
// Handle Wall Interactions (Switches, Doors, Secret Walls)
if (input.isInteracting) { if (input.isInteracting) {
int targetX = (player.x + math.cos(player.angle)).toInt(); int targetX = (player.x + math.cos(player.angle)).toInt();
int targetY = (player.y + math.sin(player.angle)).toInt(); int targetY = (player.y + math.sin(player.angle)).toInt();
if (targetX >= 0 && targetX < 64 && targetY >= 0 && targetY < 64) { if (targetX >= 0 && targetX < 64 && targetY >= 0 && targetY < 64) {
int wallId = currentLevel[targetY][targetX]; int wallId = currentLevel[targetY][targetX];
// Handle Elevator Switches
if (wallId == MapObject.normalElevatorSwitch) { if (wallId == MapObject.normalElevatorSwitch) {
_onLevelCompleted(isSecretExit: false); _onLevelCompleted(isSecretExit: false);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0); return (movement: const Coordinate2D(0, 0), dAngle: 0.0);
@@ -220,17 +253,7 @@ class WolfEngine {
_onLevelCompleted(isSecretExit: true); _onLevelCompleted(isSecretExit: true);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0); 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); doorManager.handleInteraction(player.x, player.y, player.angle);
pushwallManager.handleInteraction( pushwallManager.handleInteraction(
player.x, player.x,
@@ -243,16 +266,18 @@ class WolfEngine {
return (movement: movement, dAngle: dAngle); return (movement: movement, dAngle: dAngle);
} }
/// Performs axis-aligned collision detection with a wall-margin buffer.
Coordinate2D _calculateValidatedPosition( Coordinate2D _calculateValidatedPosition(
Coordinate2D currentPos, Coordinate2D currentPos,
Coordinate2D movement, Coordinate2D movement,
) { ) {
const double margin = 0.3; const double margin = 0.3; // Prevents clipping through wall edges
double newX = currentPos.x; double newX = currentPos.x;
double newY = currentPos.y; double newY = currentPos.y;
Coordinate2D target = currentPos + movement; Coordinate2D target = currentPos + movement;
// Check X-axis Movement
if (movement.x != 0) { if (movement.x != 0) {
int checkX = (movement.x > 0) int checkX = (movement.x > 0)
? (target.x + margin).toInt() ? (target.x + margin).toInt()
@@ -260,6 +285,7 @@ class WolfEngine {
if (isWalkable(checkX, currentPos.y.toInt())) newX = target.x; if (isWalkable(checkX, currentPos.y.toInt())) newX = target.x;
} }
// Check Y-axis Movement
if (movement.y != 0) { if (movement.y != 0) {
int checkY = (movement.y > 0) int checkY = (movement.y > 0)
? (target.y + margin).toInt() ? (target.y + margin).toInt()
@@ -270,26 +296,45 @@ class WolfEngine {
return Coordinate2D(newX, newY); return Coordinate2D(newX, newY);
} }
/// Updates all [Enemy] and [Collectible] entities in the world.
void _updateEntities(Duration elapsed) { void _updateEntities(Duration elapsed) {
List<Entity> itemsToRemove = []; List<Entity> itemsToRemove = [];
List<Entity> itemsToAdd = []; List<Entity> itemsToAdd = [];
for (Entity entity in entities) { for (Entity entity in entities) {
if (entity is Enemy) { if (entity is Enemy) {
// --- ANIMATION TRANSITION FIX (SAFE VERSION) ---
// We check if the enemy is in the 'dead' state and currently 'isDying'.
// Inside WolfEngine._updateEntities
if (entity.state == EntityState.dead && entity.isDying) {
// We use a try-catch because HansGrosse throws an UnimplementedError on 'type'.
try {
final range = entity.type.animations.dying;
final totalAnimTime = range.length * 150;
if (_timeAliveMs - entity.lastActionTime >= totalAnimTime) {
// Transition from 'Falling' to 'Corpse'.
entity.isDying = false;
}
} catch (_) {
// Bosses manage their 'isDying' flag manually in their update() methods.
}
}
// Standard AI Update cycle
final intent = entity.update( final intent = entity.update(
elapsedMs: _timeAliveMs, elapsedMs: _timeAliveMs,
playerPosition: player.position, playerPosition: player.position,
isWalkable: isWalkable, isWalkable: isWalkable,
tryOpenDoor: doorManager.tryOpenDoor, tryOpenDoor: doorManager.tryOpenDoor,
onDamagePlayer: (int damage) { onDamagePlayer: (int damage) => player.takeDamage(damage),
player.takeDamage(damage);
},
); );
entity.angle = intent.newAngle; entity.angle = intent.newAngle;
entity.x += intent.movement.x; entity.x += intent.movement.x;
entity.y += intent.movement.y; entity.y += intent.movement.y;
// Handle Item Drops
if (entity.state == EntityState.dead && if (entity.state == EntityState.dead &&
entity.isDying && entity.isDying &&
!entity.hasDroppedItem) { !entity.hasDroppedItem) {
@@ -318,12 +363,14 @@ class WolfEngine {
if (itemsToAdd.isNotEmpty) entities.addAll(itemsToAdd); if (itemsToAdd.isNotEmpty) entities.addAll(itemsToAdd);
} }
/// Returns true if a tile is empty or contains a door that is sufficiently open.
bool isWalkable(int x, int y) { bool isWalkable(int x, int y) {
if (currentLevel[y][x] == 0) return true; if (currentLevel[y][x] == 0) return true;
if (currentLevel[y][x] >= 90) return doorManager.isDoorOpenEnough(x, y); if (currentLevel[y][x] >= 90) return doorManager.isDoorOpenEnough(x, y);
return false; return false;
} }
/// Teleports the player to the nearest empty tile if they spawn inside a wall.
void _bumpPlayerIfStuck() { void _bumpPlayerIfStuck() {
int pX = player.x.toInt(); int pX = player.x.toInt();
int pY = player.y.toInt(); int pY = player.y.toInt();
@@ -341,7 +388,6 @@ class WolfEngine {
if (currentLevel[y][x] == 0) { if (currentLevel[y][x] == 0) {
Coordinate2D safeSpot = Coordinate2D(x + 0.5, y + 0.5); Coordinate2D safeSpot = Coordinate2D(x + 0.5, y + 0.5);
double dist = safeSpot.distanceTo(player.position); double dist = safeSpot.distanceTo(player.position);
if (dist < shortestDist) { if (dist < shortestDist) {
shortestDist = dist; shortestDist = dist;
nearestSafeSpot = safeSpot; nearestSafeSpot = safeSpot;

View File

@@ -1,14 +1,24 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; 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/wolf_3d_entities.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart';
/// The Episode 1 Boss: Hans Grosse.
///
/// Unlike standard enemies, Hans uses manual sprite indexing because
/// he does not have 8-directional rotation and features a unique
/// dual-chaingun firing rhythm.
class HansGrosse extends Enemy { class HansGrosse extends Enemy {
static const double speed = 0.04; static const double speed = 0.04;
static const int _baseSprite = 291; static const int _baseSprite = 291;
bool _hasFiredThisCycle = false; bool _hasFiredThisCycle = false;
/// Hans is a unique boss and does not map to a standard [EnemyType] enum.
/// This returns an 'unknown' or specialized type if needed for the engine.
@override
EnemyType get type =>
throw UnimplementedError("Hans Grosse uses manual animation logic.");
HansGrosse({ HansGrosse({
required super.x, required super.x,
required super.y, required super.y,
@@ -23,7 +33,7 @@ class HansGrosse extends Enemy {
2 => 1050, 2 => 1050,
_ => 1200, _ => 1200,
}; };
damage = 20; // Dual chainguns hit hard! damage = 20;
} }
static HansGrosse? trySpawn( static HansGrosse? trySpawn(

View File

@@ -1,25 +1,37 @@
import 'dart:math' as math; 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.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.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/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart'; import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// The Attack Dog entity.
///
/// Dogs are fast, melee-only enemies with very low health. They do not
/// have a 'pain' state and attack by leaping at the player once in range.
class Dog extends Enemy { class Dog extends Enemy {
/// The movement speed of the Dog in tiles per frame.
static const double speed = 0.05; static const double speed = 0.05;
/// Ensures the dog only deals damage once per leap animation.
bool _hasBittenThisCycle = false; bool _hasBittenThisCycle = false;
static EnemyType get type => EnemyType.dog; @override
EnemyType get type => EnemyType.dog;
Dog({ Dog({
required super.x, required super.x,
required super.y, required super.y,
required super.angle, required super.angle,
required super.mapId, required super.mapId,
}) : super(spriteIndex: type.animations.idle.start, state: EntityState.idle) { }) : super(
// Static metadata used during initialization.
spriteIndex: EnemyType.dog.animations.idle.start,
state: EntityState.idle,
) {
health = 1; health = 1;
damage = 5; damage = 2;
} }
@override @override
@@ -68,19 +80,21 @@ class Dog extends Enemy {
angleDiff: diff, angleDiff: diff,
); );
// Dogs attack based on distance, so wrap the movement and attack in alert checks // --- State: Patrolling ---
if (state == EntityState.patrolling) { if (state == EntityState.patrolling) {
if (!isAlerted || distance > 1.0) { if (!isAlerted || distance > 1.0) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle; double currentMoveAngle = isAlerted ? angleToPlayer : angle;
double moveX = math.cos(currentMoveAngle) * speed;
double moveY = math.sin(currentMoveAngle) * speed;
movement = getValidMovement( movement = getValidMovement(
Coordinate2D(moveX, moveY), Coordinate2D(
math.cos(currentMoveAngle) * speed,
math.sin(currentMoveAngle) * speed,
),
isWalkable, isWalkable,
tryOpenDoor, tryOpenDoor,
); );
} }
// Dogs switch to attacking state based on melee proximity (1 tile).
if (isAlerted && distance < 1.0) { if (isAlerted && distance < 1.0) {
state = EntityState.attacking; state = EntityState.attacking;
lastActionTime = elapsedMs; lastActionTime = elapsedMs;
@@ -88,8 +102,10 @@ class Dog extends Enemy {
} }
} }
// --- State: Attacking (The Leap) ---
if (state == EntityState.attacking) { if (state == EntityState.attacking) {
int time = elapsedMs - lastActionTime; int time = elapsedMs - lastActionTime;
// Damage is applied mid-animation.
if (time >= 200 && !_hasBittenThisCycle) { if (time >= 200 && !_hasBittenThisCycle) {
onDamagePlayer(damage); onDamagePlayer(damage);
_hasBittenThisCycle = true; _hasBittenThisCycle = true;

View File

@@ -1,6 +1,5 @@
import 'dart:math' as math; 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/dog.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.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/guard.dart';
@@ -8,7 +7,12 @@ 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/officer.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/ss.dart'; import 'package:wolf_3d_dart/src/entities/entities/enemies/ss.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart'; import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// The base class for all computer-controlled opponents in the game.
///
/// This class encapsulates the shared AI behaviors, including movement physics,
/// difficulty-based spawning, and the signature Wolfenstein 3D sight-detection system.
abstract class Enemy extends Entity { abstract class Enemy extends Entity {
Enemy({ Enemy({
required super.x, required super.x,
@@ -20,33 +24,60 @@ abstract class Enemy extends Entity {
super.lastActionTime, super.lastActionTime,
}); });
/// The amount of damage the enemy can take before dying.
int health = 25; int health = 25;
/// The potential damage dealt to the player per successful attack.
int damage = 10; int damage = 10;
/// Set to true when the enemy enters the [EntityState.dead] transition.
bool isDying = false; bool isDying = false;
/// Returns the metadata for this specific enemy type (Guard, SS, etc.).
///
/// This allows the engine to access [EnemyAnimationMap] data without
/// knowing the specific subclass.
EnemyType get type;
/// Ensures enemies drop only one item (like ammo or a key) upon death.
bool hasDroppedItem = false; bool hasDroppedItem = false;
/// When true, the enemy has spotted the player and is actively pursuing or attacking.
bool isAlerted = false; bool isAlerted = false;
// Replaces ob->temp2 for reaction delays /// Used to simulate a "reaction delay" before an alerted enemy begins to move.
/// This replaces the `ob->temp2` variable found in the original `WL_ACT2.C`.
int reactionTimeMs = 0; int reactionTimeMs = 0;
/// Reduces health and handles state transitions for pain or death.
///
/// Alerts the enemy automatically upon taking damage. There is a 50% chance
/// to trigger a "pain" flinch animation if the enemy survives the hit.
void takeDamage(int amount, int currentTime) { void takeDamage(int amount, int currentTime) {
if (state == EntityState.dead) return; if (state == EntityState.dead) return;
health -= amount; health -= amount;
lastActionTime = currentTime; lastActionTime = currentTime;
// Any hit from the player instantly alerts the enemy
isAlerted = true; isAlerted = true;
if (health <= 0) { if (health <= 0) {
state = EntityState.dead; state = EntityState.dead;
isDying = true; isDying = true;
} else if (math.Random().nextDouble() < 0.5) { } else if (math.Random().nextDouble() < 0.5) {
// 50% chance to enter the 'Pain' state (flinching)
state = EntityState.pain; state = EntityState.pain;
} else { } else {
// If no pain state, ensure they are actively patrolling/attacking
state = EntityState.patrolling; state = EntityState.patrolling;
} }
} }
/// Periodically checks if the enemy should spot the player.
///
/// Includes a randomized delay based on [baseReactionMs] and [reactionVarianceMs]
/// to prevent all enemies in a room from reacting on the exact same frame.
void checkWakeUp({ void checkWakeUp({
required int elapsedMs, required int elapsedMs,
required Coordinate2D playerPosition, required Coordinate2D playerPosition,
@@ -56,11 +87,13 @@ abstract class Enemy extends Entity {
}) { }) {
if (!isAlerted && hasLineOfSight(playerPosition, isWalkable)) { if (!isAlerted && hasLineOfSight(playerPosition, isWalkable)) {
if (reactionTimeMs == 0) { if (reactionTimeMs == 0) {
// First frame of spotting: calculate how long until they "wake up"
reactionTimeMs = reactionTimeMs =
elapsedMs + elapsedMs +
baseReactionMs + baseReactionMs +
math.Random().nextInt(reactionVarianceMs); math.Random().nextInt(reactionVarianceMs);
} else if (elapsedMs >= reactionTimeMs) { } else if (elapsedMs >= reactionTimeMs) {
// Reaction delay has passed
isAlerted = true; isAlerted = true;
if (state == EntityState.idle) { if (state == EntityState.idle) {
@@ -73,22 +106,26 @@ abstract class Enemy extends Entity {
} }
} }
// Matches WL_STATE.C's 'CheckLine' using canonical Integer DDA traversal /// Determines if there is a clear, unobstructed path between the enemy and the player.
///
/// This logic matches the sight rules of the original engine:
/// 1. **Proximity**: If the player is within ~1.2 tiles, sight is automatic.
/// 2. **Field of View**: The player must be within a 180-degree arc in front of the enemy.
/// 3. **Raycast**: Uses an Integer Bresenham traversal to check for wall obstructions.
bool hasLineOfSight( bool hasLineOfSight(
Coordinate2D playerPosition, Coordinate2D playerPosition,
bool Function(int x, int y) isWalkable, bool Function(int x, int y) isWalkable,
) { ) {
// 1. Proximity Check (Matches WL_STATE.C 'MINSIGHT') // 1. Proximity Check (Matches 'MINSIGHT' in original C)
// 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) { if (position.distanceTo(playerPosition) < 1.2) {
return true; return true;
} }
// 2. FOV Check (Matches original sight angles) // 2. FOV Check (Matches original 180-degree sight angles)
double angleToPlayer = position.angleTo(playerPosition); double angleToPlayer = position.angleTo(playerPosition);
double diff = angle - angleToPlayer; double diff = angle - angleToPlayer;
// Normalize angle difference to (-pi, pi]
while (diff <= -math.pi) { while (diff <= -math.pi) {
diff += 2 * math.pi; diff += 2 * math.pi;
} }
@@ -96,9 +133,10 @@ abstract class Enemy extends Entity {
diff -= 2 * math.pi; diff -= 2 * math.pi;
} }
// If the player is behind the enemy (more than 90 degrees left or right), return false
if (diff.abs() > math.pi / 2) return false; if (diff.abs() > math.pi / 2) return false;
// 3. Map Check (Corrected Integer Bresenham) // 3. Map Check (Integer Bresenham Traversal)
int currentX = position.x.toInt(); int currentX = position.x.toInt();
int currentY = position.y.toInt(); int currentY = position.y.toInt();
int targetX = playerPosition.x.toInt(); int targetX = playerPosition.x.toInt();
@@ -111,8 +149,8 @@ abstract class Enemy extends Entity {
int err = dx + dy; int err = dx + dy;
while (true) { while (true) {
if (!isWalkable(currentX, currentY)) return false; if (!isWalkable(currentX, currentY)) return false; // Hit a wall
if (currentX == targetX && currentY == targetY) break; if (currentX == targetX && currentY == targetY) break; // Reached player
int e2 = 2 * err; int e2 = 2 * err;
if (e2 >= dy) { if (e2 >= dy) {
@@ -127,6 +165,11 @@ abstract class Enemy extends Entity {
return true; return true;
} }
/// Resolves movement by checking for collisions and attempting to interact with doors.
///
/// The logic performs separate X and Y collision checks to allow "sliding" along
/// walls. If a movement is blocked, it calls [tryOpenDoor] to simulate the
/// enemy's ability to navigate through the level.
Coordinate2D getValidMovement( Coordinate2D getValidMovement(
Coordinate2D intendedMovement, Coordinate2D intendedMovement,
bool Function(int x, int y) isWalkable, bool Function(int x, int y) isWalkable,
@@ -150,27 +193,28 @@ abstract class Enemy extends Entity {
bool canMoveDiag = isWalkable(targetTileX, targetTileY); bool canMoveDiag = isWalkable(targetTileX, targetTileY);
if (!canMoveX || !canMoveY || !canMoveDiag) { if (!canMoveX || !canMoveY || !canMoveDiag) {
// Trigger doors if they are blocking the path // Trigger door logic if a wall is blocking the path
if (!canMoveX) tryOpenDoor(targetTileX, currentTileY); if (!canMoveX) tryOpenDoor(targetTileX, currentTileY);
if (!canMoveY) tryOpenDoor(currentTileX, targetTileY); if (!canMoveY) tryOpenDoor(currentTileX, targetTileY);
if (!canMoveDiag) tryOpenDoor(targetTileX, targetTileY); if (!canMoveDiag) tryOpenDoor(targetTileX, targetTileY);
// Allow sliding: if X is clear but Y/Diag is blocked, return only X movement
if (canMoveX) return Coordinate2D(intendedMovement.x, 0); if (canMoveX) return Coordinate2D(intendedMovement.x, 0);
if (canMoveY) return Coordinate2D(0, intendedMovement.y); if (canMoveY) return Coordinate2D(0, intendedMovement.y);
return const Coordinate2D(0, 0); return const Coordinate2D(0, 0);
} }
} }
// 2. Check Cardinal Movement // 2. Check Cardinal (Straight) Movement
if (movedX && !movedY) { if (movedX && !movedY) {
if (!isWalkable(targetTileX, currentTileY)) { if (!isWalkable(targetTileX, currentTileY)) {
tryOpenDoor(targetTileX, currentTileY); // Try to open! tryOpenDoor(targetTileX, currentTileY);
return Coordinate2D(0, intendedMovement.y); return Coordinate2D(0, intendedMovement.y);
} }
} }
if (movedY && !movedX) { if (movedY && !movedX) {
if (!isWalkable(currentTileX, targetTileY)) { if (!isWalkable(currentTileX, targetTileY)) {
tryOpenDoor(currentTileX, targetTileY); // Try to open! tryOpenDoor(currentTileX, targetTileY);
return Coordinate2D(intendedMovement.x, 0); return Coordinate2D(intendedMovement.x, 0);
} }
} }
@@ -178,6 +222,7 @@ abstract class Enemy extends Entity {
return intendedMovement; return intendedMovement;
} }
/// The per-frame update logic to be implemented by specific enemy types.
({Coordinate2D movement, double newAngle}) update({ ({Coordinate2D movement, double newAngle}) update({
required int elapsedMs, required int elapsedMs,
required Coordinate2D playerPosition, required Coordinate2D playerPosition,
@@ -186,6 +231,10 @@ abstract class Enemy extends Entity {
required void Function(int damage) onDamagePlayer, required void Function(int damage) onDamagePlayer,
}); });
/// Factory method to spawn the correct [Enemy] subclass based on a Map ID.
///
/// This validates [difficulty] requirements, filters out non-enemy objects,
/// and ensures [isSharewareMode] restrictions are respected (e.g., no Mutants).
static Enemy? spawn( static Enemy? spawn(
int objId, int objId,
double x, double x,
@@ -193,7 +242,7 @@ abstract class Enemy extends Entity {
Difficulty difficulty, { Difficulty difficulty, {
bool isSharewareMode = false, bool isSharewareMode = false,
}) { }) {
// 124 (Dead Guard) famously overwrote a patrol ID in the original engine! // Filter out decorative bodies or player starts
if (objId == MapObject.deadGuard || objId == MapObject.deadAardwolf) { if (objId == MapObject.deadGuard || objId == MapObject.deadAardwolf) {
return null; return null;
} }
@@ -201,7 +250,7 @@ abstract class Enemy extends Entity {
return null; return null;
} }
// Prevent bosses from accidentally spawning as regular enemies! // Prevent bosses from spawning via standard enemy logic
if (objId >= MapObject.bossHansGrosse && if (objId >= MapObject.bossHansGrosse &&
objId <= MapObject.bossFettgesicht) { objId <= MapObject.bossFettgesicht) {
return null; return null;
@@ -210,12 +259,12 @@ abstract class Enemy extends Entity {
final type = EnemyType.fromMapId(objId); final type = EnemyType.fromMapId(objId);
if (type == null) return null; if (type == null) return null;
// Reject enemies that don't exist in the shareware data! // Check version compatibility
if (isSharewareMode && !type.existsInShareware) return null; if (isSharewareMode && !type.existsInShareware) return null;
final mapData = type.mapData; final mapData = type.mapData;
// ALL enemies have explicit directional angles! // Resolve spawn orientation and initial AI state
double spawnAngle = CardinalDirection.fromEnemyIndex(objId).radians; double spawnAngle = CardinalDirection.fromEnemyIndex(objId).radians;
EntityState spawnState; EntityState spawnState;
@@ -226,9 +275,11 @@ abstract class Enemy extends Entity {
} else if (mapData.isAmbushForDifficulty(objId, difficulty)) { } else if (mapData.isAmbushForDifficulty(objId, difficulty)) {
spawnState = EntityState.ambush; spawnState = EntityState.ambush;
} else { } else {
return null; // ID belongs to this enemy, but not on this difficulty // The ID belongs to this enemy type, but not for this specific difficulty level
return null;
} }
// Return the specific instance
return switch (type) { return switch (type) {
EnemyType.guard => Guard(x: x, y: y, angle: spawnAngle, mapId: objId), EnemyType.guard => Guard(x: x, y: y, angle: spawnAngle, mapId: objId),
EnemyType.dog => Dog(x: x, y: y, angle: spawnAngle, mapId: objId), EnemyType.dog => Dog(x: x, y: y, angle: spawnAngle, mapId: objId),

View File

@@ -1,24 +1,43 @@
import 'dart:math' as math; 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.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.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/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart'; import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// The standard Brown Guard entity.
///
/// The Guard is a basic projectile enemy that patrols until alerted by
/// line-of-sight or proximity to the player.
class Guard extends Enemy { class Guard extends Enemy {
/// The movement speed of the Guard in tiles per frame.
static const double speed = 0.03; static const double speed = 0.03;
/// Internal flag to ensure the Guard only deals damage once per attack cycle.
bool _hasFiredThisCycle = false; bool _hasFiredThisCycle = false;
static EnemyType get type => EnemyType.guard; /// Returns the metadata specific to the Guard enemy type.
@override
EnemyType get type => EnemyType.guard;
Guard({ Guard({
required super.x, required super.x,
required super.y, required super.y,
required super.angle, required super.angle,
required super.mapId, required super.mapId,
}) : super(spriteIndex: type.animations.idle.start, state: EntityState.idle); }) : super(
// We access the static metadata via EnemyType.guard for the initializer
// since 'type' is not yet available in the super-constructor call.
spriteIndex: EnemyType.guard.animations.idle.start,
state: EntityState.idle,
) {
health = 25;
damage = 10;
}
/// Performs the per-frame logic for the Guard, including movement,
/// combat state transitions, and sprite animation.
@override @override
({Coordinate2D movement, double newAngle}) update({ ({Coordinate2D movement, double newAngle}) update({
required int elapsedMs, required int elapsedMs,
@@ -30,6 +49,7 @@ class Guard extends Enemy {
Coordinate2D movement = const Coordinate2D(0, 0); Coordinate2D movement = const Coordinate2D(0, 0);
double newAngle = angle; double newAngle = angle;
// Standard AI 'Wake Up' check for line-of-sight detection.
checkWakeUp( checkWakeUp(
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
playerPosition: playerPosition, playerPosition: playerPosition,
@@ -39,11 +59,12 @@ class Guard extends Enemy {
double distance = position.distanceTo(playerPosition); double distance = position.distanceTo(playerPosition);
double angleToPlayer = position.angleTo(playerPosition); double angleToPlayer = position.angleTo(playerPosition);
// If the enemy is alerted, they constantly turn to face the player.
if (isAlerted && state != EntityState.dead) { if (isAlerted && state != EntityState.dead) {
newAngle = angleToPlayer; newAngle = angleToPlayer;
} }
// Calculate angle diff for the octant logic // Calculate normalized angle difference for the octant billboarding logic.
double diff = angleToPlayer - newAngle; double diff = angleToPlayer - newAngle;
while (diff <= -math.pi) { while (diff <= -math.pi) {
diff += 2 * math.pi; diff += 2 * math.pi;
@@ -52,7 +73,7 @@ class Guard extends Enemy {
diff -= 2 * math.pi; diff -= 2 * math.pi;
} }
// Use the centralized animation logic to avoid manual offset errors // Resolve the current animation state for the renderer.
EnemyAnimation currentAnim = switch (state) { EnemyAnimation currentAnim = switch (state) {
EntityState.patrolling => EnemyAnimation.walking, EntityState.patrolling => EnemyAnimation.walking,
EntityState.attacking => EnemyAnimation.attacking, EntityState.attacking => EnemyAnimation.attacking,
@@ -61,6 +82,7 @@ class Guard extends Enemy {
_ => EnemyAnimation.idle, _ => EnemyAnimation.idle,
}; };
// Update the visual sprite based on state and viewing angle.
spriteIndex = type.getSpriteFromAnimation( spriteIndex = type.getSpriteFromAnimation(
animation: currentAnim, animation: currentAnim,
elapsedMs: elapsedMs, elapsedMs: elapsedMs,
@@ -68,7 +90,9 @@ class Guard extends Enemy {
angleDiff: diff, angleDiff: diff,
); );
// --- State: Patrolling ---
if (state == EntityState.patrolling) { if (state == EntityState.patrolling) {
// Move toward the player if alerted, otherwise maintain patrol heading.
if (!isAlerted || distance > 0.8) { if (!isAlerted || distance > 0.8) {
double currentMoveAngle = isAlerted ? angleToPlayer : angle; double currentMoveAngle = isAlerted ? angleToPlayer : angle;
double moveX = math.cos(currentMoveAngle) * speed; double moveX = math.cos(currentMoveAngle) * speed;
@@ -81,6 +105,7 @@ class Guard extends Enemy {
); );
} }
// Attack if the player is within 6 tiles and the cooldown has passed.
if (isAlerted && distance < 6.0 && elapsedMs - lastActionTime > 1500) { if (isAlerted && distance < 6.0 && elapsedMs - lastActionTime > 1500) {
if (hasLineOfSight(playerPosition, isWalkable)) { if (hasLineOfSight(playerPosition, isWalkable)) {
state = EntityState.attacking; state = EntityState.attacking;
@@ -90,18 +115,23 @@ class Guard extends Enemy {
} }
} }
// --- State: Attacking ---
if (state == EntityState.attacking) { if (state == EntityState.attacking) {
int timeShooting = elapsedMs - lastActionTime; int timeShooting = elapsedMs - lastActionTime;
// SS-Specific firing logic
// The Guard fires a single shot between 100ms and 200ms of the attack state.
if (timeShooting >= 100 && timeShooting < 200 && !_hasFiredThisCycle) { if (timeShooting >= 100 && timeShooting < 200 && !_hasFiredThisCycle) {
onDamagePlayer(damage); onDamagePlayer(damage);
_hasFiredThisCycle = true; _hasFiredThisCycle = true;
} else if (timeShooting >= 300) { } else if (timeShooting >= 300) {
// Return to pursuit after the attack animation completes.
state = EntityState.patrolling; state = EntityState.patrolling;
lastActionTime = elapsedMs; lastActionTime = elapsedMs;
} }
} }
// --- State: Pain ---
// Brief recovery period after being hit by a player bullet.
if (state == EntityState.pain && elapsedMs - lastActionTime > 250) { if (state == EntityState.pain && elapsedMs - lastActionTime > 250) {
state = EntityState.patrolling; state = EntityState.patrolling;
lastActionTime = elapsedMs; lastActionTime = elapsedMs;

View File

@@ -1,23 +1,27 @@
import 'dart:math' as math; 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.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.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/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart'; import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class Mutant extends Enemy { class Mutant extends Enemy {
static const double speed = 0.04; static const double speed = 0.04;
bool _hasFiredThisCycle = false; bool _hasFiredThisCycle = false;
static EnemyType get type => EnemyType.mutant; @override
EnemyType get type => EnemyType.mutant;
Mutant({ Mutant({
required super.x, required super.x,
required super.y, required super.y,
required super.angle, required super.angle,
required super.mapId, required super.mapId,
}) : super(spriteIndex: type.animations.idle.start, state: EntityState.idle) { }) : super(
spriteIndex: EnemyType.mutant.animations.idle.start,
state: EntityState.idle,
) {
health = 45; health = 45;
damage = 10; damage = 10;
} }

View File

@@ -1,23 +1,27 @@
import 'dart:math' as math; 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.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.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/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart'; import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class Officer extends Enemy { class Officer extends Enemy {
static const double speed = 0.055; static const double speed = 0.055;
bool _hasFiredThisCycle = false; bool _hasFiredThisCycle = false;
static EnemyType get type => EnemyType.officer; @override
EnemyType get type => EnemyType.officer;
Officer({ Officer({
required super.x, required super.x,
required super.y, required super.y,
required super.angle, required super.angle,
required super.mapId, required super.mapId,
}) : super(spriteIndex: type.animations.idle.start, state: EntityState.idle) { }) : super(
spriteIndex: EnemyType.officer.animations.idle.start,
state: EntityState.idle,
) {
health = 50; health = 50;
damage = 15; damage = 15;
} }

View File

@@ -1,23 +1,28 @@
import 'dart:math' as math; 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.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_animation.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/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart'; import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class SS extends Enemy { class SS extends Enemy {
static const double speed = 0.04; static const double speed = 0.04;
bool _hasFiredThisCycle = false; bool _hasFiredThisCycle = false;
static EnemyType get type => EnemyType.ss; /// Instance override required for engine-level animation lookup.
@override
EnemyType get type => EnemyType.ss;
SS({ SS({
required super.x, required super.x,
required super.y, required super.y,
required super.angle, required super.angle,
required super.mapId, required super.mapId,
}) : super(spriteIndex: type.animations.idle.start, state: EntityState.idle) { }) : super(
spriteIndex: EnemyType.ss.animations.idle.start,
state: EntityState.idle,
) {
health = 100; health = 100;
damage = 20; damage = 20;
} }

View File

@@ -1,14 +1,53 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
enum EntityState { staticObj, ambush, idle, patrolling, attacking, pain, dead } /// Defines the high-level AI or physical state of an entity.
enum EntityState {
/// Non-moving decorative objects or items.
staticObj,
abstract class Entity<T> { /// Enemies waiting for the player to enter their field of view.
ambush,
/// Enemies standing still but capable of hearing noise.
idle,
/// Enemies moving along a predefined path.
patrolling,
/// Entities currently performing a combat action.
attacking,
/// Entities in a hit-stun or flinch animation.
pain,
/// Entities that have completed their dying sequence.
dead,
}
/// The base class for all dynamic objects in the game world.
///
/// This includes players, enemies, and collectibles. It manages spatial
/// coordinates and provides core utilities for visibility checks.
abstract class Entity {
/// Horizontal position in the 64x64 grid.
double x; double x;
/// Vertical position in the 64x64 grid.
double y; double y;
/// The specific sprite index from the global [WolfensteinData.sprites] list.
int spriteIndex; int spriteIndex;
/// Current rotation in radians.
double angle; double angle;
/// Current behavior or animation state.
EntityState state; EntityState state;
/// The original ID from the map's object layer.
int mapId; int mapId;
/// Timestamp (in ms) of the last state change, used for animation timing.
int lastActionTime; int lastActionTime;
Entity({ Entity({
@@ -21,21 +60,25 @@ abstract class Entity<T> {
this.lastActionTime = 0, this.lastActionTime = 0,
}); });
/// Updates the spatial coordinates using a [Coordinate2D].
set position(Coordinate2D pos) { set position(Coordinate2D pos) {
x = pos.x; x = pos.x;
y = pos.y; y = pos.y;
} }
/// Returns the current coordinates as a [Coordinate2D].
Coordinate2D get position => Coordinate2D(x, y); Coordinate2D get position => Coordinate2D(x, y);
// NEW: Checks if a projectile or sightline from 'source' can reach this entity /// Performs a grid-based line-of-sight check from a [source] to this entity.
///
/// Uses an Integer Bresenham algorithm to traverse tiles between the points.
/// Returns `false` if the path is obstructed by a wall (non-walkable tile).
bool hasLineOfSightFrom( bool hasLineOfSightFrom(
Coordinate2D source, Coordinate2D source,
double sourceAngle, double sourceAngle,
double distance, double distance,
bool Function(int x, int y) isWalkable, bool Function(int x, int y) isWalkable,
) { ) {
// Corrected Integer Bresenham Algorithm
int currentX = source.x.toInt(); int currentX = source.x.toInt();
int currentY = source.y.toInt(); int currentY = source.y.toInt();
int targetX = x.toInt(); int targetX = x.toInt();

View File

@@ -1,4 +1,3 @@
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/collectible.dart';
import 'package:wolf_3d_dart/src/entities/entities/decorations/dead_aardwolf.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/decorations/dead_guard.dart';
@@ -6,7 +5,9 @@ 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/bosses/hans_grosse.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart'; import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart'; import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// Defines a standard signature for entity spawning functions.
typedef EntitySpawner = typedef EntitySpawner =
Entity? Function( Entity? Function(
int objId, int objId,
@@ -16,25 +17,34 @@ typedef EntitySpawner =
bool isSharewareMode, bool isSharewareMode,
}); });
/// The central factory for instantiating all dynamic objects in a Wolf3D level.
///
/// This registry uses a prioritized list of spawners. Specialized entities
/// (like bosses or unique decorations) are checked first, followed by
/// generic enemies and finally collectibles.
abstract class EntityRegistry { abstract class EntityRegistry {
/// Ordered list of spawner functions used to identify Map IDs.
static final List<EntitySpawner> _spawners = [ static final List<EntitySpawner> _spawners = [
// Special // 1. Special Case Decorations (Legacy items from source)
DeadGuard.trySpawn, DeadGuard.trySpawn,
DeadAardwolf.trySpawn, DeadAardwolf.trySpawn,
// Bosses // 2. Boss Entities
HansGrosse.trySpawn, HansGrosse.trySpawn,
// Decorations // 3. Static Decorative Objects (Puddles, Lamps, etc.)
Decorative.trySpawn, Decorative.trySpawn,
// Enemies need to try to spawn first // 4. Combat Enemies (Guard, SS, Mutant, Officer, Dog)
Enemy.spawn, Enemy.spawn,
// Collectables // 5. Pickups (Health, Ammo, Keys)
Collectible.trySpawn, Collectible.trySpawn,
]; ];
/// Interprets a [objId] from the map and returns the corresponding [Entity].
///
/// Returns `null` if the ID is 0 or is not recognized by any registered spawner.
static Entity? spawn( static Entity? spawn(
int objId, int objId,
double x, double x,