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

@@ -191,8 +191,10 @@ class SixelRasterizer extends Rasterizer {
// Write the encoded Sixel characters for each color present in the band
bool firstColor = true;
for (var entry in colorMap.entries) {
if (!firstColor)
sb.write('\$'); // Carriage return to overlay colors on the same band
if (!firstColor) {
// Carriage return to overlay colors on the same band
sb.write('\$');
}
firstColor = false;
// 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_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 {
WolfEngine({
required this.data,
@@ -17,35 +22,60 @@ class WolfEngine {
onPlaySound: (sfxId) => audio.playSoundEffect(sfxId),
);
/// Total milliseconds elapsed since the engine was initialized.
int _timeAliveMs = 0;
/// The static game data (textures, sounds, maps) parsed from original files.
final WolfensteinData data;
/// The active difficulty level, affecting enemy spawning and behavior.
final Difficulty difficulty;
/// The episode index where the game session begins.
final int startingEpisode;
/// Handles music and sound effect playback.
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;
// Managers
// --- State Managers ---
/// Manages the state and animation of doors throughout the level.
final DoorManager doorManager;
/// Polls and processes raw user input into actionable engine commands.
final Wolf3dInput input;
/// Handles the detection and movement of secret "Pushwalls".
final PushwallManager pushwallManager = PushwallManager();
// State
// --- World State ---
/// The player's current position, stats, and inventory.
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;
/// The static level data source used for reloading or reference.
late WolfLevel activeLevel;
/// All dynamic entities currently in the level (Enemies, Pickups).
List<Entity> entities = [];
int _currentEpisodeIndex = 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;
bool isInitialized = false;
/// Initializes the engine, sets the starting episode, and loads the first level.
void init() {
_currentEpisodeIndex = startingEpisode;
_currentLevelIndex = 0;
@@ -53,23 +83,29 @@ class WolfEngine {
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) {
if (!isInitialized) return;
_timeAliveMs += elapsed.inMilliseconds;
// 1. Process User Input
input.update();
final currentInput = input.currentInput;
final inputResult = _processInputs(elapsed, currentInput);
// 2. Update Environment (Doors & Pushwalls)
doorManager.update(elapsed);
pushwallManager.update(elapsed, currentLevel);
player.tick(elapsed);
// 3. Update Physics & Movement
player.tick(elapsed);
player.angle += inputResult.dAngle;
// Normalize angle to [0, 2π]
if (player.angle < 0) 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.y = validatedPos.y;
// 4. Update Dynamic World (Enemies & Combat)
_updateEntities(elapsed);
player.updateWeapon(
@@ -90,38 +127,33 @@ class WolfEngine {
);
}
/// Wipes the current world state and builds a new floor from map data.
void _loadLevel() {
entities.clear();
final episode = data.episodes[_currentEpisodeIndex];
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]));
final SpriteMap objectLevel = activeLevel.objectGrid;
doorManager.initDoors(currentLevel);
pushwallManager.initPushwalls(currentLevel, objectLevel);
audio.playLevelMusic(activeLevel);
// Spawn Player and Entities from the Object Grid
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
int objId = objectLevel[y][x];
// Map IDs 19-22 are Reserved for Player Starts
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);
player = Player(
x: x + 0.5,
y: y + 0.5,
angle: MapObject.getAngle(objId),
);
} else {
Entity? newEntity = EntityRegistry.spawn(
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 x = 0; x < 64; x++) {
int id = currentLevel[y][x];
@@ -146,18 +179,19 @@ class WolfEngine {
}
_bumpPlayerIfStuck();
print("Loaded Floor: ${_currentLevelIndex + 1} - ${activeLevel.name}");
}
/// Handles floor transitions, including the "Level 10" secret floor logic.
void _onLevelCompleted({bool isSecretExit = false}) {
audio.stopMusic();
final currentEpisode = data.episodes[_currentEpisodeIndex];
if (isSecretExit) {
// Secret exits jump to map index 9 (Level 10)
_returnLevelIndex = _currentLevelIndex + 1;
_currentLevelIndex = 9;
} else {
// Returning from Level 10 or moving to the next sequential floor
if (_currentLevelIndex == 9 && _returnLevelIndex != null) {
_currentLevelIndex = _returnLevelIndex!;
_returnLevelIndex = null;
@@ -168,13 +202,13 @@ class WolfEngine {
if (_currentLevelIndex >= currentEpisode.levels.length ||
_currentLevelIndex > 9) {
print("Episode Completed! You win!");
onGameWon();
} else {
_loadLevel();
}
}
/// Translates [EngineInput] into movement vectors and rotation.
({Coordinate2D movement, double dAngle}) _processInputs(
Duration elapsed,
EngineInput input,
@@ -185,11 +219,9 @@ class WolfEngine {
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 {
@@ -203,16 +235,17 @@ class WolfEngine {
math.cos(player.angle),
math.sin(player.angle),
);
if (input.isMovingForward) movement += forwardVec * moveSpeed;
if (input.isMovingBackward) movement -= forwardVec * moveSpeed;
// Handle Wall Interactions (Switches, Doors, Secret Walls)
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];
// Handle Elevator Switches
if (wallId == MapObject.normalElevatorSwitch) {
_onLevelCompleted(isSecretExit: false);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0);
@@ -220,17 +253,7 @@ class WolfEngine {
_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,
@@ -243,16 +266,18 @@ class WolfEngine {
return (movement: movement, dAngle: dAngle);
}
/// Performs axis-aligned collision detection with a wall-margin buffer.
Coordinate2D _calculateValidatedPosition(
Coordinate2D currentPos,
Coordinate2D movement,
) {
const double margin = 0.3;
const double margin = 0.3; // Prevents clipping through wall edges
double newX = currentPos.x;
double newY = currentPos.y;
Coordinate2D target = currentPos + movement;
// Check X-axis Movement
if (movement.x != 0) {
int checkX = (movement.x > 0)
? (target.x + margin).toInt()
@@ -260,6 +285,7 @@ class WolfEngine {
if (isWalkable(checkX, currentPos.y.toInt())) newX = target.x;
}
// Check Y-axis Movement
if (movement.y != 0) {
int checkY = (movement.y > 0)
? (target.y + margin).toInt()
@@ -270,26 +296,45 @@ class WolfEngine {
return Coordinate2D(newX, newY);
}
/// Updates all [Enemy] and [Collectible] entities in the world.
void _updateEntities(Duration elapsed) {
List<Entity> itemsToRemove = [];
List<Entity> itemsToAdd = [];
for (Entity entity in entities) {
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(
elapsedMs: _timeAliveMs,
playerPosition: player.position,
isWalkable: isWalkable,
tryOpenDoor: doorManager.tryOpenDoor,
onDamagePlayer: (int damage) {
player.takeDamage(damage);
},
onDamagePlayer: (int damage) => player.takeDamage(damage),
);
entity.angle = intent.newAngle;
entity.x += intent.movement.x;
entity.y += intent.movement.y;
// Handle Item Drops
if (entity.state == EntityState.dead &&
entity.isDying &&
!entity.hasDroppedItem) {
@@ -318,12 +363,14 @@ class WolfEngine {
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) {
if (currentLevel[y][x] == 0) return true;
if (currentLevel[y][x] >= 90) return doorManager.isDoorOpenEnough(x, y);
return false;
}
/// Teleports the player to the nearest empty tile if they spawn inside a wall.
void _bumpPlayerIfStuck() {
int pX = player.x.toInt();
int pY = player.y.toInt();
@@ -341,7 +388,6 @@ class WolfEngine {
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;