Fixed sprite rendering bug and death animations
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user