WIP: Refactoring game engine and entities into packages

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-15 15:53:39 +01:00
parent 5f3e3bb823
commit 026e6d8cb4
46 changed files with 645 additions and 116 deletions

View File

@@ -0,0 +1,62 @@
import 'package:flutter/services.dart';
import 'package:wolf_3d_entities/wolf_3d_entities.dart';
class InputManager {
Set<LogicalKeyboardKey> _previousKeys = {};
bool isMovingForward = false;
bool isMovingBackward = false;
bool isTurningLeft = false;
bool isTurningRight = false;
// Discrete (triggers once per press)
bool isInteracting = false;
// Continuous
bool isFiring = false;
WeaponType? requestedWeapon;
void update() {
final pressedKeys = HardwareKeyboard.instance.logicalKeysPressed;
// Calculate all keys that were pressed exactly on this frame
final newlyPressedKeys = pressedKeys.difference(_previousKeys);
// * Movement
isMovingForward = pressedKeys.contains(LogicalKeyboardKey.keyW);
isMovingBackward = pressedKeys.contains(LogicalKeyboardKey.keyS);
isTurningLeft = pressedKeys.contains(LogicalKeyboardKey.keyA);
isTurningRight = pressedKeys.contains(LogicalKeyboardKey.keyD);
// * Interaction (Space)
// Much simpler now using the newlyPressedKeys set
isInteracting = newlyPressedKeys.contains(LogicalKeyboardKey.space);
// * Firing (Left Control)
// - Keeping this continuous for machine guns
isFiring =
pressedKeys.contains(LogicalKeyboardKey.controlLeft) &&
!pressedKeys.contains(LogicalKeyboardKey.space);
// * Manual Weapon Switching
requestedWeapon = null;
// Iterate through newly pressed keys and switch on them
for (final LogicalKeyboardKey key in newlyPressedKeys) {
switch (key) {
case LogicalKeyboardKey.digit1:
requestedWeapon = WeaponType.knife;
case LogicalKeyboardKey.digit2:
requestedWeapon = WeaponType.pistol;
case LogicalKeyboardKey.digit3:
requestedWeapon = WeaponType.machineGun;
case LogicalKeyboardKey.digit4:
requestedWeapon = WeaponType.chainGun;
}
}
// * Save state for next tick
_previousKeys = Set.from(pressedKeys);
}
}

View File

@@ -0,0 +1,68 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/wolf_3d_entities.dart';
class DoorManager {
// Key is '$x,$y'
final Map<String, Door> doors = {};
void initDoors(Sprite 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) {
// Assuming 90+ are doors based on your previous code
doors['$x,$y'] = Door(x: x, y: y, mapId: id);
}
}
}
}
void update(Duration elapsed) {
for (final door in doors.values) {
door.update(elapsed.inMilliseconds);
}
}
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)) {
doors[key]!.interact();
}
}
// 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;
}
void tryOpenDoor(int x, int y) {
String key = '$x,$y';
if (doors.containsKey(key)) {
// If it's closed or closing, interact() will usually start it opening
if (doors[key]!.offset == 0.0) {
doors[key]!.interact();
}
}
}
bool isDoorOpenEnough(int x, int y) {
String key = '$x,$y';
if (doors.containsKey(key)) {
// 0.7 offset means 70% open, similar to the original engine's check
return doors[key]!.offset > 0.7;
}
return false; // Not a door we manage
}
}

View File

@@ -0,0 +1,120 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/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(Sprite wallGrid, Sprite 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, Sprite 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,
Sprite wallGrid,
) {
// Only one pushwall can move at a time in the original engine!
if (activePushwall != null) return;
int targetX = (playerX + math.cos(playerAngle)).toInt();
int targetY = (playerY + math.sin(playerAngle)).toInt();
String key = '$targetX,$targetY';
if (pushwalls.containsKey(key)) {
final pw = pushwalls[key]!;
// Determine the push direction based on the player's relative position
double dx = (targetX + 0.5) - playerX;
double dy = (targetY + 0.5) - playerY;
if (dx.abs() > dy.abs()) {
pw.dirX = dx > 0 ? 1 : -1;
pw.dirY = 0;
} else {
pw.dirX = 0;
pw.dirY = dy > 0 ? 1 : -1;
}
// Make sure the tile behind the wall is empty before starting the push
int checkX = targetX + pw.dirX;
int checkY = targetY + pw.dirY;
if (wallGrid[checkY][checkX] == 0) {
activePushwall = pw;
}
}
}
}

View File

@@ -0,0 +1,253 @@
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_entities/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;
// 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);
// --- 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);
if (health <= 0) {
print("YOU DIED!");
} else {
print("Ouch! ($health)");
}
}
void heal(int amount) {
final int newHealth = math.min(100, health + amount);
if (health < 100) {
print("Feelin' better. ($newHealth)");
}
health = newHealth;
}
void addAmmo(int amount) {
final int newAmmo = math.min(99, ammo + amount);
if (ammo < 99) {
print("Hell yeah. ($newAmmo)");
}
ammo = newAmmo;
}
bool tryPickup(Collectible item) {
bool pickedUp = false;
switch (item.type) {
case CollectibleType.health:
if (health >= 100) return false;
heal(item.mapId == MapObject.dogFoodDecoration ? 4 : 25);
pickedUp = true;
break;
case CollectibleType.ammo:
if (ammo >= 99) return false;
int previousAmmo = ammo;
addAmmo(8);
if (currentWeapon is Knife && previousAmmo <= 0) {
requestWeaponSwitch(WeaponType.pistol);
}
pickedUp = true;
break;
case CollectibleType.treasure:
if (item.mapId == MapObject.cross) score += 100;
if (item.mapId == MapObject.chalice) score += 500;
if (item.mapId == MapObject.chest) score += 1000;
if (item.mapId == MapObject.crown) score += 5000;
if (item.mapId == MapObject.extraLife) {
heal(100);
addAmmo(25);
}
pickedUp = true;
break;
case CollectibleType.weapon:
if (item.mapId == MapObject.machineGun) {
if (weapons[WeaponType.machineGun] == null) {
weapons[WeaponType.machineGun] = MachineGun();
hasMachineGun = true;
}
addAmmo(8);
requestWeaponSwitch(WeaponType.machineGun);
pickedUp = true;
}
if (item.mapId == MapObject.chainGun) {
if (weapons[WeaponType.chainGun] == null) {
weapons[WeaponType.chainGun] = ChainGun();
hasChainGun = true;
}
addAmmo(8);
requestWeaponSwitch(WeaponType.chainGun);
pickedUp = true;
}
break;
case CollectibleType.key:
if (item.mapId == MapObject.goldKey) hasGoldKey = true;
if (item.mapId == MapObject.silverKey) hasSilverKey = true;
pickedUp = true;
break;
}
return pickedUp;
}
void fire(int currentTime) {
if (switchState != WeaponSwitchState.idle) return;
// We pass the isFiring state to handle automatic vs semi-auto behavior
bool shotFired = currentWeapon.fire(
currentTime,
currentAmmo: ammo,
);
if (shotFired && currentWeapon.type != WeaponType.knife) {
ammo--;
}
}
void releaseTrigger() {
currentWeapon.releaseTrigger();
}
/// Returns true only on the specific frame where the hit should be calculated
void updateWeapon({
required int currentTime,
required List<Entity> entities,
required bool Function(int x, int y) isWalkable,
}) {
int oldFrame = currentWeapon.frameIndex;
currentWeapon.update(currentTime);
// If we just crossed into the firing frame...
if (currentWeapon.state == WeaponState.firing &&
oldFrame == 0 &&
currentWeapon.frameIndex == 1) {
currentWeapon.performHitscan(
playerX: x,
playerY: y,
playerAngle: angle,
entities: entities,
isWalkable: isWalkable,
currentTime: currentTime,
onEnemyKilled: (Enemy killedEnemy) {
// Dynamic scoring based on the enemy type!
int pointsToAdd = 0;
switch (killedEnemy.runtimeType.toString()) {
case 'BrownGuard':
pointsToAdd = 100;
break;
case 'Dog':
pointsToAdd = 200;
break;
// You can easily plug in future enemies here!
// case 'SSOfficer': pointsToAdd = 500; break;
default:
pointsToAdd = 100; // Fallback
}
score += pointsToAdd;
// Optional: Print to console so you can see it working
print(
"Killed ${killedEnemy.runtimeType}! +$pointsToAdd (Score: $score)",
);
},
);
}
if (currentWeapon.state == WeaponState.idle &&
ammo <= 0 &&
currentWeapon.type != WeaponType.knife) {
requestWeaponSwitch(WeaponType.knife);
}
}
}

View File

@@ -0,0 +1,349 @@
// No flutter imports allowed!
import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_engine/wolf_3d_engine.dart';
import 'package:wolf_3d_entities/wolf_3d_entities.dart';
class WolfEngine {
WolfEngine({
required this.data,
required this.difficulty,
required this.startingEpisode,
required this.onGameWon,
});
final WolfensteinData data;
final Difficulty difficulty;
final int startingEpisode;
// Standard Dart function instead of Flutter's VoidCallback
final void Function() onGameWon;
// Managers
final InputManager inputManager = InputManager();
final DoorManager doorManager = DoorManager();
final PushwallManager pushwallManager = PushwallManager();
// State
late Player player;
late Level currentLevel;
late WolfLevel activeLevel;
List<Entity> entities = [];
int _currentEpisodeIndex = 0;
int _currentLevelIndex = 0;
int? _returnLevelIndex;
double damageFlashOpacity = 0.0;
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;
final inputResult = _processInputs(elapsed);
doorManager.update(elapsed);
pushwallManager.update(elapsed, currentLevel);
player.updateWeaponSwitch();
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);
if (damageFlashOpacity > 0) {
damageFlashOpacity = math.max(0.0, damageFlashOpacity - 0.05);
}
player.updateWeapon(
currentTime: elapsed.inMilliseconds,
entities: entities,
isWalkable: isWalkable,
);
}
void _loadLevel() {
entities.clear();
damageFlashOpacity = 0.0;
final episode = data.episodes[_currentEpisodeIndex];
activeLevel = episode.levels[_currentLevelIndex];
currentLevel = List.generate(64, (y) => List.from(activeLevel.wallGrid[y]));
final Level objectLevel = activeLevel.objectGrid;
doorManager.initDoors(currentLevel);
pushwallManager.initPushwalls(currentLevel, objectLevel);
// TODO: Consider abstracting audio so the engine doesn't depend on Wolf3d singleton
Wolf3d.I.audio.playLevelMusic(activeLevel);
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
int objId = objectLevel[y][x];
if (!MapObject.shouldSpawn(objId, difficulty)) continue;
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}) {
Wolf3d.I.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) {
inputManager.update();
const double moveSpeed = 0.14;
const double turnSpeed = 0.10;
Coordinate2D movement = const Coordinate2D(0, 0);
double dAngle = 0.0;
if (inputManager.requestedWeapon != null) {
player.requestWeaponSwitch(inputManager.requestedWeapon!);
}
if (inputManager.isFiring) {
player.fire(elapsed.inMilliseconds);
} else {
player.releaseTrigger();
}
if (inputManager.isTurningLeft) dAngle -= turnSpeed;
if (inputManager.isTurningRight) dAngle += turnSpeed;
Coordinate2D forwardVec = Coordinate2D(
math.cos(player.angle),
math.sin(player.angle),
);
if (inputManager.isMovingForward) movement += forwardVec * moveSpeed;
if (inputManager.isMovingBackward) movement -= forwardVec * moveSpeed;
if (inputManager.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: elapsed.inMilliseconds,
playerPosition: player.position,
isWalkable: isWalkable,
tryOpenDoor: doorManager.tryOpenDoor,
onDamagePlayer: (int damage) {
player.takeDamage(damage);
damageFlashOpacity = 0.5;
},
);
entity.angle = intent.newAngle;
entity.x += intent.movement.x;
entity.y += intent.movement.y;
if (entity.state == EntityState.dead &&
entity.isDying &&
!entity.hasDroppedItem) {
entity.hasDroppedItem = true;
Entity? droppedAmmo = EntityRegistry.spawn(
MapObject.ammoClip,
entity.x,
entity.y,
difficulty,
data.sprites.length,
);
if (droppedAmmo != null) itemsToAdd.add(droppedAmmo);
}
} else if (entity is Collectible) {
if (player.position.distanceTo(entity.position) < 0.5) {
if (player.tryPickup(entity)) {
itemsToRemove.add(entity);
}
}
}
}
if (itemsToRemove.isNotEmpty) {
entities.removeWhere((e) => itemsToRemove.contains(e));
}
if (itemsToAdd.isNotEmpty) entities.addAll(itemsToAdd);
}
bool isWalkable(int x, int y) {
if (currentLevel[y][x] == 0) return true;
if (currentLevel[y][x] >= 90) return doorManager.isDoorOpenEnough(x, y);
return false;
}
void _bumpPlayerIfStuck() {
int pX = player.x.toInt();
int pY = player.y.toInt();
if (pY < 0 ||
pY >= currentLevel.length ||
pX < 0 ||
pX >= currentLevel[0].length ||
currentLevel[pY][pX] > 0) {
double shortestDist = double.infinity;
Coordinate2D nearestSafeSpot = Coordinate2D(1.5, 1.5);
for (int y = 0; y < currentLevel.length; y++) {
for (int x = 0; x < currentLevel[y].length; x++) {
if (currentLevel[y][x] == 0) {
Coordinate2D safeSpot = Coordinate2D(x + 0.5, y + 0.5);
double dist = safeSpot.distanceTo(player.position);
if (dist < shortestDist) {
shortestDist = dist;
nearestSafeSpot = safeSpot;
}
}
}
}
player.x = nearestSafeSpot.x;
player.y = nearestSafeSpot.y;
}
}
}

View File

@@ -0,0 +1,9 @@
/// Support for doing something awesome.
///
/// More dartdocs go here.
library;
export 'src/managers/door_manager.dart';
export 'src/managers/pushwall_manager.dart';
export 'src/player/player.dart';
export 'src/wolf_3d_engine_base.dart';