WIP moving difficulty selection to engine
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -8,6 +8,9 @@ class EngineInput {
|
||||
final bool isTurningRight;
|
||||
final bool isFiring;
|
||||
final bool isInteracting;
|
||||
final bool isBack;
|
||||
final double? menuTapX;
|
||||
final double? menuTapY;
|
||||
final WeaponType? requestedWeapon;
|
||||
|
||||
const EngineInput({
|
||||
@@ -17,6 +20,9 @@ class EngineInput {
|
||||
this.isTurningRight = false,
|
||||
this.isFiring = false,
|
||||
this.isInteracting = false,
|
||||
this.isBack = false,
|
||||
this.menuTapX,
|
||||
this.menuTapY,
|
||||
this.requestedWeapon,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,837 +0,0 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,39 @@ 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';
|
||||
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||
|
||||
class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
||||
static const Map<String, List<String>> _menuFont = {
|
||||
'A': ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
'B': ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
|
||||
'C': ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
|
||||
'D': ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
|
||||
'E': ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
|
||||
'F': ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
|
||||
'G': ['01110', '10001', '10000', '10111', '10001', '10001', '01111'],
|
||||
'H': ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
'I': ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
|
||||
'K': ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
|
||||
'L': ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
|
||||
'M': ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
|
||||
'N': ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
|
||||
'O': ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
'P': ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
|
||||
'R': ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
|
||||
'S': ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
|
||||
'T': ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
|
||||
'U': ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
'W': ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
|
||||
'Y': ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
|
||||
'?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'],
|
||||
'!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'],
|
||||
',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'],
|
||||
'.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'],
|
||||
"'": ['00100', '00100', '00100', '00000', '00000', '00000', '00000'],
|
||||
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
|
||||
};
|
||||
|
||||
late FrameBuffer _buffer;
|
||||
late WolfEngine _engine;
|
||||
|
||||
@@ -145,10 +176,126 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
||||
_drawWeaponIcon(engine);
|
||||
}
|
||||
|
||||
@override
|
||||
void drawMenu(WolfEngine engine) {
|
||||
final int bgColor = ColorPalette.vga32Bit[153];
|
||||
final int panelColor = ColorPalette.vga32Bit[157];
|
||||
final int headingColor = ColorPalette.vga32Bit[119];
|
||||
final int selectedTextColor = ColorPalette.vga32Bit[19];
|
||||
final int unselectedTextColor = ColorPalette.vga32Bit[23];
|
||||
|
||||
for (int i = 0; i < _buffer.pixels.length; i++) {
|
||||
_buffer.pixels[i] = bgColor;
|
||||
}
|
||||
|
||||
const panelX = 28;
|
||||
const panelY = 70;
|
||||
const panelW = 264;
|
||||
const panelH = 82;
|
||||
|
||||
for (int y = panelY; y < panelY + panelH; y++) {
|
||||
if (y < 0 || y >= height) continue;
|
||||
final rowStart = y * width;
|
||||
for (int x = panelX; x < panelX + panelW; x++) {
|
||||
if (x >= 0 && x < width) {
|
||||
_buffer.pixels[rowStart + x] = panelColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final art = WolfClassicMenuArt(engine.data);
|
||||
_drawMenuTextCentered('HOW TOUGH ARE YOU?', 48, headingColor, scale: 2);
|
||||
|
||||
final bottom = art.pic(15);
|
||||
if (bottom != null) {
|
||||
final x = (width - bottom.width) ~/ 2;
|
||||
final y = height - bottom.height - 8;
|
||||
_blitVgaImage(bottom, x, y);
|
||||
}
|
||||
|
||||
final face = art.difficultyOption(
|
||||
Difficulty.values[engine.menuSelectedDifficultyIndex],
|
||||
);
|
||||
if (face != null) {
|
||||
_blitVgaImage(face, panelX + panelW - face.width - 10, panelY + 22);
|
||||
}
|
||||
|
||||
final cursor = art.pic(engine.isMenuCursorAltFrame ? 9 : 8);
|
||||
const rowYStart = panelY + 16;
|
||||
const rowStep = 15;
|
||||
const textX = panelX + 42;
|
||||
const labels = [
|
||||
'CAN I PLAY, DADDY?',
|
||||
"DON'T HURT ME.",
|
||||
"BRING 'EM ON!",
|
||||
'I AM DEATH INCARNATE!',
|
||||
];
|
||||
|
||||
for (int i = 0; i < Difficulty.values.length; i++) {
|
||||
final y = rowYStart + (i * rowStep);
|
||||
final isSelected = i == engine.menuSelectedDifficultyIndex;
|
||||
|
||||
if (isSelected && cursor != null) {
|
||||
_blitVgaImage(cursor, panelX + 10, y - 2);
|
||||
}
|
||||
|
||||
_drawMenuText(
|
||||
labels[i],
|
||||
textX,
|
||||
y,
|
||||
isSelected ? selectedTextColor : unselectedTextColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawMenuText(
|
||||
String text,
|
||||
int startX,
|
||||
int startY,
|
||||
int color, {
|
||||
int scale = 1,
|
||||
}) {
|
||||
int x = startX;
|
||||
for (final rune in text.runes) {
|
||||
final char = String.fromCharCode(rune).toUpperCase();
|
||||
final pattern = _menuFont[char] ?? _menuFont[' ']!;
|
||||
|
||||
for (int row = 0; row < pattern.length; row++) {
|
||||
final bits = pattern[row];
|
||||
for (int col = 0; col < bits.length; col++) {
|
||||
if (bits[col] != '1') continue;
|
||||
for (int sy = 0; sy < scale; sy++) {
|
||||
for (int sx = 0; sx < scale; sx++) {
|
||||
final drawX = x + (col * scale) + sx;
|
||||
final drawY = startY + (row * scale) + sy;
|
||||
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
|
||||
_buffer.pixels[drawY * width + drawX] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
x += (6 * scale);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawMenuTextCentered(
|
||||
String text,
|
||||
int y,
|
||||
int color, {
|
||||
int scale = 1,
|
||||
}) {
|
||||
final textWidth = text.length * 6 * scale;
|
||||
final x = ((width - textWidth) ~/ 2).clamp(0, width - 1);
|
||||
_drawMenuText(text, x, y, color, scale: scale);
|
||||
}
|
||||
|
||||
@override
|
||||
FrameBuffer finalizeFrame() {
|
||||
// If the player took damage, overlay a red tint across the 3D view
|
||||
if (_engine.player.damageFlash > 0) {
|
||||
if (!_engine.isDifficultySelectionPending &&
|
||||
_engine.player.damageFlash > 0) {
|
||||
_applyDamageFlash();
|
||||
}
|
||||
return _buffer; // Return the fully painted pixel array
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:wolf_3d_dart/src/menu/menu_manager.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_entities.dart';
|
||||
@@ -13,11 +14,13 @@ import 'package:wolf_3d_dart/wolf_3d_input.dart';
|
||||
class WolfEngine {
|
||||
WolfEngine({
|
||||
required this.data,
|
||||
required this.difficulty,
|
||||
required this.startingEpisode,
|
||||
required this.onGameWon,
|
||||
required this.input,
|
||||
required this.frameBuffer,
|
||||
this.difficulty,
|
||||
this.menuBackgroundRgb = 0x890000,
|
||||
this.menuPanelRgb = 0x590002,
|
||||
EngineAudio? audio,
|
||||
}) : audio = audio ?? CliSilentAudio(),
|
||||
doorManager = DoorManager(
|
||||
@@ -33,8 +36,26 @@ class WolfEngine {
|
||||
/// The static game data (textures, sounds, maps) parsed from original files.
|
||||
final WolfensteinData data;
|
||||
|
||||
/// Desired menu background color in 24-bit RGB.
|
||||
final int menuBackgroundRgb;
|
||||
|
||||
/// Desired menu panel color in 24-bit RGB.
|
||||
final int menuPanelRgb;
|
||||
|
||||
/// The active difficulty level, affecting enemy spawning and behavior.
|
||||
final Difficulty difficulty;
|
||||
Difficulty? difficulty;
|
||||
|
||||
/// Whether the engine is waiting on player difficulty selection.
|
||||
bool get isDifficultySelectionPending => difficulty == null;
|
||||
|
||||
/// Menu state owner for difficulty-selection navigation and edge detection.
|
||||
final MenuManager menuManager = MenuManager();
|
||||
|
||||
/// Cursor index used by renderer-side difficulty menus.
|
||||
int get menuSelectedDifficultyIndex => menuManager.selectedDifficultyIndex;
|
||||
|
||||
/// Cursor blink phase used by renderer-side difficulty menus.
|
||||
bool get isMenuCursorAltFrame => menuManager.isCursorAltFrame(_timeAliveMs);
|
||||
|
||||
/// The episode index where the game session begins.
|
||||
final int startingEpisode;
|
||||
@@ -62,7 +83,11 @@ class WolfEngine {
|
||||
// --- World State ---
|
||||
|
||||
/// The player's current position, stats, and inventory.
|
||||
late Player player;
|
||||
///
|
||||
/// This starts with a safe placeholder so menu-mode rendering/input can
|
||||
/// access player fields (for example damage flash state) before a map is
|
||||
/// loaded. `_loadLevel()` replaces it with the true map spawn.
|
||||
Player player = Player(x: 1.5, y: 1.5, angle: 0.0);
|
||||
|
||||
/// The mutable 64x64 grid representing the current world.
|
||||
/// This grid is modified in real-time by doors and pushwalls.
|
||||
@@ -91,7 +116,13 @@ class WolfEngine {
|
||||
audio.activeGame = data;
|
||||
_currentEpisodeIndex = startingEpisode;
|
||||
_currentLevelIndex = 0;
|
||||
_loadLevel();
|
||||
|
||||
menuManager.beginDifficultySelection(initialDifficulty: difficulty);
|
||||
|
||||
if (!isDifficultySelectionPending) {
|
||||
_loadLevel();
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
@@ -119,6 +150,12 @@ class WolfEngine {
|
||||
// 1. Process User Input
|
||||
input.update();
|
||||
final currentInput = input.currentInput;
|
||||
|
||||
if (isDifficultySelectionPending) {
|
||||
_tickDifficultyMenu(currentInput);
|
||||
return;
|
||||
}
|
||||
|
||||
final inputResult = _processInputs(delta, currentInput);
|
||||
|
||||
// 2. Update Environment
|
||||
@@ -150,6 +187,19 @@ class WolfEngine {
|
||||
);
|
||||
}
|
||||
|
||||
void _tickDifficultyMenu(EngineInput input) {
|
||||
final menuResult = menuManager.updateDifficultySelection(input);
|
||||
if (menuResult.goBack) {
|
||||
onGameWon();
|
||||
return;
|
||||
}
|
||||
|
||||
if (menuResult.selected != null) {
|
||||
difficulty = menuResult.selected;
|
||||
_loadLevel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wipes the current world state and builds a new floor from map data.
|
||||
void _loadLevel() {
|
||||
entities.clear();
|
||||
@@ -182,7 +232,7 @@ class WolfEngine {
|
||||
objId,
|
||||
x + 0.5,
|
||||
y + 0.5,
|
||||
difficulty,
|
||||
difficulty!,
|
||||
data.sprites.length,
|
||||
isSharewareMode: data.version == GameVersion.shareware,
|
||||
);
|
||||
@@ -401,7 +451,7 @@ class WolfEngine {
|
||||
MapObject.ammoClip,
|
||||
entity.x,
|
||||
entity.y,
|
||||
difficulty,
|
||||
difficulty!,
|
||||
data.sprites.length,
|
||||
);
|
||||
if (droppedAmmo != null) itemsToAdd.add(droppedAmmo);
|
||||
|
||||
Reference in New Issue
Block a user