Migrate all Dart packages to a single wolf_3d_dart package

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-17 10:55:10 +01:00
parent eec1f8f495
commit 0dc75ded62
120 changed files with 364 additions and 763 deletions

View File

@@ -0,0 +1,12 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
abstract class EngineAudio {
WolfensteinData? activeGame;
Future<void> debugSoundTest();
void playMenuMusic();
void playLevelMusic(WolfLevel level);
void stopMusic();
void playSoundEffect(int sfxId);
Future<void> init();
void dispose();
}

View File

@@ -0,0 +1,39 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class CliSilentAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
@override
Future<void> init() async {
// No-op for CLI
}
@override
void playMenuMusic() {}
@override
void playLevelMusic(WolfLevel level) {
// Optional: Print a log so you know it's working!
// print("🎵 Playing music for: ${level.name} 🎵");
}
@override
void stopMusic() {}
@override
void playSoundEffect(int sfxId) {
// Optional: You could use the terminal 'bell' character here
// to actually make a system beep when a sound plays!
// stdout.write('\x07');
}
@override
void dispose() {}
@override
Future<void> debugSoundTest() async {
return Future.value(null);
}
}

View File

@@ -0,0 +1,22 @@
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
/// A pure, framework-agnostic snapshot of the player's intended actions for a single frame.
class EngineInput {
final bool isMovingForward;
final bool isMovingBackward;
final bool isTurningLeft;
final bool isTurningRight;
final bool isFiring;
final bool isInteracting;
final WeaponType? requestedWeapon;
const EngineInput({
this.isMovingForward = false,
this.isMovingBackward = false,
this.isTurningLeft = false,
this.isTurningRight = false,
this.isFiring = false,
this.isInteracting = false,
this.requestedWeapon,
});
}

View File

@@ -0,0 +1,79 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
class DoorManager {
// Key is '$x,$y'
final Map<String, Door> doors = {};
// Callback to play sounds without tightly coupling to the audio engine
final void Function(int sfxId) onPlaySound;
DoorManager({required this.onPlaySound});
void initDoors(SpriteMap wallGrid) {
doors.clear();
for (int y = 0; y < wallGrid.length; y++) {
for (int x = 0; x < wallGrid[y].length; x++) {
int id = wallGrid[y][x];
if (id >= 90) {
doors['$x,$y'] = Door(x: x, y: y, mapId: id);
}
}
}
}
void update(Duration elapsed) {
for (final door in doors.values) {
final newState = door.update(elapsed.inMilliseconds);
// The Manager decides: "If a door just started closing, play the close sound."
if (newState == DoorState.closing) {
onPlaySound(WolfSound.closeDoor);
}
}
}
void handleInteraction(double playerX, double playerY, double playerAngle) {
int targetX = (playerX + math.cos(playerAngle)).toInt();
int targetY = (playerY + math.sin(playerAngle)).toInt();
String key = '$targetX,$targetY';
if (doors.containsKey(key)) {
if (doors[key]!.interact()) {
// The Manager decides: "Player successfully opened a door, play the sound."
onPlaySound(WolfSound.openDoor);
}
}
}
void tryOpenDoor(int x, int y) {
String key = '$x,$y';
if (doors.containsKey(key) && doors[key]!.offset == 0.0) {
if (doors[key]!.interact()) {
onPlaySound(WolfSound.openDoor);
}
}
}
// Helper method for the raycaster
Map<String, double> getOffsetsForRenderer() {
Map<String, double> offsets = {};
for (var entry in doors.entries) {
if (entry.value.offset > 0.0) {
offsets[entry.key] = entry.value.offset;
}
}
return offsets;
}
bool isDoorOpenEnough(int x, int y) {
String key = '$x,$y';
if (doors.containsKey(key)) {
// 0.7 offset means 70% open, similar to the original engine's check
return doors[key]!.offset > 0.7;
}
return false; // Not a door we manage
}
}

View File

@@ -0,0 +1,120 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
class Pushwall {
int x;
int y;
int mapId;
int dirX = 0;
int dirY = 0;
double offset = 0.0;
int tilesMoved = 0;
Pushwall(this.x, this.y, this.mapId);
}
class PushwallManager {
final Map<String, Pushwall> pushwalls = {};
Pushwall? activePushwall;
void initPushwalls(SpriteMap wallGrid, SpriteMap objectGrid) {
pushwalls.clear();
activePushwall = null;
for (int y = 0; y < objectGrid.length; y++) {
for (int x = 0; x < objectGrid[y].length; x++) {
if (objectGrid[y][x] == MapObject.pushwallTrigger) {
pushwalls['$x,$y'] = Pushwall(x, y, wallGrid[y][x]);
}
}
}
}
void update(Duration elapsed, SpriteMap wallGrid) {
if (activePushwall == null) return;
final pw = activePushwall!;
// Original logic: 1/128 tile per tick.
// At 70 ticks/sec, that is roughly 0.54 tiles per second.
const double originalSpeed = 0.546875;
pw.offset += (elapsed.inMilliseconds / 1000.0) * originalSpeed;
// Once it crosses a full tile boundary, we update the collision grid!
if (pw.offset >= 1.0) {
pw.offset -= 1.0;
pw.tilesMoved++;
int nextX = pw.x + pw.dirX;
int nextY = pw.y + pw.dirY;
// Move the solid block in the physical grid
wallGrid[nextY][nextX] = pw.mapId;
wallGrid[pw.y][pw.x] = 0; // Clear the old space so the player can walk in
// Update the dictionary key
pushwalls.remove('${pw.x},${pw.y}');
pw.x = nextX;
pw.y = nextY;
pushwalls['${pw.x},${pw.y}'] = pw;
// Check if we should keep sliding
bool blocked = false;
int checkX = pw.x + pw.dirX;
int checkY = pw.y + pw.dirY;
if (checkX < 0 ||
checkX >= wallGrid[0].length ||
checkY < 0 ||
checkY >= wallGrid.length) {
blocked = true;
} else if (wallGrid[checkY][checkX] != 0) {
blocked = true; // Blocked by another wall or a door
}
// Standard Wolf3D pushwalls move exactly 2 tiles (or 1 if blocked)
if (pw.tilesMoved >= 2 || blocked) {
activePushwall = null;
pw.offset = 0.0;
}
}
}
void handleInteraction(
double playerX,
double playerY,
double playerAngle,
SpriteMap wallGrid,
) {
// Only one pushwall can move at a time in the original engine!
if (activePushwall != null) return;
int targetX = (playerX + math.cos(playerAngle)).toInt();
int targetY = (playerY + math.sin(playerAngle)).toInt();
String key = '$targetX,$targetY';
if (pushwalls.containsKey(key)) {
final pw = pushwalls[key]!;
// Determine the push direction based on the player's relative position
double dx = (targetX + 0.5) - playerX;
double dy = (targetY + 0.5) - playerY;
if (dx.abs() > dy.abs()) {
pw.dirX = dx > 0 ? 1 : -1;
pw.dirY = 0;
} else {
pw.dirX = 0;
pw.dirY = dy > 0 ? 1 : -1;
}
// Make sure the tile behind the wall is empty before starting the push
int checkX = targetX + pw.dirX;
int checkY = targetY + pw.dirY;
if (wallGrid[checkY][checkX] == 0) {
activePushwall = pw;
}
}
}
}

View File

@@ -0,0 +1,267 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
enum WeaponSwitchState { idle, lowering, raising }
class Player {
// Spatial
double x;
double y;
double angle;
// Stats
int health = 100;
int ammo = 8;
int score = 0;
// Damage flash
double damageFlash = 0.0; // 0.0 is none, 1.0 is maximum red
final double damageFlashFadeSpeed = 0.05; // How fast it fades per tick
// Inventory
bool hasGoldKey = false;
bool hasSilverKey = false;
bool hasMachineGun = false;
bool hasChainGun = false;
// Weapon System
late Weapon currentWeapon;
final Map<WeaponType, Weapon?> weapons = {
WeaponType.knife: Knife(),
WeaponType.pistol: Pistol(),
WeaponType.machineGun: null,
WeaponType.chainGun: null,
};
WeaponSwitchState switchState = WeaponSwitchState.idle;
WeaponType? pendingWeaponType;
// 0.0 is resting, 500.0 is fully off-screen
double weaponAnimOffset = 0.0;
// How fast the weapon drops/raises per tick
final double switchSpeed = 30.0;
Player({required this.x, required this.y, required this.angle}) {
currentWeapon = weapons[WeaponType.pistol]!;
}
// Helper getter to interface with the RaycasterPainter
Coordinate2D get position => Coordinate2D(x, y);
// --- General Update ---
void tick(Duration elapsed) {
// Fade the damage flash over time
if (damageFlash > 0.0) {
// Assuming 60fps, we fade it out
damageFlash = math.max(0.0, damageFlash - damageFlashFadeSpeed);
}
updateWeaponSwitch();
}
// --- Weapon Switching & Animation Logic ---
void updateWeaponSwitch() {
if (switchState == WeaponSwitchState.lowering) {
// If the map doesn't contain the pending weapon, stop immediately
if (weapons[pendingWeaponType] == null) {
switchState = WeaponSwitchState.idle;
return;
}
weaponAnimOffset += switchSpeed;
if (weaponAnimOffset >= 500.0) {
weaponAnimOffset = 500.0;
// We already know it's not null now, but we can keep the
// fallback to pistol just to be extra safe.
currentWeapon = weapons[pendingWeaponType]!;
switchState = WeaponSwitchState.raising;
}
} else if (switchState == WeaponSwitchState.raising) {
weaponAnimOffset -= switchSpeed;
if (weaponAnimOffset <= 0) {
weaponAnimOffset = 0.0;
switchState = WeaponSwitchState.idle;
}
}
}
void requestWeaponSwitch(WeaponType weaponType) {
if (switchState != WeaponSwitchState.idle) return;
if (currentWeapon.state != WeaponState.idle) return;
if (weaponType == currentWeapon.type) return;
if (!weapons.containsKey(weaponType)) return;
if (weaponType != WeaponType.knife && ammo <= 0) return;
pendingWeaponType = weaponType;
switchState = WeaponSwitchState.lowering;
}
// --- Health & Damage ---
void takeDamage(int damage) {
health = math.max(0, health - damage);
// Spike the damage flash based on how much damage was taken
// A 10 damage hit gives a 0.5 flash, a 20 damage hit maxes it out at 1.0
damageFlash = math.min(1.0, damageFlash + (damage * 0.05));
if (health <= 0) {
print("YOU DIED!");
} else {
print("Ouch! ($health)");
}
}
void heal(int amount) {
final int newHealth = math.min(100, health + amount);
if (health < 100) {
print("Feelin' better. ($newHealth)");
}
health = newHealth;
}
void addAmmo(int amount) {
final int newAmmo = math.min(99, ammo + amount);
if (ammo < 99) {
print("Hell yeah. ($newAmmo)");
}
ammo = newAmmo;
}
bool tryPickup(Collectible item) {
bool pickedUp = false;
switch (item.type) {
case CollectibleType.health:
if (health >= 100) return false;
heal(item.mapId == MapObject.dogFoodDecoration ? 4 : 25);
pickedUp = true;
break;
case CollectibleType.ammo:
if (ammo >= 99) return false;
int previousAmmo = ammo;
addAmmo(8);
if (currentWeapon is Knife && previousAmmo <= 0) {
requestWeaponSwitch(WeaponType.pistol);
}
pickedUp = true;
break;
case CollectibleType.treasure:
if (item.mapId == MapObject.cross) score += 100;
if (item.mapId == MapObject.chalice) score += 500;
if (item.mapId == MapObject.chest) score += 1000;
if (item.mapId == MapObject.crown) score += 5000;
if (item.mapId == MapObject.extraLife) {
heal(100);
addAmmo(25);
}
pickedUp = true;
break;
case CollectibleType.weapon:
if (item.mapId == MapObject.machineGun) {
if (weapons[WeaponType.machineGun] == null) {
weapons[WeaponType.machineGun] = MachineGun();
hasMachineGun = true;
}
addAmmo(8);
requestWeaponSwitch(WeaponType.machineGun);
pickedUp = true;
}
if (item.mapId == MapObject.chainGun) {
if (weapons[WeaponType.chainGun] == null) {
weapons[WeaponType.chainGun] = ChainGun();
hasChainGun = true;
}
addAmmo(8);
requestWeaponSwitch(WeaponType.chainGun);
pickedUp = true;
}
break;
case CollectibleType.key:
if (item.mapId == MapObject.goldKey) hasGoldKey = true;
if (item.mapId == MapObject.silverKey) hasSilverKey = true;
pickedUp = true;
break;
}
return pickedUp;
}
void fire(int currentTime) {
if (switchState != WeaponSwitchState.idle) return;
// We pass the isFiring state to handle automatic vs semi-auto behavior
bool shotFired = currentWeapon.fire(currentTime, currentAmmo: ammo);
if (shotFired && currentWeapon.type != WeaponType.knife) {
ammo--;
}
}
void releaseTrigger() {
currentWeapon.releaseTrigger();
}
/// Returns true only on the specific frame where the hit should be calculated
void updateWeapon({
required int currentTime,
required List<Entity> entities,
required bool Function(int x, int y) isWalkable,
}) {
int oldFrame = currentWeapon.frameIndex;
currentWeapon.update(currentTime);
// If we just crossed into the firing frame...
if (currentWeapon.state == WeaponState.firing &&
oldFrame == 0 &&
currentWeapon.frameIndex == 1) {
currentWeapon.performHitscan(
playerX: x,
playerY: y,
playerAngle: angle,
entities: entities,
isWalkable: isWalkable,
currentTime: currentTime,
onEnemyKilled: (Enemy killedEnemy) {
// Dynamic scoring based on the enemy type!
int pointsToAdd = 0;
switch (killedEnemy.runtimeType.toString()) {
case 'BrownGuard':
pointsToAdd = 100;
break;
case 'Dog':
pointsToAdd = 200;
break;
// You can easily plug in future enemies here!
// case 'SSOfficer': pointsToAdd = 500; break;
default:
pointsToAdd = 100; // Fallback
}
score += pointsToAdd;
// Optional: Print to console so you can see it working
print(
"Killed ${killedEnemy.runtimeType}! +$pointsToAdd (Score: $score)",
);
},
);
}
if (currentWeapon.state == WeaponState.idle &&
ammo <= 0 &&
currentWeapon.type != WeaponType.knife) {
requestWeaponSwitch(WeaponType.knife);
}
}
}

View File

@@ -0,0 +1,522 @@
import 'dart:math' as math;
import 'package:arcane_helper_utils/arcane_helper_utils.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class AsciiTheme {
/// The character ramp, ordered from most dense (index 0) to least dense (last index).
final String ramp;
const AsciiTheme(this.ramp);
/// Always returns the densest character (e.g., for walls, UI, floors)
String get solid => ramp[0];
/// Always returns the completely empty character (e.g., for pitch black darkness)
String get empty => ramp[ramp.length - 1];
/// Returns a character based on a 0.0 to 1.0 brightness scale.
/// 1.0 returns the [solid] character, 0.0 returns the [empty] character.
String getByBrightness(double brightness) {
double b = brightness.clamp(0.0, 1.0);
int index = ((1.0 - b) * (ramp.length - 1)).round();
return ramp[index];
}
}
/// A collection of pre-defined character sets
abstract class AsciiThemes {
static const AsciiTheme blocks = AsciiTheme("█▓▒░ ");
static const AsciiTheme classic = AsciiTheme("@%#*+=-:. ");
static const AsciiTheme dense = AsciiTheme("█▇▆▅▄▃▂ ");
}
class ColoredChar {
final String char;
final int rawColor; // Stores the AABBGGRR integer from the palette
ColoredChar(this.char, this.rawColor);
// Safely extract the exact RGB channels regardless of framework
int get r => rawColor & 0xFF;
int get g => (rawColor >> 8) & 0xFF;
int get b => (rawColor >> 16) & 0xFF;
// Outputs standard AARRGGBB for Flutter's Color(int) constructor
int get argb => (0xFF000000) | (r << 16) | (g << 8) | b;
}
class AsciiRasterizer extends Rasterizer {
AsciiRasterizer({
this.activeTheme = AsciiThemes.blocks,
this.aspectMultiplier = 1.0,
this.verticalStretch = 1.0,
});
AsciiTheme activeTheme = AsciiThemes.blocks;
late List<List<ColoredChar>> _screen;
late WolfEngine _engine;
@override
final double aspectMultiplier;
@override
final double verticalStretch;
// Intercept the base render call to initialize our text grid
@override
dynamic render(WolfEngine engine, FrameBuffer buffer) {
_engine = engine;
_screen = List.generate(
buffer.height,
(_) =>
List.filled(buffer.width, ColoredChar(' ', ColorPalette.vga32Bit[0])),
);
return super.render(engine, buffer);
}
@override
void prepareFrame(WolfEngine engine) {
// Just grab the raw ints!
final int ceilingColor = ColorPalette.vga32Bit[25];
final int floorColor = ColorPalette.vga32Bit[29];
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
if (y < viewHeight / 2) {
_screen[y][x] = ColoredChar(activeTheme.solid, ceilingColor);
} else if (y < viewHeight) {
_screen[y][x] = ColoredChar(activeTheme.solid, floorColor);
}
}
}
}
@override
void drawWallColumn(
int x,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
) {
double brightness = calculateDepthBrightness(perpWallDist);
String wallChar = activeTheme.getByBrightness(brightness);
for (int y = drawStart; y < drawEnd; y++) {
double relativeY =
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
int colorByte = texture.pixels[texX * 64 + texY];
int pixelColor = ColorPalette.vga32Bit[colorByte]; // Raw int
// Faux directional lighting using your new base class method
if (side == 1) {
pixelColor = shadeColor(pixelColor);
}
_screen[y][x] = ColoredChar(wallChar, pixelColor);
}
}
@override
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
) {
double brightness = calculateDepthBrightness(transformY);
for (
int y = math.max(0, drawStartY);
y < math.min(viewHeight, drawEndY);
y++
) {
double relativeY = (y - drawStartY) / spriteHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
int colorByte = texture.pixels[texX * 64 + texY];
if (colorByte != 255) {
int rawColor = ColorPalette.vga32Bit[colorByte];
// Shade the sprite's actual RGB color based on distance
int r = (rawColor & 0xFF);
int g = ((rawColor >> 8) & 0xFF);
int b = ((rawColor >> 16) & 0xFF);
r = (r * brightness).toInt();
g = (g * brightness).toInt();
b = (b * brightness).toInt();
int shadedColor = (0xFF000000) | (b << 16) | (g << 8) | r;
// Force sprites to be SOLID so they don't vanish into the terminal background
_screen[y][stripeX] = ColoredChar(activeTheme.solid, shadedColor);
}
}
}
@override
void drawWeapon(WolfEngine engine) {
int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex(
engine.data.sprites.length,
);
Sprite weaponSprite = engine.data.sprites[spriteIndex];
int weaponWidth = (width * 0.5).toInt();
int weaponHeight = (viewHeight * 0.8).toInt();
int startX = (width ~/ 2) - (weaponWidth ~/ 2);
int startY =
viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4);
for (int dy = 0; dy < weaponHeight; dy++) {
for (int dx = 0; dx < weaponWidth; dx++) {
int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63);
int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63);
int colorByte = weaponSprite.pixels[texX * 64 + texY];
if (colorByte != 255) {
int drawX = startX + dx;
int drawY = startY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) {
_screen[drawY][drawX] = ColoredChar(
activeTheme.solid,
ColorPalette.vga32Bit[colorByte],
);
}
}
}
}
}
// --- PRIVATE HUD DRAWING HELPER ---
/// Injects a pure text string directly into the rasterizer grid
void _writeString(int startX, int y, String text, int color) {
for (int i = 0; i < text.length; i++) {
int x = startX + i;
if (x >= 0 && x < width && y >= 0 && y < height) {
_screen[y][x] = ColoredChar(text[i], color);
}
}
}
@override
void drawHud(WolfEngine engine) {
// If the terminal is at least 160 columns wide and 50 rows tall,
// there are enough "pixels" to downscale the VGA image clearly.
if (width >= 160 && height >= 50) {
_drawFullVgaHud(engine);
} else {
_drawSimpleHud(engine);
}
}
void _drawSimpleHud(WolfEngine engine) {
// 1. Pull Retro Colors
final int vgaStatusBarBlue = ColorPalette.vga32Bit[153];
final int vgaPanelDark = ColorPalette.vga32Bit[0];
final int white = ColorPalette.vga32Bit[15];
final int yellow = ColorPalette.vga32Bit[11];
final int red = ColorPalette.vga32Bit[4];
// 2. Setup Centered Layout
// The total width of our standard HUD elements is roughly 120 chars
const int hudContentWidth = 120;
final int offsetX = ((width - hudContentWidth) ~/ 2).clamp(0, width);
// 3. Clear HUD Base
_fillRect(0, viewHeight, width, height - viewHeight, ' ', vgaStatusBarBlue);
_writeString(0, viewHeight, "" * width, white);
// 4. Panel Drawing Helper
void drawBorderedPanel(int startX, int startY, int w, int h) {
_fillRect(startX, startY, w, h, ' ', vgaPanelDark);
// Horizontal lines
_writeString(startX, startY, "${"" * (w - 2)}", white);
_writeString(startX, startY + h - 1, "${"" * (w - 2)}", white);
// Vertical sides
for (int i = 1; i < h - 1; i++) {
_writeString(startX, startY + i, "", white);
_writeString(startX + w - 1, startY + i, "", white);
}
}
// 5. Draw the Panels
// FLOOR
drawBorderedPanel(offsetX + 4, viewHeight + 2, 12, 5);
_writeString(offsetX + 7, viewHeight + 3, "FLOOR", white);
_writeString(
offsetX + 9,
viewHeight + 5,
engine.activeLevel.name.split(' ').last,
white,
);
// SCORE
drawBorderedPanel(offsetX + 18, viewHeight + 2, 24, 5);
_writeString(offsetX + 27, viewHeight + 3, "SCORE", white);
_writeString(
offsetX + 27,
viewHeight + 5,
engine.player.score.toString().padLeft(6, '0'),
white,
);
// LIVES
drawBorderedPanel(offsetX + 44, viewHeight + 2, 12, 5);
_writeString(offsetX + 47, viewHeight + 3, "LIVES", white);
_writeString(offsetX + 49, viewHeight + 5, "3", white);
// FACE (With Reactive BJ Logic)
drawBorderedPanel(offsetX + 58, viewHeight + 1, 14, 7);
String face = " :-)";
if (engine.player.health <= 0) {
face = " X-x";
} else if (engine.player.damageFlash > 0.1) {
face = " :-O"; // Mouth open in pain!
} else if (engine.player.health <= 25) {
face = " :-(";
} else if (engine.player.health <= 60) {
face = " :-|";
}
_writeString(offsetX + 63, viewHeight + 4, face, yellow);
// HEALTH
int healthColor = engine.player.health > 25 ? white : red;
drawBorderedPanel(offsetX + 74, viewHeight + 2, 16, 5);
_writeString(offsetX + 78, viewHeight + 3, "HEALTH", white);
_writeString(
offsetX + 79,
viewHeight + 5,
"${engine.player.health}%",
healthColor,
);
// AMMO
drawBorderedPanel(offsetX + 92, viewHeight + 2, 12, 5);
_writeString(offsetX + 95, viewHeight + 3, "AMMO", white);
_writeString(offsetX + 97, viewHeight + 5, "${engine.player.ammo}", white);
// WEAPON
drawBorderedPanel(offsetX + 106, viewHeight + 2, 14, 5);
String weapon = engine.player.currentWeapon.type.name.spacePascalCase!
.toUpperCase();
if (weapon.length > 12) weapon = weapon.substring(0, 12);
_writeString(offsetX + 107, viewHeight + 4, weapon, white);
}
void _drawFullVgaHud(WolfEngine engine) {
int statusBarIndex = engine.data.vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
// 1. Draw Background
_blitVgaImageAscii(engine.data.vgaImages[statusBarIndex], 0, 160);
// 2. Draw Stats
_drawNumberAscii(1, 32, 176, engine.data.vgaImages); // Floor
_drawNumberAscii(
engine.player.score,
96,
176,
engine.data.vgaImages,
); // Score
_drawNumberAscii(3, 120, 176, engine.data.vgaImages); // Lives
_drawNumberAscii(
engine.player.health,
192,
176,
engine.data.vgaImages,
); // Health
_drawNumberAscii(
engine.player.ammo,
232,
176,
engine.data.vgaImages,
); // Ammo
// 3. Draw BJ's Face & Current Weapon
_drawFaceAscii(engine);
_drawWeaponIconAscii(engine);
}
void _drawNumberAscii(
int value,
int rightAlignX,
int startY,
List<VgaImage> vgaImages,
) {
const int zeroIndex = 96;
String numStr = value.toString();
int currentX = rightAlignX - (numStr.length * 8);
for (int i = 0; i < numStr.length; i++) {
int digit = int.parse(numStr[i]);
if (zeroIndex + digit < vgaImages.length) {
_blitVgaImageAscii(vgaImages[zeroIndex + digit], currentX, startY);
}
currentX += 8;
}
}
void _drawFaceAscii(WolfEngine engine) {
int health = engine.player.health;
int faceIndex;
if (health <= 0) {
faceIndex = 127;
} else {
int healthTier = ((100 - health) ~/ 16).clamp(0, 6);
faceIndex = 106 + (healthTier * 3);
}
if (faceIndex < engine.data.vgaImages.length) {
_blitVgaImageAscii(engine.data.vgaImages[faceIndex], 136, 164);
}
}
void _drawWeaponIconAscii(WolfEngine engine) {
int weaponIndex = 89;
if (engine.player.hasChainGun) {
weaponIndex = 91;
} else if (engine.player.hasMachineGun) {
weaponIndex = 90;
}
if (weaponIndex < engine.data.vgaImages.length) {
_blitVgaImageAscii(engine.data.vgaImages[weaponIndex], 256, 164);
}
}
/// Helper to fill a rectangular area with a specific char and background color
void _fillRect(int startX, int startY, int w, int h, String char, int color) {
for (int dy = 0; dy < h; dy++) {
for (int dx = 0; dx < w; dx++) {
int x = startX + dx;
int y = startY + dy;
if (x >= 0 && x < width && y >= 0 && y < height) {
_screen[y][x] = ColoredChar(char, color);
}
}
}
}
@override
dynamic finalizeFrame() {
if (_engine.player.damageFlash > 0.0) {
_applyDamageFlash();
}
return _screen;
}
// --- PRIVATE HUD DRAWING HELPERS ---
void _blitVgaImageAscii(VgaImage image, int startX_320, int startY_200) {
int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height;
double scaleX = width / 320.0;
double scaleY = height / 200.0;
int destStartX = (startX_320 * scaleX).toInt();
int destStartY = (startY_200 * scaleY).toInt();
int destWidth = (image.width * scaleX).toInt();
int destHeight = (image.height * scaleY).toInt();
for (int dy = 0; dy < destHeight; dy++) {
for (int dx = 0; dx < destWidth; dx++) {
int drawX = destStartX + dx;
int drawY = destStartY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1);
int srcY = (dy / scaleY).toInt().clamp(0, image.height - 1);
int plane = srcX % 4;
int sx = srcX ~/ 4;
int index = (plane * planeSize) + (srcY * planeWidth) + sx;
int colorByte = image.pixels[index];
if (colorByte != 255) {
_screen[drawY][drawX] = ColoredChar(
activeTheme.solid,
ColorPalette.vga32Bit[colorByte],
);
}
}
}
}
}
// --- DAMAGE FLASH ---
void _applyDamageFlash() {
double intensity = _engine.player.damageFlash;
int redBoost = (150 * intensity).toInt();
double colorDrop = 1.0 - (0.5 * intensity);
for (int y = 0; y < viewHeight; y++) {
for (int x = 0; x < width; x++) {
ColoredChar cell = _screen[y][x];
// Use our safe getters!
int r = cell.r;
int g = cell.g;
int b = cell.b;
r = (r + redBoost).clamp(0, 255);
g = (g * colorDrop).toInt().clamp(0, 255);
b = (b * colorDrop).toInt().clamp(0, 255);
// Pack back into the native AABBGGRR format that ColoredChar expects
int newRawColor = (0xFF000000) | (b << 16) | (g << 8) | r;
_screen[y][x] = ColoredChar(cell.char, newRawColor);
}
}
}
/// Converts the current frame to a single printable ANSI string
String toAnsiString() {
StringBuffer buffer = StringBuffer();
int lastR = -1;
int lastG = -1;
int lastB = -1;
for (int y = 0; y < _screen.length; y++) {
List<ColoredChar> row = _screen[y];
for (ColoredChar cell in row) {
if (cell.r != lastR || cell.g != lastG || cell.b != lastB) {
buffer.write('\x1b[38;2;${cell.r};${cell.g};${cell.b}m');
lastR = cell.r;
lastG = cell.g;
lastB = cell.b;
}
buffer.write(cell.char);
}
// Only print a newline if we are NOT on the very last row.
// This stops the terminal from scrolling down!
if (y < _screen.length - 1) {
buffer.write('\n');
}
}
// Reset the terminal color at the very end
buffer.write('\x1b[0m');
return buffer.toString();
}
}

View File

@@ -0,0 +1,388 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
abstract class Rasterizer {
late List<double> zBuffer;
late int width;
late int height;
late int viewHeight;
/// A multiplier to adjust the width of sprites.
/// Pixel renderers usually keep this at 1.0.
/// ASCII renderers can override this (e.g., 0.6) to account for tall characters.
double get aspectMultiplier => 1.0;
/// A multiplier to counteract tall pixel formats (like 1:2 terminal fonts).
/// Defaults to 1.0 (no squish) for standard pixel rendering.
double get verticalStretch => 1.0;
/// The main entry point called by the game loop.
/// Orchestrates the mathematical rendering pipeline.
dynamic render(WolfEngine engine, FrameBuffer buffer) {
width = buffer.width;
height = buffer.height;
// The 3D view typically takes up the top 80% of the screen
viewHeight = (height * 0.8).toInt();
zBuffer = List.filled(width, 0.0);
// 1. Setup the frame (clear screen, draw floor/ceiling)
prepareFrame(engine);
// 2. Do the heavy math for Raycasting Walls
_castWalls(engine);
// 3. Do the heavy math for Projecting Sprites
_castSprites(engine);
// 4. Draw 2D Overlays
drawWeapon(engine);
drawHud(engine);
// 5. Finalize and return the frame data (Buffer or String/List)
return finalizeFrame();
}
// ===========================================================================
// ABSTRACT METHODS (Implemented by the child renderers)
// ===========================================================================
/// Initialize buffers, clear the screen, and draw the floor/ceiling.
void prepareFrame(WolfEngine engine);
/// Draw a single vertical column of a wall.
void drawWallColumn(
int x,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
);
/// Draw a single vertical stripe of a sprite (enemy/item).
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
);
/// Draw the player's weapon overlay at the bottom of the 3D view.
void drawWeapon(WolfEngine engine);
/// Draw the 2D status bar at the bottom 20% of the screen.
void drawHud(WolfEngine engine);
/// Return the finished frame (e.g., the FrameBuffer itself, or an ASCII list).
dynamic finalizeFrame();
// ===========================================================================
// SHARED LIGHTING MATH
// ===========================================================================
/// Calculates depth-based lighting falloff (0.0 to 1.0).
/// While the original Wolf3D didn't use depth fog, this provides a great
/// atmospheric effect for custom renderers (like ASCII dithering).
double calculateDepthBrightness(double distance) {
return (10.0 / (distance + 2.0)).clamp(0.0, 1.0);
}
// ===========================================================================
// CORE ENGINE MATH (Shared across all renderers)
// ===========================================================================
void _castWalls(WolfEngine engine) {
final Player player = engine.player;
final SpriteMap map = engine.currentLevel;
final List<Sprite> wallTextures = engine.data.walls;
final Map<String, double> doorOffsets = engine.doorManager
.getOffsetsForRenderer();
final Pushwall? activePushwall = engine.pushwallManager.activePushwall;
final double fov = math.pi / 3;
Coordinate2D dir = Coordinate2D(
math.cos(player.angle),
math.sin(player.angle),
);
Coordinate2D plane = Coordinate2D(-dir.y, dir.x) * math.tan(fov / 2);
for (int x = 0; x < width; x++) {
double cameraX = 2 * x / width - 1.0;
Coordinate2D rayDir = dir + (plane * cameraX);
int mapX = player.x.toInt();
int mapY = player.y.toInt();
double deltaDistX = (rayDir.x == 0) ? 1e30 : (1.0 / rayDir.x).abs();
double deltaDistY = (rayDir.y == 0) ? 1e30 : (1.0 / rayDir.y).abs();
double sideDistX, sideDistY, perpWallDist = 0.0;
int stepX, stepY, side = 0, hitWallId = 0;
bool hit = false, hitOutOfBounds = false, customDistCalculated = false;
double textureOffset = 0.0;
Set<String> ignoredDoors = {};
if (rayDir.x < 0) {
stepX = -1;
sideDistX = (player.x - mapX) * deltaDistX;
} else {
stepX = 1;
sideDistX = (mapX + 1.0 - player.x) * deltaDistX;
}
if (rayDir.y < 0) {
stepY = -1;
sideDistY = (player.y - mapY) * deltaDistY;
} else {
stepY = 1;
sideDistY = (mapY + 1.0 - player.y) * deltaDistY;
}
// DDA Loop
while (!hit) {
if (sideDistX < sideDistY) {
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
} else {
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
if (mapY < 0 ||
mapY >= map.length ||
mapX < 0 ||
mapX >= map[0].length) {
hit = true;
hitOutOfBounds = true;
} else if (map[mapY][mapX] > 0) {
String mapKey = '$mapX,$mapY';
// DOOR LOGIC
if (map[mapY][mapX] >= 90 && !ignoredDoors.contains(mapKey)) {
double currentOffset = doorOffsets[mapKey] ?? 0.0;
if (currentOffset > 0.0) {
double perpWallDistTemp = (side == 0)
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
double wallXTemp = (side == 0)
? player.y + perpWallDistTemp * rayDir.y
: player.x + perpWallDistTemp * rayDir.x;
wallXTemp -= wallXTemp.floor();
if (wallXTemp < currentOffset) {
ignoredDoors.add(mapKey);
continue; // Ray passes through the open part of the door
}
}
hit = true;
hitWallId = map[mapY][mapX];
textureOffset = currentOffset;
}
// PUSHWALL LOGIC
else if (activePushwall != null &&
mapX == activePushwall.x &&
mapY == activePushwall.y) {
hit = true;
hitWallId = map[mapY][mapX];
double pOffset = activePushwall.offset;
int pDirX = activePushwall.dirX;
int pDirY = activePushwall.dirY;
perpWallDist = (side == 0)
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
if (side == 0 && pDirX != 0) {
if (pDirX == stepX) {
double intersect = perpWallDist + pOffset * deltaDistX;
if (intersect < sideDistY) {
perpWallDist = intersect;
} else {
side = 1;
perpWallDist = sideDistY - deltaDistY;
}
} else {
perpWallDist -= (1.0 - pOffset) * deltaDistX;
}
} else if (side == 1 && pDirY != 0) {
if (pDirY == stepY) {
double intersect = perpWallDist + pOffset * deltaDistY;
if (intersect < sideDistX) {
perpWallDist = intersect;
} else {
side = 0;
perpWallDist = sideDistX - deltaDistX;
}
} else {
perpWallDist -= (1.0 - pOffset) * deltaDistY;
}
} else {
double wallFraction = (side == 0)
? player.y + perpWallDist * rayDir.y
: player.x + perpWallDist * rayDir.x;
wallFraction -= wallFraction.floor();
if (side == 0) {
if (pDirY == 1 && wallFraction < pOffset) hit = false;
if (pDirY == -1 && wallFraction > (1.0 - pOffset)) hit = false;
if (hit) textureOffset = pOffset * pDirY;
} else {
if (pDirX == 1 && wallFraction < pOffset) hit = false;
if (pDirX == -1 && wallFraction > (1.0 - pOffset)) hit = false;
if (hit) textureOffset = pOffset * pDirX;
}
}
if (!hit) continue;
customDistCalculated = true;
} else {
hit = true;
hitWallId = map[mapY][mapX];
}
}
}
if (hitOutOfBounds) continue;
if (!customDistCalculated) {
perpWallDist = (side == 0)
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
}
if (perpWallDist < 0.1) perpWallDist = 0.1;
// Save for sprite depth checks
zBuffer[x] = perpWallDist;
// Calculate Texture X Coordinate
double wallX = (side == 0)
? player.y + perpWallDist * rayDir.y
: player.x + perpWallDist * rayDir.x;
wallX -= wallX.floor();
int texNum;
if (hitWallId >= 90) {
texNum = 98.clamp(0, wallTextures.length - 1);
} else {
texNum = ((hitWallId - 1) * 2).clamp(0, wallTextures.length - 2);
if (side == 1) texNum += 1;
}
Sprite texture = wallTextures[texNum];
// Texture flipping for specific orientations
int texX = (((wallX - textureOffset) % 1.0) * 64).toInt().clamp(0, 63);
if (side == 0 && math.cos(player.angle) > 0) texX = 63 - texX;
if (side == 1 && math.sin(player.angle) < 0) texX = 63 - texX;
// Calculate drawing dimensions
int columnHeight = ((viewHeight / perpWallDist) * verticalStretch)
.toInt();
int drawStart = (-columnHeight ~/ 2 + viewHeight ~/ 2).clamp(
0,
viewHeight,
);
int drawEnd = (columnHeight ~/ 2 + viewHeight ~/ 2).clamp(0, viewHeight);
// Tell the implementation to draw this column
drawWallColumn(
x,
drawStart,
drawEnd,
columnHeight,
texture,
texX,
perpWallDist,
side,
);
}
}
void _castSprites(WolfEngine engine) {
final Player player = engine.player;
final List<Entity> activeSprites = List.from(engine.entities);
// Sort from furthest to closest (Painter's Algorithm)
activeSprites.sort((a, b) {
double distA = player.position.distanceTo(a.position);
double distB = player.position.distanceTo(b.position);
return distB.compareTo(distA);
});
Coordinate2D dir = Coordinate2D(
math.cos(player.angle),
math.sin(player.angle),
);
Coordinate2D plane =
Coordinate2D(-dir.y, dir.x) * math.tan((math.pi / 3) / 2);
for (Entity entity in activeSprites) {
Coordinate2D spritePos = entity.position - player.position;
double invDet = 1.0 / (plane.x * dir.y - dir.x * plane.y);
double transformX = invDet * (dir.y * spritePos.x - dir.x * spritePos.y);
double transformY =
invDet * (-plane.y * spritePos.x + plane.x * spritePos.y);
// Only process if the sprite is in front of the camera
if (transformY > 0) {
int spriteScreenX = ((width / 2) * (1 + transformX / transformY))
.toInt();
int spriteHeight = ((viewHeight / transformY).abs() * verticalStretch)
.toInt();
// Scale width based on the aspectMultiplier (useful for ASCII)
int spriteWidth = (spriteHeight * aspectMultiplier / verticalStretch)
.toInt();
int drawStartY = -spriteHeight ~/ 2 + viewHeight ~/ 2;
int drawEndY = spriteHeight ~/ 2 + viewHeight ~/ 2;
int drawStartX = -spriteWidth ~/ 2 + spriteScreenX;
int drawEndX = spriteWidth ~/ 2 + spriteScreenX;
int clipStartX = math.max(0, drawStartX);
int clipEndX = math.min(width - 1, drawEndX);
int safeIndex = entity.spriteIndex.clamp(
0,
engine.data.sprites.length - 1,
);
Sprite texture = engine.data.sprites[safeIndex];
// Loop through the visible vertical stripes
for (int stripe = clipStartX; stripe < clipEndX; stripe++) {
// Check the Z-Buffer to see if a wall is in front of this stripe
if (transformY < zBuffer[stripe]) {
int texX = ((stripe - drawStartX) * 64 ~/ spriteWidth).clamp(0, 63);
// Tell the implementation to draw this stripe
drawSpriteStripe(
stripe,
drawStartY,
drawEndY,
spriteHeight,
texture,
texX,
transformY,
);
}
}
}
}
}
/// Darkens a 32-bit 0xAABBGGRR color by roughly 30% without touching Alpha
int shadeColor(int color) {
int r = (color & 0xFF) * 7 ~/ 10;
int g = ((color >> 8) & 0xFF) * 7 ~/ 10;
int b = ((color >> 16) & 0xFF) * 7 ~/ 10;
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
}

View File

@@ -0,0 +1,264 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class SoftwareRasterizer extends Rasterizer {
late FrameBuffer _buffer;
late WolfEngine _engine;
// Intercept the base render call to store our references
@override
dynamic render(WolfEngine engine, FrameBuffer buffer) {
_engine = engine;
_buffer = buffer;
return super.render(engine, buffer);
}
@override
void prepareFrame(WolfEngine engine) {
// Top half is ceiling color (25), bottom half is floor color (29)
int ceilingColor = ColorPalette.vga32Bit[25];
int floorColor = ColorPalette.vga32Bit[29];
for (int y = 0; y < viewHeight; y++) {
int color = (y < viewHeight / 2) ? ceilingColor : floorColor;
for (int x = 0; x < width; x++) {
_buffer.pixels[y * width + x] = color;
}
}
}
@override
void drawWallColumn(
int x,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
) {
for (int y = drawStart; y < drawEnd; y++) {
// Calculate which Y pixel of the texture to sample
double relativeY =
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
int colorByte = texture.pixels[texX * 64 + texY];
int pixelColor = ColorPalette.vga32Bit[colorByte];
// Darken Y-side walls for faux directional lighting
if (side == 1) {
pixelColor = shadeColor(pixelColor);
}
_buffer.pixels[y * width + x] = pixelColor;
}
}
@override
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
) {
for (
int y = math.max(0, drawStartY);
y < math.min(viewHeight, drawEndY);
y++
) {
double relativeY = (y - drawStartY) / spriteHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
int colorByte = texture.pixels[texX * 64 + texY];
// 255 is the "transparent" color index in VGA Wolfenstein
if (colorByte != 255) {
_buffer.pixels[y * width + stripeX] = ColorPalette.vga32Bit[colorByte];
}
}
}
@override
void drawWeapon(WolfEngine engine) {
int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex(
engine.data.sprites.length,
);
Sprite weaponSprite = engine.data.sprites[spriteIndex];
int weaponWidth = (width * 0.5).toInt();
int weaponHeight = (viewHeight * 0.8).toInt();
int startX = (width ~/ 2) - (weaponWidth ~/ 2);
int startY =
viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4);
for (int dy = 0; dy < weaponHeight; dy++) {
for (int dx = 0; dx < weaponWidth; dx++) {
int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63);
int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63);
int colorByte = weaponSprite.pixels[texX * 64 + texY];
if (colorByte != 255) {
int drawX = startX + dx;
int drawY = startY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) {
_buffer.pixels[drawY * width + drawX] =
ColorPalette.vga32Bit[colorByte];
}
}
}
}
}
@override
void drawHud(WolfEngine engine) {
int statusBarIndex = engine.data.vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
// 1. Draw Background
_blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160);
// 2. Draw Stats (100% mathematically accurate right-aligned coordinates)
_drawNumber(1, 32, 176, engine.data.vgaImages); // Floor
_drawNumber(engine.player.score, 96, 176, engine.data.vgaImages); // Score
_drawNumber(3, 120, 176, engine.data.vgaImages); // Lives
_drawNumber(
engine.player.health,
192,
176,
engine.data.vgaImages,
); // Health
_drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages); // Ammo
// 3. Draw BJ's Face & Current Weapon
_drawFace(engine);
_drawWeaponIcon(engine);
}
@override
FrameBuffer finalizeFrame() {
// If the player took damage, overlay a red tint across the 3D view
if (_engine.player.damageFlash > 0) {
_applyDamageFlash();
}
return _buffer; // Return the fully painted pixel array
}
// ===========================================================================
// PRIVATE HELPER METHODS
// ===========================================================================
/// Maps the planar VGA image data directly to 32-bit pixels.
/// (Assuming a 1:1 scale, which is standard for the 320x200 software renderer).
void _blitVgaImage(VgaImage image, int startX, int startY) {
int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height;
for (int dy = 0; dy < image.height; dy++) {
for (int dx = 0; dx < image.width; dx++) {
int drawX = startX + dx;
int drawY = startY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
int srcX = dx.clamp(0, image.width - 1);
int srcY = dy.clamp(0, image.height - 1);
int plane = srcX % 4;
int sx = srcX ~/ 4;
int index = (plane * planeSize) + (srcY * planeWidth) + sx;
int colorByte = image.pixels[index];
if (colorByte != 255) {
_buffer.pixels[drawY * width + drawX] =
ColorPalette.vga32Bit[colorByte];
}
}
}
}
}
void _drawNumber(
int value,
int rightAlignX,
int startY,
List<VgaImage> vgaImages,
) {
const int zeroIndex = 96;
String numStr = value.toString();
int currentX = rightAlignX - (numStr.length * 8);
for (int i = 0; i < numStr.length; i++) {
int digit = int.parse(numStr[i]);
if (zeroIndex + digit < vgaImages.length) {
_blitVgaImage(vgaImages[zeroIndex + digit], currentX, startY);
}
currentX += 8;
}
}
void _drawFace(WolfEngine engine) {
int health = engine.player.health;
int faceIndex;
if (health <= 0) {
faceIndex = 127; // Dead face
} else {
int healthTier = ((100 - health) ~/ 16).clamp(0, 6);
faceIndex = 106 + (healthTier * 3);
}
if (faceIndex < engine.data.vgaImages.length) {
_blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164);
}
}
void _drawWeaponIcon(WolfEngine engine) {
int weaponIndex = 89; // Default to Pistol
if (engine.player.hasChainGun) {
weaponIndex = 91;
} else if (engine.player.hasMachineGun) {
weaponIndex = 90;
}
if (weaponIndex < engine.data.vgaImages.length) {
_blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164);
}
}
/// Tints the top 80% of the screen red based on player.damageFlash intensity
void _applyDamageFlash() {
// Grab the intensity (0.0 to 1.0)
double intensity = _engine.player.damageFlash;
// Calculate how much to boost red and drop green/blue
int redBoost = (150 * intensity).toInt();
double colorDrop = 1.0 - (0.5 * intensity);
for (int y = 0; y < viewHeight; y++) {
for (int x = 0; x < width; x++) {
int index = y * width + x;
int color = _buffer.pixels[index];
int r = color & 0xFF;
int g = (color >> 8) & 0xFF;
int b = (color >> 16) & 0xFF;
r = (r + redBoost).clamp(0, 255);
g = (g * colorDrop).toInt();
b = (b * colorDrop).toInt();
_buffer.pixels[index] = (0xFF000000) | (b << 16) | (g << 8) | r;
}
}
}
}

View File

@@ -0,0 +1,356 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
class WolfEngine {
WolfEngine({
required this.data,
required this.difficulty,
required this.startingEpisode,
required this.onGameWon,
required this.audio,
required this.input,
}) : doorManager = DoorManager(
onPlaySound: (sfxId) => audio.playSoundEffect(sfxId),
);
int _timeAliveMs = 0;
final WolfensteinData data;
final Difficulty difficulty;
final int startingEpisode;
final EngineAudio audio;
// Standard Dart function instead of Flutter's VoidCallback
final void Function() onGameWon;
// Managers
final DoorManager doorManager;
final Wolf3dInput input;
final PushwallManager pushwallManager = PushwallManager();
// State
late Player player;
late SpriteMap currentLevel;
late WolfLevel activeLevel;
List<Entity> entities = [];
int _currentEpisodeIndex = 0;
int _currentLevelIndex = 0;
int? _returnLevelIndex;
bool isInitialized = false;
void init() {
_currentEpisodeIndex = startingEpisode;
_currentLevelIndex = 0;
_loadLevel();
isInitialized = true;
}
// Expect standard Dart Duration. The host app is responsible for the loop.
void tick(Duration elapsed) {
if (!isInitialized) return;
_timeAliveMs += elapsed.inMilliseconds;
input.update();
final currentInput = input.currentInput;
final inputResult = _processInputs(elapsed, currentInput);
doorManager.update(elapsed);
pushwallManager.update(elapsed, currentLevel);
player.tick(elapsed);
player.angle += inputResult.dAngle;
if (player.angle < 0) player.angle += 2 * math.pi;
if (player.angle >= 2 * math.pi) player.angle -= 2 * math.pi;
final Coordinate2D validatedPos = _calculateValidatedPosition(
player.position,
inputResult.movement,
);
player.x = validatedPos.x;
player.y = validatedPos.y;
_updateEntities(elapsed);
player.updateWeapon(
currentTime: _timeAliveMs,
entities: entities,
isWalkable: isWalkable,
);
}
void _loadLevel() {
entities.clear();
final episode = data.episodes[_currentEpisodeIndex];
activeLevel = episode.levels[_currentLevelIndex];
currentLevel = List.generate(64, (y) => List.from(activeLevel.wallGrid[y]));
final SpriteMap objectLevel = activeLevel.objectGrid;
doorManager.initDoors(currentLevel);
pushwallManager.initPushwalls(currentLevel, objectLevel);
audio.playLevelMusic(activeLevel);
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
int objId = objectLevel[y][x];
if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) {
double spawnAngle = 0.0;
if (objId == MapObject.playerNorth) {
spawnAngle = 3 * math.pi / 2;
} else if (objId == MapObject.playerEast) {
spawnAngle = 0.0;
} else if (objId == MapObject.playerSouth) {
spawnAngle = math.pi / 2;
} else if (objId == MapObject.playerWest) {
spawnAngle = math.pi;
}
player = Player(x: x + 0.5, y: y + 0.5, angle: spawnAngle);
} else {
Entity? newEntity = EntityRegistry.spawn(
objId,
x + 0.5,
y + 0.5,
difficulty,
data.sprites.length,
isSharewareMode: data.version == GameVersion.shareware,
);
if (newEntity != null) entities.add(newEntity);
}
}
}
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
int id = currentLevel[y][x];
if (!((id >= 1 && id <= 63) || (id >= 90 && id <= 101))) {
currentLevel[y][x] = 0;
}
}
}
_bumpPlayerIfStuck();
print("Loaded Floor: ${_currentLevelIndex + 1} - ${activeLevel.name}");
}
void _onLevelCompleted({bool isSecretExit = false}) {
audio.stopMusic();
final currentEpisode = data.episodes[_currentEpisodeIndex];
if (isSecretExit) {
_returnLevelIndex = _currentLevelIndex + 1;
_currentLevelIndex = 9;
} else {
if (_currentLevelIndex == 9 && _returnLevelIndex != null) {
_currentLevelIndex = _returnLevelIndex!;
_returnLevelIndex = null;
} else {
_currentLevelIndex++;
}
}
if (_currentLevelIndex >= currentEpisode.levels.length ||
_currentLevelIndex > 9) {
print("Episode Completed! You win!");
onGameWon();
} else {
_loadLevel();
}
}
({Coordinate2D movement, double dAngle}) _processInputs(
Duration elapsed,
EngineInput input,
) {
const double moveSpeed = 0.14;
const double turnSpeed = 0.10;
Coordinate2D movement = const Coordinate2D(0, 0);
double dAngle = 0.0;
// Read directly from the passed-in EngineInput object
if (input.requestedWeapon != null) {
player.requestWeaponSwitch(input.requestedWeapon!);
}
if (input.isFiring) {
player.fire(_timeAliveMs);
} else {
player.releaseTrigger();
}
if (input.isTurningLeft) dAngle -= turnSpeed;
if (input.isTurningRight) dAngle += turnSpeed;
Coordinate2D forwardVec = Coordinate2D(
math.cos(player.angle),
math.sin(player.angle),
);
if (input.isMovingForward) movement += forwardVec * moveSpeed;
if (input.isMovingBackward) movement -= forwardVec * moveSpeed;
if (input.isInteracting) {
int targetX = (player.x + math.cos(player.angle)).toInt();
int targetY = (player.y + math.sin(player.angle)).toInt();
if (targetX >= 0 && targetX < 64 && targetY >= 0 && targetY < 64) {
int wallId = currentLevel[targetY][targetX];
if (wallId == MapObject.normalElevatorSwitch) {
_onLevelCompleted(isSecretExit: false);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0);
} else if (wallId == MapObject.secretElevatorSwitch) {
_onLevelCompleted(isSecretExit: true);
return (movement: const Coordinate2D(0, 0), dAngle: 0.0);
}
int objId = activeLevel.objectGrid[targetY][targetX];
if (objId == MapObject.normalExitTrigger) {
_onLevelCompleted(isSecretExit: false);
return (movement: movement, dAngle: dAngle);
} else if (objId == MapObject.secretExitTrigger) {
_onLevelCompleted(isSecretExit: true);
return (movement: movement, dAngle: dAngle);
}
}
doorManager.handleInteraction(player.x, player.y, player.angle);
pushwallManager.handleInteraction(
player.x,
player.y,
player.angle,
currentLevel,
);
}
return (movement: movement, dAngle: dAngle);
}
Coordinate2D _calculateValidatedPosition(
Coordinate2D currentPos,
Coordinate2D movement,
) {
const double margin = 0.3;
double newX = currentPos.x;
double newY = currentPos.y;
Coordinate2D target = currentPos + movement;
if (movement.x != 0) {
int checkX = (movement.x > 0)
? (target.x + margin).toInt()
: (target.x - margin).toInt();
if (isWalkable(checkX, currentPos.y.toInt())) newX = target.x;
}
if (movement.y != 0) {
int checkY = (movement.y > 0)
? (target.y + margin).toInt()
: (target.y - margin).toInt();
if (isWalkable(newX.toInt(), checkY)) newY = target.y;
}
return Coordinate2D(newX, newY);
}
void _updateEntities(Duration elapsed) {
List<Entity> itemsToRemove = [];
List<Entity> itemsToAdd = [];
for (Entity entity in entities) {
if (entity is Enemy) {
final intent = entity.update(
elapsedMs: _timeAliveMs,
playerPosition: player.position,
isWalkable: isWalkable,
tryOpenDoor: doorManager.tryOpenDoor,
onDamagePlayer: (int damage) {
player.takeDamage(damage);
},
);
entity.angle = intent.newAngle;
entity.x += intent.movement.x;
entity.y += intent.movement.y;
if (entity.state == EntityState.dead &&
entity.isDying &&
!entity.hasDroppedItem) {
entity.hasDroppedItem = true;
Entity? droppedAmmo = EntityRegistry.spawn(
MapObject.ammoClip,
entity.x,
entity.y,
difficulty,
data.sprites.length,
);
if (droppedAmmo != null) itemsToAdd.add(droppedAmmo);
}
} else if (entity is Collectible) {
if (player.position.distanceTo(entity.position) < 0.5) {
if (player.tryPickup(entity)) {
itemsToRemove.add(entity);
}
}
}
}
if (itemsToRemove.isNotEmpty) {
entities.removeWhere((e) => itemsToRemove.contains(e));
}
if (itemsToAdd.isNotEmpty) entities.addAll(itemsToAdd);
}
bool isWalkable(int x, int y) {
if (currentLevel[y][x] == 0) return true;
if (currentLevel[y][x] >= 90) return doorManager.isDoorOpenEnough(x, y);
return false;
}
void _bumpPlayerIfStuck() {
int pX = player.x.toInt();
int pY = player.y.toInt();
if (pY < 0 ||
pY >= currentLevel.length ||
pX < 0 ||
pX >= currentLevel[0].length ||
currentLevel[pY][pX] > 0) {
double shortestDist = double.infinity;
Coordinate2D nearestSafeSpot = Coordinate2D(1.5, 1.5);
for (int y = 0; y < currentLevel.length; y++) {
for (int x = 0; x < currentLevel[y].length; x++) {
if (currentLevel[y][x] == 0) {
Coordinate2D safeSpot = Coordinate2D(x + 0.5, y + 0.5);
double dist = safeSpot.distanceTo(player.position);
if (dist < shortestDist) {
shortestDist = dist;
nearestSafeSpot = safeSpot;
}
}
}
}
player.x = nearestSafeSpot.x;
player.y = nearestSafeSpot.y;
}
}
}