Fixes pushwalls and a bunch of ASCII/sixel rasterizer issues

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-18 02:02:39 +01:00
parent d7692ea325
commit 7ee1d0704d
23 changed files with 3781 additions and 143 deletions

View File

@@ -1,6 +1,7 @@
import 'dart:math' as math;
import 'package:arcane_helper_utils/arcane_helper_utils.dart';
import 'package:wolf_3d_dart/src/rasterizer/cli_rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';

View File

@@ -1,6 +1,7 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:wolf_3d_dart/src/rasterizer/cli_rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';

View File

@@ -1,5 +1,6 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';

View File

@@ -16,11 +16,12 @@ class WolfEngine {
required this.difficulty,
required this.startingEpisode,
required this.onGameWon,
required this.audio,
required this.input,
required this.frameBuffer,
}) : doorManager = DoorManager(
onPlaySound: (sfxId) => audio.playSoundEffect(sfxId),
EngineAudio? audio,
}) : audio = audio ?? CliSilentAudio(),
doorManager = DoorManager(
onPlaySound: (sfxId) => audio?.playSoundEffect(sfxId),
);
/// Total milliseconds elapsed since the engine was initialized.
@@ -36,7 +37,7 @@ class WolfEngine {
final int startingEpisode;
/// Handles music and sound effect playback.
final EngineAudio audio;
late final EngineAudio audio;
/// Callback triggered when the final level of an episode is completed.
final void Function() onGameWon;

View File

@@ -1,5 +1,5 @@
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
import 'package:wolf_3d_dart/src/input/wolf_3d_input.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
class CliInput extends Wolf3dInput {
// Pending buffer for asynchronous stdin events

View File

@@ -0,0 +1,838 @@
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';
import 'cli_rasterizer.dart';
class AsciiTheme {
final String name;
/// The character ramp, ordered from most dense (index 0) to least dense (last index).
final String ramp;
const AsciiTheme(this.name, 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('Blocks', "█▓▒░ ");
static const AsciiTheme classic = AsciiTheme('Classic', "@%#*+=-:. ");
static const List<AsciiTheme> values = [blocks, classic];
static AsciiTheme nextOf(AsciiTheme current) {
final int currentIndex = values.indexOf(current);
final int nextIndex = currentIndex == -1
? 0
: (currentIndex + 1) % values.length;
return values[nextIndex];
}
}
class ColoredChar {
final String char;
final int rawColor; // Stores the AABBGGRR integer from the palette
final int? rawBackgroundColor;
ColoredChar(this.char, this.rawColor, [this.rawBackgroundColor]);
// 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 CliRasterizer<dynamic> {
static const double _targetAspectRatio = 4 / 3;
static const int _terminalBackdropArgb = 0xFF009688;
static const int _minimumTerminalColumns = 80;
static const int _minimumTerminalRows = 24;
static const int _simpleHudMinWidth = 84;
static const int _simpleHudMinRows = 7;
AsciiRasterizer({
this.activeTheme = AsciiThemes.blocks,
this.isTerminal = false,
this.aspectMultiplier = 1.0,
this.verticalStretch = 1.0,
});
AsciiTheme activeTheme = AsciiThemes.blocks;
final bool isTerminal;
late List<List<ColoredChar>> _screen;
late List<List<int>> _scenePixels;
late WolfEngine _engine;
@override
final double aspectMultiplier;
@override
final double verticalStretch;
@override
int get projectionWidth => isTerminal
? math.max(
1,
math.min(width, (_terminalPixelHeight * _targetAspectRatio).floor()),
)
: width;
@override
int get projectionOffsetX => isTerminal ? (width - projectionWidth) ~/ 2 : 0;
@override
int get projectionViewHeight => isTerminal ? viewHeight * 2 : viewHeight;
@override
bool isTerminalSizeSupported(int columns, int rows) {
if (!isTerminal) {
return true;
}
return columns >= _minimumTerminalColumns && rows >= _minimumTerminalRows;
}
@override
String get terminalSizeRequirement =>
'ASCII renderer requires a minimum resolution of '
'${_minimumTerminalColumns}x$_minimumTerminalRows.';
int get _terminalPixelHeight => isTerminal ? height * 2 : height;
int get _viewportRightX => projectionOffsetX + projectionWidth;
int get _terminalBackdropColor => _argbToRawColor(_terminalBackdropArgb);
// Intercept the base render call to initialize our text grid
@override
dynamic render(WolfEngine engine) {
_engine = engine;
_screen = List.generate(
engine.frameBuffer.height,
(_) => List.filled(
engine.frameBuffer.width,
ColoredChar(' ', ColorPalette.vga32Bit[0]),
),
);
return super.render(engine);
}
@override
void prepareFrame(WolfEngine engine) {
// Just grab the raw ints!
final int ceilingColor = ColorPalette.vga32Bit[25];
final int floorColor = ColorPalette.vga32Bit[29];
final int backdropColor = isTerminal
? _terminalBackdropColor
: ColorPalette.vga32Bit[0];
_scenePixels = List.generate(
_terminalPixelHeight,
(_) => List.filled(width, backdropColor),
);
for (int y = 0; y < projectionViewHeight; y++) {
final int color = y < projectionViewHeight / 2
? ceilingColor
: floorColor;
for (int x = projectionOffsetX; x < _viewportRightX; x++) {
_scenePixels[y][x] = color;
}
}
if (!isTerminal) {
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);
for (int y = drawStart; y < drawEnd; y++) {
double relativeY =
(y - (-columnHeight ~/ 2 + projectionViewHeight ~/ 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);
}
if (isTerminal) {
_scenePixels[y][x] = _scaleColor(pixelColor, brightness);
} else {
String wallChar = activeTheme.getByBrightness(brightness);
_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(projectionViewHeight, 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;
if (isTerminal) {
_scenePixels[y][stripeX] = shadedColor;
} else {
// 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 = (projectionWidth * 0.5).toInt();
int weaponHeight = ((projectionViewHeight * 0.8)).toInt();
int startX =
projectionOffsetX + (projectionWidth ~/ 2) - (weaponWidth ~/ 2);
int startY =
projectionViewHeight -
weaponHeight +
(engine.player.weaponAnimOffset * (isTerminal ? 2 : 1) ~/ 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 sceneX = startX + dx;
int drawY = startY + dy;
if (sceneX >= projectionOffsetX &&
sceneX < _viewportRightX &&
drawY >= 0) {
if (isTerminal && drawY < projectionViewHeight) {
_scenePixels[drawY][sceneX] = ColorPalette.vga32Bit[colorByte];
} else if (!isTerminal && drawY < viewHeight) {
_screen[drawY][sceneX] = 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, [
int? backgroundColor,
]) {
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, backgroundColor);
}
}
}
@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.
int hudWidth = isTerminal ? projectionWidth : width;
if (hudWidth >= 160 && height >= 50) {
_drawFullVgaHud(engine);
} else {
_drawSimpleHud(engine);
}
}
void _drawSimpleHud(WolfEngine engine) {
final int hudWidth = isTerminal ? projectionWidth : width;
final int hudRows = height - viewHeight;
if (hudWidth < _simpleHudMinWidth || hudRows < _simpleHudMinRows) {
_drawMinimalHud(engine);
return;
}
// 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];
// Compact full simple HUD layout.
const int floorW = 10;
const int scoreW = 14;
const int livesW = 9;
const int faceW = 10;
const int healthW = 12;
const int ammoW = 10;
const int weaponW = 13;
const int gap = 1;
const int hudContentWidth =
floorW +
scoreW +
livesW +
faceW +
healthW +
ammoW +
weaponW +
(gap * 6);
final int offsetX =
projectionOffsetX +
((projectionWidth - hudContentWidth) ~/ 2).clamp(0, projectionWidth);
final int baseY = viewHeight + 1;
// 3. Clear HUD Base
if (isTerminal) {
_fillTerminalRect(
projectionOffsetX,
viewHeight * 2,
projectionWidth,
hudRows * 2,
vgaStatusBarBlue,
);
_fillTerminalRect(
projectionOffsetX,
viewHeight * 2,
projectionWidth,
1,
white,
);
} else {
_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) {
if (isTerminal) {
_fillTerminalRect(startX, startY * 2, w, h * 2, vgaPanelDark);
_fillTerminalRect(startX, startY * 2, w, 1, white);
_fillTerminalRect(startX, (startY + h) * 2 - 1, w, 1, white);
_fillTerminalRect(startX, startY * 2, 1, h * 2, white);
_fillTerminalRect(startX + w - 1, startY * 2, 1, h * 2, white);
} else {
_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 compact panels.
int cursorX = offsetX;
drawBorderedPanel(cursorX, baseY + 1, floorW, 4);
_writeString(cursorX + 2, baseY + 2, "FLR", white, vgaPanelDark);
String floorLabel = engine.activeLevel.name.split(' ').last;
if (floorLabel.length > 4) {
floorLabel = floorLabel.substring(floorLabel.length - 4);
}
_writeString(cursorX + 2, baseY + 3, floorLabel, white, vgaPanelDark);
cursorX += floorW + gap;
drawBorderedPanel(cursorX, baseY + 1, scoreW, 4);
_writeString(cursorX + 4, baseY + 2, "SCORE", white, vgaPanelDark);
_writeString(
cursorX + 4,
baseY + 3,
engine.player.score.toString().padLeft(6, '0'),
white,
vgaPanelDark,
);
cursorX += scoreW + gap;
drawBorderedPanel(cursorX, baseY + 1, livesW, 4);
_writeString(cursorX + 2, baseY + 2, "LIV", white, vgaPanelDark);
_writeString(cursorX + 3, baseY + 3, "3", white, vgaPanelDark);
cursorX += livesW + gap;
drawBorderedPanel(cursorX, baseY, faceW, 5);
String face = "ಠ⌣ಠ";
if (engine.player.health <= 0) {
face = "x⸑x";
} else if (engine.player.damageFlash > 0.1) {
face = "ಠoಠ";
} else if (engine.player.health <= 25) {
face = "ಥ_ಥ";
} else if (engine.player.health <= 60) {
face = "ಠ~ಠ";
}
_writeString(cursorX + 3, baseY + 2, face, yellow, vgaPanelDark);
cursorX += faceW + gap;
int healthColor = engine.player.health > 25 ? white : red;
drawBorderedPanel(cursorX, baseY + 1, healthW, 4);
_writeString(cursorX + 2, baseY + 2, "HEALTH", white, vgaPanelDark);
_writeString(
cursorX + 3,
baseY + 3,
"${engine.player.health}%",
healthColor,
vgaPanelDark,
);
cursorX += healthW + gap;
drawBorderedPanel(cursorX, baseY + 1, ammoW, 4);
_writeString(cursorX + 2, baseY + 2, "AMMO", white, vgaPanelDark);
_writeString(
cursorX + 2,
baseY + 3,
"${engine.player.ammo}",
white,
vgaPanelDark,
);
cursorX += ammoW + gap;
drawBorderedPanel(cursorX, baseY + 1, weaponW, 4);
String weapon = engine.player.currentWeapon.type.name.spacePascalCase!
.toUpperCase();
if (weapon.length > weaponW - 2) {
weapon = weapon.substring(0, weaponW - 2);
}
_writeString(cursorX + 1, baseY + 3, weapon, white, vgaPanelDark);
}
void _drawMinimalHud(WolfEngine engine) {
final int vgaStatusBarBlue = ColorPalette.vga32Bit[153];
final int white = ColorPalette.vga32Bit[15];
final int red = ColorPalette.vga32Bit[4];
final int hudRows = height - viewHeight;
if (isTerminal) {
_fillTerminalRect(
projectionOffsetX,
viewHeight * 2,
projectionWidth,
hudRows * 2,
vgaStatusBarBlue,
);
_fillTerminalRect(
projectionOffsetX,
viewHeight * 2,
projectionWidth,
1,
white,
);
} else {
_fillRect(0, viewHeight, width, hudRows, ' ', vgaStatusBarBlue);
_writeString(0, viewHeight, "" * width, white);
}
final int healthColor = engine.player.health > 25 ? white : red;
String weapon = engine.player.currentWeapon.type.name.spacePascalCase!
.toUpperCase();
if (weapon.length > 8) {
weapon = weapon.substring(0, 8);
}
final String hudText =
'H:${engine.player.health}% A:${engine.player.ammo} S:${engine.player.score} W:$weapon';
final int lineY = viewHeight + 1;
if (lineY >= height) return;
final int drawStartX = isTerminal ? projectionOffsetX : 0;
final int drawWidth = isTerminal ? projectionWidth : width;
final int maxTextLen = math.max(0, drawWidth - 2);
String clipped = hudText;
if (clipped.length > maxTextLen) {
clipped = clipped.substring(0, maxTextLen);
}
final int startX = drawStartX + ((drawWidth - clipped.length) ~/ 2);
_writeString(startX, lineY, clipped, healthColor, vgaStatusBarBlue);
}
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) {
if (isTerminal) {
_applyDamageFlashToScene();
} else {
_applyDamageFlash();
}
}
if (isTerminal) {
_composeTerminalScene();
return toAnsiString();
}
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;
int maxDrawHeight = isTerminal ? _terminalPixelHeight : height;
int maxDrawWidth = isTerminal ? _viewportRightX : width;
double scaleX = (isTerminal ? projectionWidth : width) / 320.0;
double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0;
int destStartX =
(isTerminal ? projectionOffsetX : 0) + (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 < maxDrawWidth &&
drawY >= 0 &&
drawY < maxDrawHeight) {
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) {
if (isTerminal) {
_scenePixels[drawY][drawX] = ColorPalette.vga32Bit[colorByte];
} else {
_screen[drawY][drawX] = ColoredChar(
activeTheme.solid,
ColorPalette.vga32Bit[colorByte],
);
}
}
}
}
}
}
void _fillTerminalRect(int startX, int startY, int w, int h, 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 < _terminalPixelHeight) {
_scenePixels[y][x] = color;
}
}
}
}
// --- DAMAGE FLASH ---
void _applyDamageFlash() {
for (int y = 0; y < viewHeight; y++) {
for (int x = 0; x < width; x++) {
ColoredChar cell = _screen[y][x];
_screen[y][x] = ColoredChar(
cell.char,
_applyDamageFlashToColor(cell.rawColor),
cell.rawBackgroundColor == null
? null
: _applyDamageFlashToColor(cell.rawBackgroundColor!),
);
}
}
}
void _applyDamageFlashToScene() {
for (int y = 0; y < _terminalPixelHeight; y++) {
for (int x = projectionOffsetX; x < _viewportRightX; x++) {
_scenePixels[y][x] = _applyDamageFlashToColor(_scenePixels[y][x]);
}
}
}
int _argbToRawColor(int argb) {
int r = (argb >> 16) & 0xFF;
int g = (argb >> 8) & 0xFF;
int b = argb & 0xFF;
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
int _applyDamageFlashToColor(int color) {
double intensity = _engine.player.damageFlash;
int redBoost = (150 * intensity).toInt();
double colorDrop = 1.0 - (0.5 * intensity);
int r = color & 0xFF;
int g = (color >> 8) & 0xFF;
int b = (color >> 16) & 0xFF;
r = (r + redBoost).clamp(0, 255);
g = (g * colorDrop).toInt().clamp(0, 255);
b = (b * colorDrop).toInt().clamp(0, 255);
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
int _scaleColor(int color, double brightness) {
int r = ((color & 0xFF) * brightness).toInt().clamp(0, 255);
int g = (((color >> 8) & 0xFF) * brightness).toInt().clamp(0, 255);
int b = (((color >> 16) & 0xFF) * brightness).toInt().clamp(0, 255);
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
void _composeTerminalScene() {
for (int y = 0; y < height; y++) {
int topY = y * 2;
int bottomY = math.min(topY + 1, _terminalPixelHeight - 1);
for (int x = 0; x < width; x++) {
int topColor = _scenePixels[topY][x];
int bottomColor = _scenePixels[bottomY][x];
ColoredChar overlay = _screen[y][x];
if (overlay.char != ' ') {
if (overlay.rawBackgroundColor == null) {
_screen[y][x] = ColoredChar(
overlay.char,
overlay.rawColor,
bottomColor,
);
}
continue;
}
_screen[y][x] = topColor == bottomColor
? ColoredChar('', topColor)
: ColoredChar('', topColor, bottomColor);
}
}
}
/// Converts the current frame to a single printable ANSI string
StringBuffer toAnsiString() {
StringBuffer buffer = StringBuffer();
int? lastForeground;
int? lastBackground;
for (int y = 0; y < _screen.length; y++) {
List<ColoredChar> row = _screen[y];
for (ColoredChar cell in row) {
if (cell.rawColor != lastForeground) {
buffer.write('\x1b[38;2;${cell.r};${cell.g};${cell.b}m');
lastForeground = cell.rawColor;
}
if (cell.rawBackgroundColor != lastBackground) {
if (cell.rawBackgroundColor == null) {
buffer.write('\x1b[49m');
} else {
int background = cell.rawBackgroundColor!;
int bgR = background & 0xFF;
int bgG = (background >> 8) & 0xFF;
int bgB = (background >> 16) & 0xFF;
buffer.write(
'\x1b[48;2;$bgR;$bgG;$bgB'
'm',
);
}
lastBackground = cell.rawBackgroundColor;
}
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;
}
}

View File

@@ -0,0 +1,38 @@
import 'package:wolf_3d_dart/src/engine/wolf_3d_engine_base.dart';
import 'rasterizer.dart';
/// Shared terminal orchestration for CLI rasterizers.
abstract class CliRasterizer<T> extends Rasterizer<T> {
/// Resolves the framebuffer dimensions required by this renderer.
///
/// The default uses the full terminal size.
({int width, int height}) terminalFrameBufferSize(int columns, int rows) {
return (width: columns, height: rows);
}
/// Applies terminal-size policy and updates the engine framebuffer.
///
/// Returns `false` when the terminal is too small for this renderer.
bool prepareTerminalFrame(
WolfEngine engine, {
required int columns,
required int rows,
}) {
if (!isTerminalSizeSupported(columns, rows)) {
return false;
}
final size = terminalFrameBufferSize(columns, rows);
engine.setFrameBuffer(size.width, size.height);
return true;
}
/// Builds the standard terminal size warning shown by the CLI host.
String buildTerminalSizeWarning({required int columns, required int rows}) {
return '\x1b[31m[ ERROR ] TERMINAL TOO SMALL\x1b[0m\n\n'
'$terminalSizeRequirement\n'
'Current size: \x1b[33m${columns}x$rows\x1b[0m\n\n'
'Please resize your window to resume the game...';
}
}

View File

@@ -114,6 +114,84 @@ abstract class Rasterizer<T> {
return (10.0 / (distance + 2.0)).clamp(0.0, 1.0);
}
({double distance, int side, int hitWallId, double wallX})?
_intersectActivePushwall(
Player player,
Coordinate2D rayDir,
Pushwall activePushwall,
) {
double minX = activePushwall.x.toDouble();
double maxX = activePushwall.x + 1.0;
double minY = activePushwall.y.toDouble();
double maxY = activePushwall.y + 1.0;
if (activePushwall.dirX != 0) {
final double delta = activePushwall.dirX * activePushwall.offset;
minX += delta;
maxX += delta;
}
if (activePushwall.dirY != 0) {
final double delta = activePushwall.dirY * activePushwall.offset;
minY += delta;
maxY += delta;
}
const double epsilon = 1e-9;
double tMinX = double.negativeInfinity;
double tMaxX = double.infinity;
if (rayDir.x.abs() < epsilon) {
if (player.x < minX || player.x > maxX) {
return null;
}
} else {
final double tx1 = (minX - player.x) / rayDir.x;
final double tx2 = (maxX - player.x) / rayDir.x;
tMinX = math.min(tx1, tx2);
tMaxX = math.max(tx1, tx2);
}
double tMinY = double.negativeInfinity;
double tMaxY = double.infinity;
if (rayDir.y.abs() < epsilon) {
if (player.y < minY || player.y > maxY) {
return null;
}
} else {
final double ty1 = (minY - player.y) / rayDir.y;
final double ty2 = (maxY - player.y) / rayDir.y;
tMinY = math.min(ty1, ty2);
tMaxY = math.max(ty1, ty2);
}
final double entryDistance = math.max(tMinX, tMinY);
final double exitDistance = math.min(tMaxX, tMaxY);
if (exitDistance < 0 || entryDistance > exitDistance) {
return null;
}
final double hitDistance = entryDistance >= 0
? entryDistance
: exitDistance;
if (hitDistance < 0) {
return null;
}
final int side = tMinX > tMinY ? 0 : 1;
final double wallCoord = side == 0
? player.y + hitDistance * rayDir.y
: player.x + hitDistance * rayDir.x;
return (
distance: hitDistance,
side: side,
hitWallId: activePushwall.mapId,
wallX: wallCoord - wallCoord.floor(),
);
}
// ===========================================================================
// CORE ENGINE MATH (Shared across all renderers)
// ===========================================================================
@@ -139,6 +217,9 @@ abstract class Rasterizer<T> {
for (int x = 0; x < sceneWidth; x++) {
double cameraX = 2 * x / sceneWidth - 1.0;
Coordinate2D rayDir = dir + (plane * cameraX);
final pushwallHit = activePushwall == null
? null
: _intersectActivePushwall(player, rayDir, activePushwall);
int mapX = player.x.toInt();
int mapY = player.y.toInt();
@@ -150,6 +231,7 @@ abstract class Rasterizer<T> {
int stepX, stepY, side = 0, hitWallId = 0;
bool hit = false, hitOutOfBounds = false, customDistCalculated = false;
double textureOffset = 0.0;
double? wallXOverride;
Set<String> ignoredDoors = {};
if (rayDir.x < 0) {
@@ -186,6 +268,12 @@ abstract class Rasterizer<T> {
hit = true;
hitOutOfBounds = true;
} else if (map[mapY][mapX] > 0) {
if (activePushwall != null &&
mapX == activePushwall.x &&
mapY == activePushwall.y) {
continue;
}
String mapKey = '$mapX,$mapY';
// DOOR LOGIC
@@ -207,63 +295,6 @@ abstract class Rasterizer<T> {
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];
@@ -271,22 +302,47 @@ abstract class Rasterizer<T> {
}
}
if (hitOutOfBounds) continue;
if (hitOutOfBounds || !hit) {
if (pushwallHit == null) {
continue;
}
customDistCalculated = true;
perpWallDist = pushwallHit.distance;
side = pushwallHit.side;
hitWallId = pushwallHit.hitWallId;
wallXOverride = pushwallHit.wallX;
textureOffset = 0.0;
hit = true;
hitOutOfBounds = false;
}
if (!customDistCalculated) {
perpWallDist = (side == 0)
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
}
if (pushwallHit != null && pushwallHit.distance < perpWallDist) {
customDistCalculated = true;
perpWallDist = pushwallHit.distance;
side = pushwallHit.side;
hitWallId = pushwallHit.hitWallId;
wallXOverride = pushwallHit.wallX;
textureOffset = 0.0;
}
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;
double wallX =
wallXOverride ??
((side == 0)
? player.y + perpWallDist * rayDir.y
: player.x + perpWallDist * rayDir.x);
wallX -= wallX.floor();
int texNum;

View File

@@ -1,3 +1,4 @@
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Shared terminal orchestration for CLI rasterizers.

View File

@@ -0,0 +1,423 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:wolf_3d_dart/src/rasterizer/cli_rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class SixelRasterizer extends CliRasterizer<String> {
static const double _targetAspectRatio = 4 / 3;
static const int _defaultLineHeightPx = 18;
static const double _defaultCellWidthToHeight = 0.55;
static const int _minimumTerminalColumns = 117;
static const int _minimumTerminalRows = 34;
static const int _maxRenderWidth = 320;
static const int _maxRenderHeight = 240;
static const String _terminalTealBackground = '\x1b[48;2;0;150;136m';
late Uint8List _screen;
late WolfEngine _engine;
int _offsetColumns = 0;
int _offsetRows = 0;
int _outputWidth = 1;
int _outputHeight = 1;
bool _needsBackgroundClear = true;
FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) {
final int previousOffsetColumns = _offsetColumns;
final int previousOffsetRows = _offsetRows;
final int previousOutputWidth = _outputWidth;
final int previousOutputHeight = _outputHeight;
final double fitScale = math.min(
terminalBuffer.width / _minimumTerminalColumns,
terminalBuffer.height / _minimumTerminalRows,
);
final int targetColumns = math.max(
1,
(_minimumTerminalColumns * fitScale).floor(),
);
final int targetRows = math.max(
1,
(_minimumTerminalRows * fitScale).floor(),
);
_offsetColumns = math.max(0, (terminalBuffer.width - targetColumns) ~/ 2);
_offsetRows = math.max(0, (terminalBuffer.height - targetRows) ~/ 2);
final int boundsPixelWidth = math.max(
1,
(targetColumns * _defaultLineHeightPx * _defaultCellWidthToHeight)
.floor(),
);
final int boundsPixelHeight = math.max(
1,
targetRows * _defaultLineHeightPx,
);
final double boundsAspect = boundsPixelWidth / boundsPixelHeight;
if (boundsAspect > _targetAspectRatio) {
_outputHeight = boundsPixelHeight;
_outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor());
} else {
_outputWidth = boundsPixelWidth;
_outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor());
}
if (_offsetColumns != previousOffsetColumns ||
_offsetRows != previousOffsetRows ||
_outputWidth != previousOutputWidth ||
_outputHeight != previousOutputHeight) {
_needsBackgroundClear = true;
}
final double renderScale = math.min(
1.0,
math.min(
_maxRenderWidth / _outputWidth,
_maxRenderHeight / _outputHeight,
),
);
final int renderWidth = math.max(1, (_outputWidth * renderScale).floor());
final int renderHeight = math.max(1, (_outputHeight * renderScale).floor());
return FrameBuffer(renderWidth, renderHeight);
}
@override
String render(WolfEngine engine) {
_engine = engine;
final FrameBuffer originalBuffer = engine.frameBuffer;
final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);
// We only need 8-bit indices for the 256 VGA colors
_screen = Uint8List(scaledBuffer.width * scaledBuffer.height);
engine.frameBuffer = scaledBuffer;
try {
return super.render(engine);
} finally {
engine.frameBuffer = originalBuffer;
}
}
@override
void prepareFrame(WolfEngine engine) {
// Top half is ceiling color index (25), bottom half is floor color index (29)
for (int y = 0; y < viewHeight; y++) {
int colorIndex = (y < viewHeight / 2) ? 25 : 29;
for (int x = 0; x < width; x++) {
_screen[y * width + x] = colorIndex;
}
}
}
@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++) {
double relativeY =
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
int colorByte = texture.pixels[texX * 64 + texY];
// Note: Directional shading is omitted here to preserve strict VGA palette indices.
// Sixel uses a fixed 256-color palette, so real-time shading requires a lookup table.
_screen[y * width + x] = colorByte;
}
}
@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
if (colorByte != 255) {
_screen[y * width + stripeX] = 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) {
_screen[drawY * width + drawX] = colorByte;
}
}
}
}
}
@override
void drawHud(WolfEngine engine) {
int statusBarIndex = engine.data.vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
_blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160);
_drawNumber(1, 32, 176, engine.data.vgaImages);
_drawNumber(engine.player.score, 96, 176, engine.data.vgaImages);
_drawNumber(3, 120, 176, engine.data.vgaImages);
_drawNumber(engine.player.health, 192, 176, engine.data.vgaImages);
_drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages);
_drawFace(engine);
_drawWeaponIcon(engine);
}
@override
String finalizeFrame() {
final String clearPrefix = _needsBackgroundClear
? '$_terminalTealBackground\x1b[2J\x1b[0m'
: '';
_needsBackgroundClear = false;
return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}';
}
// ===========================================================================
// SIXEL ENCODER
// ===========================================================================
/// Converts the 8-bit index buffer into a standard Sixel sequence
String toSixelString() {
StringBuffer sb = StringBuffer();
// Start Sixel sequence (q = Sixel format)
sb.write('\x1bPq');
// 1. Define the Palette (and apply damage flash directly to the palette!)
double damageIntensity = _engine.player.damageFlash;
int redBoost = (150 * damageIntensity).toInt();
double colorDrop = 1.0 - (0.5 * damageIntensity);
for (int i = 0; i < 256; i++) {
int color = ColorPalette.vga32Bit[i];
int r = color & 0xFF;
int g = (color >> 8) & 0xFF;
int b = (color >> 16) & 0xFF;
if (damageIntensity > 0) {
r = (r + redBoost).clamp(0, 255);
g = (g * colorDrop).toInt().clamp(0, 255);
b = (b * colorDrop).toInt().clamp(0, 255);
}
// Sixel RGB ranges from 0 to 100
int sixelR = (r * 100) ~/ 255;
int sixelG = (g * 100) ~/ 255;
int sixelB = (b * 100) ~/ 255;
sb.write('#$i;2;$sixelR;$sixelG;$sixelB');
}
// 2. Encode scaled image in 6-pixel vertical bands.
for (int band = 0; band < _outputHeight; band += 6) {
Map<int, Uint8List> colorMap = {};
// Map out which pixels use which color in this 6px high band
for (int x = 0; x < _outputWidth; x++) {
for (int yOffset = 0; yOffset < 6; yOffset++) {
int y = band + yOffset;
if (y >= _outputHeight) break;
int colorIdx = _sampleScaledPixel(x, y);
if (!colorMap.containsKey(colorIdx)) {
colorMap[colorIdx] = Uint8List(_outputWidth);
}
// Set the bit corresponding to the vertical position (0-5)
colorMap[colorIdx]![x] |= (1 << yOffset);
}
}
// Write the encoded Sixel characters for each color present in the band
bool firstColor = true;
for (var entry in colorMap.entries) {
if (!firstColor) {
// Carriage return to overlay colors on the same band
sb.write('\$');
}
firstColor = false;
// Select color index
sb.write('#${entry.key}');
Uint8List cols = entry.value;
int currentVal = -1;
int runLength = 0;
// Run-Length Encoding (RLE) loop
for (int x = 0; x < _outputWidth; x++) {
int val = cols[x];
if (val == currentVal) {
runLength++;
} else {
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
currentVal = val;
runLength = 1;
}
}
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
}
if (band + 6 < _outputHeight) {
sb.write('-');
}
}
// End Sixel sequence
sb.write('\x1b\\');
return sb.toString();
}
int _sampleScaledPixel(int outX, int outY) {
final int srcX = ((((outX + 0.5) * width) / _outputWidth) - 0.5)
.round()
.clamp(
0,
width - 1,
);
final int srcY = ((((outY + 0.5) * height) / _outputHeight) - 0.5)
.round()
.clamp(
0,
height - 1,
);
return _screen[srcY * width + srcX];
}
void _writeSixelRle(StringBuffer sb, int value, int runLength) {
String char = String.fromCharCode(value + 63);
// Sixel RLE format: !<count><char> (only worth it if count > 3)
if (runLength > 3) {
sb.write('!$runLength$char');
} else {
sb.write(char * runLength);
}
}
// ===========================================================================
// PRIVATE HUD HELPERS (Adapted for 8-bit index buffer)
// ===========================================================================
void _blitVgaImage(VgaImage image, int startX, int startY) {
int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height;
final double scaleX = width / 320.0;
final double scaleY = height / 200.0;
final int destStartX = (startX * scaleX).toInt();
final int destStartY = (startY * scaleY).toInt();
final int destWidth = math.max(1, (image.width * scaleX).toInt());
final int destHeight = math.max(1, (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 * width + drawX] = 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 = (health <= 0)
? 127
: 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3);
if (faceIndex < engine.data.vgaImages.length) {
_blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164);
}
}
void _drawWeaponIcon(WolfEngine engine) {
int weaponIndex = 89;
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);
}
}
}

View File

@@ -0,0 +1,265 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
late FrameBuffer _buffer;
late WolfEngine _engine;
// Intercept the base render call to store our references
@override
FrameBuffer render(WolfEngine engine) {
_engine = engine;
_buffer = engine.frameBuffer;
return super.render(engine);
}
@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,424 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'cli_rasterizer.dart';
class SixelRasterizer extends CliRasterizer<String> {
static const double _targetAspectRatio = 4 / 3;
static const int _defaultLineHeightPx = 18;
static const double _defaultCellWidthToHeight = 0.55;
static const int _minimumTerminalColumns = 117;
static const int _minimumTerminalRows = 34;
static const int _maxRenderWidth = 320;
static const int _maxRenderHeight = 240;
static const String _terminalTealBackground = '\x1b[48;2;0;150;136m';
late Uint8List _screen;
late WolfEngine _engine;
int _offsetColumns = 0;
int _offsetRows = 0;
int _outputWidth = 1;
int _outputHeight = 1;
bool _needsBackgroundClear = true;
FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) {
final int previousOffsetColumns = _offsetColumns;
final int previousOffsetRows = _offsetRows;
final int previousOutputWidth = _outputWidth;
final int previousOutputHeight = _outputHeight;
final double fitScale = math.min(
terminalBuffer.width / _minimumTerminalColumns,
terminalBuffer.height / _minimumTerminalRows,
);
final int targetColumns = math.max(
1,
(_minimumTerminalColumns * fitScale).floor(),
);
final int targetRows = math.max(
1,
(_minimumTerminalRows * fitScale).floor(),
);
_offsetColumns = math.max(0, (terminalBuffer.width - targetColumns) ~/ 2);
_offsetRows = math.max(0, (terminalBuffer.height - targetRows) ~/ 2);
final int boundsPixelWidth = math.max(
1,
(targetColumns * _defaultLineHeightPx * _defaultCellWidthToHeight)
.floor(),
);
final int boundsPixelHeight = math.max(
1,
targetRows * _defaultLineHeightPx,
);
final double boundsAspect = boundsPixelWidth / boundsPixelHeight;
if (boundsAspect > _targetAspectRatio) {
_outputHeight = boundsPixelHeight;
_outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor());
} else {
_outputWidth = boundsPixelWidth;
_outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor());
}
if (_offsetColumns != previousOffsetColumns ||
_offsetRows != previousOffsetRows ||
_outputWidth != previousOutputWidth ||
_outputHeight != previousOutputHeight) {
_needsBackgroundClear = true;
}
final double renderScale = math.min(
1.0,
math.min(
_maxRenderWidth / _outputWidth,
_maxRenderHeight / _outputHeight,
),
);
final int renderWidth = math.max(1, (_outputWidth * renderScale).floor());
final int renderHeight = math.max(1, (_outputHeight * renderScale).floor());
return FrameBuffer(renderWidth, renderHeight);
}
@override
String render(WolfEngine engine) {
_engine = engine;
final FrameBuffer originalBuffer = engine.frameBuffer;
final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);
// We only need 8-bit indices for the 256 VGA colors
_screen = Uint8List(scaledBuffer.width * scaledBuffer.height);
engine.frameBuffer = scaledBuffer;
try {
return super.render(engine);
} finally {
engine.frameBuffer = originalBuffer;
}
}
@override
void prepareFrame(WolfEngine engine) {
// Top half is ceiling color index (25), bottom half is floor color index (29)
for (int y = 0; y < viewHeight; y++) {
int colorIndex = (y < viewHeight / 2) ? 25 : 29;
for (int x = 0; x < width; x++) {
_screen[y * width + x] = colorIndex;
}
}
}
@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++) {
double relativeY =
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
int colorByte = texture.pixels[texX * 64 + texY];
// Note: Directional shading is omitted here to preserve strict VGA palette indices.
// Sixel uses a fixed 256-color palette, so real-time shading requires a lookup table.
_screen[y * width + x] = colorByte;
}
}
@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
if (colorByte != 255) {
_screen[y * width + stripeX] = 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) {
_screen[drawY * width + drawX] = colorByte;
}
}
}
}
}
@override
void drawHud(WolfEngine engine) {
int statusBarIndex = engine.data.vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
_blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160);
_drawNumber(1, 32, 176, engine.data.vgaImages);
_drawNumber(engine.player.score, 96, 176, engine.data.vgaImages);
_drawNumber(3, 120, 176, engine.data.vgaImages);
_drawNumber(engine.player.health, 192, 176, engine.data.vgaImages);
_drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages);
_drawFace(engine);
_drawWeaponIcon(engine);
}
@override
String finalizeFrame() {
final String clearPrefix = _needsBackgroundClear
? '$_terminalTealBackground\x1b[2J\x1b[0m'
: '';
_needsBackgroundClear = false;
return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}';
}
// ===========================================================================
// SIXEL ENCODER
// ===========================================================================
/// Converts the 8-bit index buffer into a standard Sixel sequence
String toSixelString() {
StringBuffer sb = StringBuffer();
// Start Sixel sequence (q = Sixel format)
sb.write('\x1bPq');
// 1. Define the Palette (and apply damage flash directly to the palette!)
double damageIntensity = _engine.player.damageFlash;
int redBoost = (150 * damageIntensity).toInt();
double colorDrop = 1.0 - (0.5 * damageIntensity);
for (int i = 0; i < 256; i++) {
int color = ColorPalette.vga32Bit[i];
int r = color & 0xFF;
int g = (color >> 8) & 0xFF;
int b = (color >> 16) & 0xFF;
if (damageIntensity > 0) {
r = (r + redBoost).clamp(0, 255);
g = (g * colorDrop).toInt().clamp(0, 255);
b = (b * colorDrop).toInt().clamp(0, 255);
}
// Sixel RGB ranges from 0 to 100
int sixelR = (r * 100) ~/ 255;
int sixelG = (g * 100) ~/ 255;
int sixelB = (b * 100) ~/ 255;
sb.write('#$i;2;$sixelR;$sixelG;$sixelB');
}
// 2. Encode scaled image in 6-pixel vertical bands.
for (int band = 0; band < _outputHeight; band += 6) {
Map<int, Uint8List> colorMap = {};
// Map out which pixels use which color in this 6px high band
for (int x = 0; x < _outputWidth; x++) {
for (int yOffset = 0; yOffset < 6; yOffset++) {
int y = band + yOffset;
if (y >= _outputHeight) break;
int colorIdx = _sampleScaledPixel(x, y);
if (!colorMap.containsKey(colorIdx)) {
colorMap[colorIdx] = Uint8List(_outputWidth);
}
// Set the bit corresponding to the vertical position (0-5)
colorMap[colorIdx]![x] |= (1 << yOffset);
}
}
// Write the encoded Sixel characters for each color present in the band
bool firstColor = true;
for (var entry in colorMap.entries) {
if (!firstColor) {
// Carriage return to overlay colors on the same band
sb.write('\$');
}
firstColor = false;
// Select color index
sb.write('#${entry.key}');
Uint8List cols = entry.value;
int currentVal = -1;
int runLength = 0;
// Run-Length Encoding (RLE) loop
for (int x = 0; x < _outputWidth; x++) {
int val = cols[x];
if (val == currentVal) {
runLength++;
} else {
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
currentVal = val;
runLength = 1;
}
}
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
}
if (band + 6 < _outputHeight) {
sb.write('-');
}
}
// End Sixel sequence
sb.write('\x1b\\');
return sb.toString();
}
int _sampleScaledPixel(int outX, int outY) {
final int srcX = ((((outX + 0.5) * width) / _outputWidth) - 0.5)
.round()
.clamp(
0,
width - 1,
);
final int srcY = ((((outY + 0.5) * height) / _outputHeight) - 0.5)
.round()
.clamp(
0,
height - 1,
);
return _screen[srcY * width + srcX];
}
void _writeSixelRle(StringBuffer sb, int value, int runLength) {
String char = String.fromCharCode(value + 63);
// Sixel RLE format: !<count><char> (only worth it if count > 3)
if (runLength > 3) {
sb.write('!$runLength$char');
} else {
sb.write(char * runLength);
}
}
// ===========================================================================
// PRIVATE HUD HELPERS (Adapted for 8-bit index buffer)
// ===========================================================================
void _blitVgaImage(VgaImage image, int startX, int startY) {
int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height;
final double scaleX = width / 320.0;
final double scaleY = height / 200.0;
final int destStartX = (startX * scaleX).toInt();
final int destStartY = (startY * scaleY).toInt();
final int destWidth = math.max(1, (image.width * scaleX).toInt());
final int destHeight = math.max(1, (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 * width + drawX] = 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 = (health <= 0)
? 127
: 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3);
if (faceIndex < engine.data.vgaImages.length) {
_blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164);
}
}
void _drawWeaponIcon(WolfEngine engine) {
int weaponIndex = 89;
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);
}
}
}

View File

@@ -0,0 +1,265 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
late FrameBuffer _buffer;
late WolfEngine _engine;
// Intercept the base render call to store our references
@override
FrameBuffer render(WolfEngine engine) {
_engine = engine;
_buffer = engine.frameBuffer;
return super.render(engine);
}
@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,473 @@
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<T> {
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 logical width of the projection area used for raycasting and sprites.
/// Most renderers use the full buffer width.
int get projectionWidth => width;
/// Horizontal offset of the projection area within the output buffer.
int get projectionOffsetX => 0;
/// The logical height of the 3D projection before a renderer maps rows to output pixels.
/// Most renderers use the visible view height. Terminal ASCII can override this to render
/// more vertical detail and collapse it into half-block glyphs.
int get projectionViewHeight => viewHeight;
/// Whether the current terminal dimensions are supported by this renderer.
/// Default renderers accept all sizes.
bool isTerminalSizeSupported(int columns, int rows) => true;
/// Human-readable requirement text used by the host app when size checks fail.
String get terminalSizeRequirement => 'Please resize your terminal window.';
/// The main entry point called by the game loop.
/// Orchestrates the mathematical rendering pipeline.
T render(WolfEngine engine) {
width = engine.frameBuffer.width;
height = engine.frameBuffer.height;
// The 3D view typically takes up the top 80% of the screen
viewHeight = (height * 0.8).toInt();
zBuffer = List.filled(projectionWidth, 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).
T 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);
}
({double distance, int side, int hitWallId, double wallX})?
_intersectActivePushwall(
Player player,
Coordinate2D rayDir,
Pushwall activePushwall,
) {
double minX = activePushwall.x.toDouble();
double maxX = activePushwall.x + 1.0;
double minY = activePushwall.y.toDouble();
double maxY = activePushwall.y + 1.0;
if (activePushwall.dirX != 0) {
final double delta = activePushwall.dirX * activePushwall.offset;
minX += delta;
maxX += delta;
}
if (activePushwall.dirY != 0) {
final double delta = activePushwall.dirY * activePushwall.offset;
minY += delta;
maxY += delta;
}
const double epsilon = 1e-9;
double tMinX = double.negativeInfinity;
double tMaxX = double.infinity;
if (rayDir.x.abs() < epsilon) {
if (player.x < minX || player.x > maxX) {
return null;
}
} else {
final double tx1 = (minX - player.x) / rayDir.x;
final double tx2 = (maxX - player.x) / rayDir.x;
tMinX = math.min(tx1, tx2);
tMaxX = math.max(tx1, tx2);
}
double tMinY = double.negativeInfinity;
double tMaxY = double.infinity;
if (rayDir.y.abs() < epsilon) {
if (player.y < minY || player.y > maxY) {
return null;
}
} else {
final double ty1 = (minY - player.y) / rayDir.y;
final double ty2 = (maxY - player.y) / rayDir.y;
tMinY = math.min(ty1, ty2);
tMaxY = math.max(ty1, ty2);
}
final double entryDistance = math.max(tMinX, tMinY);
final double exitDistance = math.min(tMaxX, tMaxY);
if (exitDistance < 0 || entryDistance > exitDistance) {
return null;
}
final double hitDistance = entryDistance >= 0
? entryDistance
: exitDistance;
if (hitDistance < 0) {
return null;
}
final int side = tMinX > tMinY ? 0 : 1;
final double wallCoord = side == 0
? player.y + hitDistance * rayDir.y
: player.x + hitDistance * rayDir.x;
return (
distance: hitDistance,
side: side,
hitWallId: activePushwall.mapId,
wallX: wallCoord - wallCoord.floor(),
);
}
// ===========================================================================
// 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 int sceneWidth = projectionWidth;
final int sceneHeight = projectionViewHeight;
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 < sceneWidth; x++) {
double cameraX = 2 * x / sceneWidth - 1.0;
Coordinate2D rayDir = dir + (plane * cameraX);
final pushwallHit = activePushwall == null
? null
: _intersectActivePushwall(player, rayDir, activePushwall);
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;
double? wallXOverride;
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) {
if (activePushwall != null &&
mapX == activePushwall.x &&
mapY == activePushwall.y) {
continue;
}
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;
} else {
hit = true;
hitWallId = map[mapY][mapX];
}
}
}
if (hitOutOfBounds || !hit) {
if (pushwallHit == null) {
continue;
}
customDistCalculated = true;
perpWallDist = pushwallHit.distance;
side = pushwallHit.side;
hitWallId = pushwallHit.hitWallId;
wallXOverride = pushwallHit.wallX;
textureOffset = 0.0;
hit = true;
hitOutOfBounds = false;
}
if (!customDistCalculated) {
perpWallDist = (side == 0)
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
}
if (pushwallHit != null && pushwallHit.distance < perpWallDist) {
customDistCalculated = true;
perpWallDist = pushwallHit.distance;
side = pushwallHit.side;
hitWallId = pushwallHit.hitWallId;
wallXOverride = pushwallHit.wallX;
textureOffset = 0.0;
}
if (perpWallDist < 0.1) perpWallDist = 0.1;
// Save for sprite depth checks
zBuffer[x] = perpWallDist;
// Calculate Texture X Coordinate
double wallX =
wallXOverride ??
((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 = ((sceneHeight / perpWallDist) * verticalStretch)
.toInt();
int drawStart = (-columnHeight ~/ 2 + sceneHeight ~/ 2).clamp(
0,
sceneHeight,
);
int drawEnd = (columnHeight ~/ 2 + sceneHeight ~/ 2).clamp(
0,
sceneHeight,
);
// Tell the implementation to draw this column
drawWallColumn(
projectionOffsetX + 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);
final int sceneWidth = projectionWidth;
final int sceneHeight = projectionViewHeight;
// 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 = ((sceneWidth / 2) * (1 + transformX / transformY))
.toInt();
int spriteHeight = ((sceneHeight / transformY).abs() * verticalStretch)
.toInt();
int displayedSpriteHeight =
((viewHeight / transformY).abs() * verticalStretch).toInt();
// Scale width based on the aspectMultiplier (useful for ASCII)
int spriteWidth =
(displayedSpriteHeight * aspectMultiplier / verticalStretch)
.toInt();
int drawStartY = -spriteHeight ~/ 2 + sceneHeight ~/ 2;
int drawEndY = spriteHeight ~/ 2 + sceneHeight ~/ 2;
int drawStartX = -spriteWidth ~/ 2 + spriteScreenX;
int drawEndX = spriteWidth ~/ 2 + spriteScreenX;
int clipStartX = math.max(0, drawStartX);
int clipEndX = math.min(sceneWidth, 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(
projectionOffsetX + 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,423 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:wolf_3d_dart/src/rasterizer/cli_rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class SixelRasterizer extends CliRasterizer<String> {
static const double _targetAspectRatio = 4 / 3;
static const int _defaultLineHeightPx = 18;
static const double _defaultCellWidthToHeight = 0.55;
static const int _minimumTerminalColumns = 117;
static const int _minimumTerminalRows = 34;
static const int _maxRenderWidth = 320;
static const int _maxRenderHeight = 240;
static const String _terminalTealBackground = '\x1b[48;2;0;150;136m';
late Uint8List _screen;
late WolfEngine _engine;
int _offsetColumns = 0;
int _offsetRows = 0;
int _outputWidth = 1;
int _outputHeight = 1;
bool _needsBackgroundClear = true;
FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) {
final int previousOffsetColumns = _offsetColumns;
final int previousOffsetRows = _offsetRows;
final int previousOutputWidth = _outputWidth;
final int previousOutputHeight = _outputHeight;
final double fitScale = math.min(
terminalBuffer.width / _minimumTerminalColumns,
terminalBuffer.height / _minimumTerminalRows,
);
final int targetColumns = math.max(
1,
(_minimumTerminalColumns * fitScale).floor(),
);
final int targetRows = math.max(
1,
(_minimumTerminalRows * fitScale).floor(),
);
_offsetColumns = math.max(0, (terminalBuffer.width - targetColumns) ~/ 2);
_offsetRows = math.max(0, (terminalBuffer.height - targetRows) ~/ 2);
final int boundsPixelWidth = math.max(
1,
(targetColumns * _defaultLineHeightPx * _defaultCellWidthToHeight)
.floor(),
);
final int boundsPixelHeight = math.max(
1,
targetRows * _defaultLineHeightPx,
);
final double boundsAspect = boundsPixelWidth / boundsPixelHeight;
if (boundsAspect > _targetAspectRatio) {
_outputHeight = boundsPixelHeight;
_outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor());
} else {
_outputWidth = boundsPixelWidth;
_outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor());
}
if (_offsetColumns != previousOffsetColumns ||
_offsetRows != previousOffsetRows ||
_outputWidth != previousOutputWidth ||
_outputHeight != previousOutputHeight) {
_needsBackgroundClear = true;
}
final double renderScale = math.min(
1.0,
math.min(
_maxRenderWidth / _outputWidth,
_maxRenderHeight / _outputHeight,
),
);
final int renderWidth = math.max(1, (_outputWidth * renderScale).floor());
final int renderHeight = math.max(1, (_outputHeight * renderScale).floor());
return FrameBuffer(renderWidth, renderHeight);
}
@override
String render(WolfEngine engine) {
_engine = engine;
final FrameBuffer originalBuffer = engine.frameBuffer;
final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);
// We only need 8-bit indices for the 256 VGA colors
_screen = Uint8List(scaledBuffer.width * scaledBuffer.height);
engine.frameBuffer = scaledBuffer;
try {
return super.render(engine);
} finally {
engine.frameBuffer = originalBuffer;
}
}
@override
void prepareFrame(WolfEngine engine) {
// Top half is ceiling color index (25), bottom half is floor color index (29)
for (int y = 0; y < viewHeight; y++) {
int colorIndex = (y < viewHeight / 2) ? 25 : 29;
for (int x = 0; x < width; x++) {
_screen[y * width + x] = colorIndex;
}
}
}
@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++) {
double relativeY =
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
int colorByte = texture.pixels[texX * 64 + texY];
// Note: Directional shading is omitted here to preserve strict VGA palette indices.
// Sixel uses a fixed 256-color palette, so real-time shading requires a lookup table.
_screen[y * width + x] = colorByte;
}
}
@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
if (colorByte != 255) {
_screen[y * width + stripeX] = 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) {
_screen[drawY * width + drawX] = colorByte;
}
}
}
}
}
@override
void drawHud(WolfEngine engine) {
int statusBarIndex = engine.data.vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
_blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160);
_drawNumber(1, 32, 176, engine.data.vgaImages);
_drawNumber(engine.player.score, 96, 176, engine.data.vgaImages);
_drawNumber(3, 120, 176, engine.data.vgaImages);
_drawNumber(engine.player.health, 192, 176, engine.data.vgaImages);
_drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages);
_drawFace(engine);
_drawWeaponIcon(engine);
}
@override
String finalizeFrame() {
final String clearPrefix = _needsBackgroundClear
? '$_terminalTealBackground\x1b[2J\x1b[0m'
: '';
_needsBackgroundClear = false;
return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}';
}
// ===========================================================================
// SIXEL ENCODER
// ===========================================================================
/// Converts the 8-bit index buffer into a standard Sixel sequence
String toSixelString() {
StringBuffer sb = StringBuffer();
// Start Sixel sequence (q = Sixel format)
sb.write('\x1bPq');
// 1. Define the Palette (and apply damage flash directly to the palette!)
double damageIntensity = _engine.player.damageFlash;
int redBoost = (150 * damageIntensity).toInt();
double colorDrop = 1.0 - (0.5 * damageIntensity);
for (int i = 0; i < 256; i++) {
int color = ColorPalette.vga32Bit[i];
int r = color & 0xFF;
int g = (color >> 8) & 0xFF;
int b = (color >> 16) & 0xFF;
if (damageIntensity > 0) {
r = (r + redBoost).clamp(0, 255);
g = (g * colorDrop).toInt().clamp(0, 255);
b = (b * colorDrop).toInt().clamp(0, 255);
}
// Sixel RGB ranges from 0 to 100
int sixelR = (r * 100) ~/ 255;
int sixelG = (g * 100) ~/ 255;
int sixelB = (b * 100) ~/ 255;
sb.write('#$i;2;$sixelR;$sixelG;$sixelB');
}
// 2. Encode scaled image in 6-pixel vertical bands.
for (int band = 0; band < _outputHeight; band += 6) {
Map<int, Uint8List> colorMap = {};
// Map out which pixels use which color in this 6px high band
for (int x = 0; x < _outputWidth; x++) {
for (int yOffset = 0; yOffset < 6; yOffset++) {
int y = band + yOffset;
if (y >= _outputHeight) break;
int colorIdx = _sampleScaledPixel(x, y);
if (!colorMap.containsKey(colorIdx)) {
colorMap[colorIdx] = Uint8List(_outputWidth);
}
// Set the bit corresponding to the vertical position (0-5)
colorMap[colorIdx]![x] |= (1 << yOffset);
}
}
// Write the encoded Sixel characters for each color present in the band
bool firstColor = true;
for (var entry in colorMap.entries) {
if (!firstColor) {
// Carriage return to overlay colors on the same band
sb.write('\$');
}
firstColor = false;
// Select color index
sb.write('#${entry.key}');
Uint8List cols = entry.value;
int currentVal = -1;
int runLength = 0;
// Run-Length Encoding (RLE) loop
for (int x = 0; x < _outputWidth; x++) {
int val = cols[x];
if (val == currentVal) {
runLength++;
} else {
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
currentVal = val;
runLength = 1;
}
}
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
}
if (band + 6 < _outputHeight) {
sb.write('-');
}
}
// End Sixel sequence
sb.write('\x1b\\');
return sb.toString();
}
int _sampleScaledPixel(int outX, int outY) {
final int srcX = ((((outX + 0.5) * width) / _outputWidth) - 0.5)
.round()
.clamp(
0,
width - 1,
);
final int srcY = ((((outY + 0.5) * height) / _outputHeight) - 0.5)
.round()
.clamp(
0,
height - 1,
);
return _screen[srcY * width + srcX];
}
void _writeSixelRle(StringBuffer sb, int value, int runLength) {
String char = String.fromCharCode(value + 63);
// Sixel RLE format: !<count><char> (only worth it if count > 3)
if (runLength > 3) {
sb.write('!$runLength$char');
} else {
sb.write(char * runLength);
}
}
// ===========================================================================
// PRIVATE HUD HELPERS (Adapted for 8-bit index buffer)
// ===========================================================================
void _blitVgaImage(VgaImage image, int startX, int startY) {
int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height;
final double scaleX = width / 320.0;
final double scaleY = height / 200.0;
final int destStartX = (startX * scaleX).toInt();
final int destStartY = (startY * scaleY).toInt();
final int destWidth = math.max(1, (image.width * scaleX).toInt());
final int destHeight = math.max(1, (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 * width + drawX] = 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 = (health <= 0)
? 127
: 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3);
if (faceIndex < engine.data.vgaImages.length) {
_blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164);
}
}
void _drawWeaponIcon(WolfEngine engine) {
int weaponIndex = 89;
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);
}
}
}

View File

@@ -0,0 +1,265 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
late FrameBuffer _buffer;
late WolfEngine _engine;
// Intercept the base render call to store our references
@override
FrameBuffer render(WolfEngine engine) {
_engine = engine;
_buffer = engine.frameBuffer;
return super.render(engine);
}
@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

@@ -9,10 +9,4 @@ export 'src/engine/input/engine_input.dart';
export 'src/engine/managers/door_manager.dart';
export 'src/engine/managers/pushwall_manager.dart';
export 'src/engine/player/player.dart';
export 'src/engine/rasterizer/ascii_rasterizer.dart'
show AsciiRasterizer, ColoredChar;
export 'src/engine/rasterizer/cli_rasterizer.dart';
export 'src/engine/rasterizer/rasterizer.dart';
export 'src/engine/rasterizer/sixel_rasterizer.dart';
export 'src/engine/rasterizer/software_rasterizer.dart';
export 'src/engine/wolf_3d_engine_base.dart';

View File

@@ -0,0 +1,7 @@
library;
export 'src/rasterizer/ascii_rasterizer.dart' show AsciiRasterizer, ColoredChar;
export 'src/rasterizer/cli_rasterizer.dart';
export 'src/rasterizer/rasterizer.dart';
export 'src/rasterizer/sixel_rasterizer.dart';
export 'src/rasterizer/software_rasterizer.dart';

View File

@@ -0,0 +1,90 @@
import 'dart:typed_data';
import 'package:test/test.dart';
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_input.dart';
import 'package:wolf_3d_dart/wolf_3d_rasterizer.dart';
void main() {
group('Pushwall rasterization', () {
test('active pushwall occludes the wall behind it while sliding', () {
final wallGrid = _buildGrid();
final objectGrid = _buildGrid();
_fillBoundaries(wallGrid, 2);
objectGrid[2][2] = MapObject.playerEast;
wallGrid[2][4] = 1;
objectGrid[2][4] = MapObject.pushwallTrigger;
wallGrid[2][6] = 2;
final engine = WolfEngine(
data: WolfensteinData(
version: GameVersion.shareware,
walls: [
_solidSprite(1),
_solidSprite(1),
_solidSprite(2),
_solidSprite(2),
],
sprites: List.generate(436, (_) => _solidSprite(255)),
sounds: [],
adLibSounds: [],
music: [],
vgaImages: [],
episodes: [
Episode(
name: 'Episode 1',
levels: [
WolfLevel(
name: 'Test Level',
wallGrid: wallGrid,
objectGrid: objectGrid,
musicIndex: 0,
),
],
),
],
),
difficulty: Difficulty.medium,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: CliInput(),
onGameWon: () {},
);
engine.init();
final pushwall = engine.pushwallManager.pushwalls['4,2']!;
pushwall
..dirX = 1
..dirY = 0
..offset = 0.5;
engine.pushwallManager.activePushwall = pushwall;
final frame = SoftwareRasterizer().render(engine);
final centerIndex =
(frame.height ~/ 2) * frame.width + (frame.width ~/ 2);
expect(frame.pixels[centerIndex], ColorPalette.vga32Bit[1]);
expect(frame.pixels[centerIndex], isNot(ColorPalette.vga32Bit[2]));
});
});
}
SpriteMap _buildGrid() => List.generate(64, (_) => List.filled(64, 0));
void _fillBoundaries(SpriteMap grid, int wallId) {
for (int i = 0; i < 64; i++) {
grid[0][i] = wallId;
grid[63][i] = wallId;
grid[i][0] = wallId;
grid[i][63] = wallId;
}
}
Sprite _solidSprite(int colorIndex) {
return Sprite(Uint8List.fromList(List.filled(64 * 64, colorIndex)));
}