Refactor rendering architecture and replace rasterizer with renderer

- Introduced SoftwareRenderer as a pixel-accurate software rendering backend.
- Removed the obsolete wolf_3d_rasterizer.dart file.
- Created a new wolf_3d_renderer.dart file to centralize rendering exports.
- Updated tests to accommodate the new rendering structure, including pushwall and projection sampling tests.
- Modified the WolfAsciiRenderer and WolfFlutterRenderer to utilize the new SoftwareRenderer.
- Enhanced enemy spawn tests to include new enemy states.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-19 11:38:07 +01:00
parent ac6edb030e
commit 786ba4b450
22 changed files with 952 additions and 684 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
import 'package:wolf_3d_dart/src/engine/wolf_3d_engine_base.dart';
import 'renderer_backend.dart';
/// Shared terminal orchestration for CLI renderers.
abstract class CliRendererBackend<T> extends RendererBackend<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...';
}
}
@@ -0,0 +1,82 @@
class WolfMenuFont {
const WolfMenuFont._();
static const int _letterSpacing = 2;
static const Map<String, List<String>> glyphs = {
'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'],
'J': ['00001', '00001', '00001', '00001', '10001', '10001', '01110'],
'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'],
'Q': ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
'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'],
'V': ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
'W': ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
'X': ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
'Y': ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
'Z': ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
'0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
'1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
'2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
'3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
'4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
'5': ['11111', '10000', '10000', '11110', '00001', '00001', '11110'],
'6': ['01110', '10000', '10000', '11110', '10001', '10001', '01110'],
'7': ['11111', '00001', '00010', '00100', '01000', '10000', '10000'],
'8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
'9': ['01110', '10001', '10001', '01111', '00001', '00001', '01110'],
'?': ['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', '00110', '00110', '00000', '00110', '00110', '00000'],
'/': ['00001', '00010', '00100', '01000', '10000', '00000', '00000'],
'>': ['00000', '10000', '01000', '00100', '01000', '10000', '00000'],
'-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
};
static List<String> glyphFor(String char) {
return glyphs[char] ?? glyphs[' ']!;
}
static int glyphAdvance(String char, int scale) {
switch (char) {
case 'I':
case '!':
case '.':
case ',':
case "'":
case ':':
return (4 + _letterSpacing) * scale;
case ' ':
return 5 * scale;
default:
return (6 + _letterSpacing) * scale;
}
}
static int measureTextWidth(String text, int scale) {
int width = 0;
for (final rune in text.runes) {
width += glyphAdvance(String.fromCharCode(rune).toUpperCase(), scale);
}
return width;
}
}
@@ -0,0 +1,231 @@
import 'package:wolf_3d_dart/src/raycasting/projection.dart';
import 'package:wolf_3d_dart/src/raycasting/raycaster.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Shared rendering pipeline for Wolf3D backends.
///
/// Subclasses implement draw primitives for their output target (software
/// framebuffer, ANSI text, Sixel, etc), while this backend coordinates frame
/// orchestration and delegates DDA/sprite math to [Raycaster].
abstract class RendererBackend<T>
with ProjectionMath
implements RaycastBackend {
@override
List<double> zBuffer = <double>[];
late int width;
late int height;
@override
late int viewHeight;
/// The current engine instance; set at the start of every [render] call.
late WolfEngine engine;
final Raycaster _raycaster = Raycaster();
/// 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.
@override
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.
@override
double get verticalStretch => 1.0;
/// The logical width of the projection area used for raycasting and sprites.
/// Most renderers use the full buffer width.
@override
int get projectionWidth => width;
/// Horizontal offset of the projection area within the output buffer.
@override
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.
@override
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.';
void _ensureZBuffer() {
if (zBuffer.length != projectionWidth) {
zBuffer = List<double>.filled(projectionWidth, 0.0);
return;
}
zBuffer.fillRange(0, zBuffer.length, 0.0);
}
/// The main entry point called by the game loop.
/// Orchestrates the rendering pipeline.
T render(WolfEngine engine) {
this.engine = 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();
_ensureZBuffer();
// 1. Setup the frame (clear screen, draw floor/ceiling).
prepareFrame(engine);
if (engine.difficulty == null) {
drawMenu(engine);
return finalizeFrame();
}
// 2. Do the heavy math for wall and sprite casting.
_raycaster.castWorld(engine, this);
// 3. Draw 2D overlays.
drawWeapon(engine);
drawHud(engine);
// 4. Finalize and return the frame data (Buffer or String/List).
return finalizeFrame();
}
// ===========================================================================
// ABSTRACT METHODS (Implemented by backend subclasses)
// ===========================================================================
/// Initialize buffers, clear the screen, and draw the floor/ceiling.
void prepareFrame(WolfEngine engine);
/// Draw a single vertical column of a wall.
@override
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).
@override
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();
/// Draws a non-world menu frame when the engine is awaiting configuration.
///
/// Default implementation is a no-op for backends that don't support menus.
void drawMenu(WolfEngine engine) {}
/// Plots a VGA image into this backend's HUD coordinate space.
///
/// Coordinates are in the original 320x200 HUD space. Backends that support
/// shared HUD composition should override this.
void blitHudVgaImage(VgaImage image, int startX320, int startY200) {}
/// Shared Wolf3D VGA HUD sequence used by software/sixel/ASCII-full-HUD.
///
/// Coordinates are intentionally in original 320x200 HUD space so each
/// backend can scale/map them consistently via [blitHudVgaImage].
void drawStandardVgaHud(WolfEngine engine) {
final List<VgaImage> vgaImages = engine.data.vgaImages;
final int statusBarIndex = vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
blitHudVgaImage(vgaImages[statusBarIndex], 0, 160);
_drawHudNumber(vgaImages, 1, 32, 176);
_drawHudNumber(vgaImages, engine.player.score, 96, 176);
_drawHudNumber(vgaImages, 3, 120, 176);
_drawHudNumber(vgaImages, engine.player.health, 192, 176);
_drawHudNumber(vgaImages, engine.player.ammo, 232, 176);
_drawHudFace(engine, vgaImages);
_drawHudWeaponIcon(engine, vgaImages);
}
void _drawHudNumber(
List<VgaImage> vgaImages,
int value,
int rightAlignX,
int startY,
) {
// HUD numbers are rendered with fixed-width VGA glyphs (8 px advance).
const int zeroIndex = 96;
final String numStr = value.toString();
int currentX = rightAlignX - (numStr.length * 8);
for (int i = 0; i < numStr.length; i++) {
final int digit = int.parse(numStr[i]);
final int imageIndex = zeroIndex + digit;
if (imageIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[imageIndex], currentX, startY);
}
currentX += 8;
}
}
void _drawHudFace(WolfEngine engine, List<VgaImage> vgaImages) {
final int faceIndex = hudFaceVgaIndex(engine.player.health);
if (faceIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[faceIndex], 136, 164);
}
}
void _drawHudWeaponIcon(WolfEngine engine, List<VgaImage> vgaImages) {
final int weaponIndex = hudWeaponVgaIndex(engine);
if (weaponIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[weaponIndex], 256, 164);
}
}
/// 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 backends (like ASCII dithering).
double calculateDepthBrightness(double distance) {
return (10.0 / (distance + 2.0)).clamp(0.0, 1.0);
}
/// Returns the VGA image index for BJ's face sprite based on player health.
int hudFaceVgaIndex(int health) {
if (health <= 0) return 127;
return 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3);
}
/// Returns the VGA image index for the current weapon icon in the HUD.
int hudWeaponVgaIndex(WolfEngine engine) {
if (engine.player.hasChainGun) return 91;
if (engine.player.hasMachineGun) return 90;
return 89;
}
/// Darkens a 32-bit 0xAABBGGRR color by roughly 30% without touching alpha.
int shadeColor(int color) {
final int r = (color & 0xFF) * 7 ~/ 10;
final int g = ((color >> 8) & 0xFF) * 7 ~/ 10;
final int b = ((color >> 16) & 0xFF) * 7 ~/ 10;
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
}
@@ -0,0 +1,837 @@
/// Terminal renderer that encodes engine frames as Sixel graphics.
library;
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
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_menu.dart';
import 'cli_renderer_backend.dart';
import 'menu_font.dart';
/// Renders the game into an indexed off-screen buffer and emits Sixel output.
///
/// The renderer adapts the engine framebuffer to the current terminal size,
/// preserving a 4:3 presentation while falling back to size warnings when the
/// terminal is too small.
class SixelRenderer extends CliRendererBackend<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 double _terminalViewportSafety = 0.90;
static const int _compactMenuMinWidthPx = 200;
static const int _compactMenuMinHeightPx = 130;
static const int _maxRenderWidth = 320;
static const int _maxRenderHeight = 240;
static const String _terminalTealBackground = '\x1b[48;2;0;150;136m';
late Uint8List _screen;
int _offsetColumns = 0;
int _offsetRows = 0;
int _outputWidth = 1;
int _outputHeight = 1;
bool _needsBackgroundClear = true;
/// Flag to determine if Sixel should actually be encoded and rendered.
/// This should be updated by calling [checkTerminalSixelSupport] before the game loop.
bool isSixelSupported = true;
@override
bool isTerminalSizeSupported(int columns, int rows) {
return columns >= _minimumTerminalColumns && rows >= _minimumTerminalRows;
}
@override
String get terminalSizeRequirement =>
'Sixel renderer requires a minimum resolution of '
'${_minimumTerminalColumns}x$_minimumTerminalRows.';
// ===========================================================================
// TERMINAL AUTODETECT
// ===========================================================================
/// Asynchronously checks if the current terminal emulator supports Sixel graphics.
/// Ports the device attribute query logic from lsix.
static Future<bool> checkTerminalSixelSupport({
Stream<List<int>>? inputStream,
}) async {
// Match lsix behavior: allow explicit user override for misreporting terminals.
if (Platform.environment.containsKey('LSIX_FORCE_SIXEL_SUPPORT')) {
return true;
}
// YAFT is vt102 compatible and cannot respond to the vt220 sequence, but supports Sixel.
final term = Platform.environment['TERM'] ?? '';
if (term.startsWith('yaft')) {
return true;
}
if (!stdin.hasTerminal) return false;
bool terminalModesChanged = false;
bool originalLineMode = true;
bool originalEchoMode = true;
try {
// Some runtimes report hasTerminal=true but still reject mode mutation
// with EBADF (bad file descriptor). Treat that as unsupported.
originalLineMode = stdin.lineMode;
originalEchoMode = stdin.echoMode;
// Don't show escape sequences the terminal doesn't understand[cite: 24].
stdin.lineMode = false;
stdin.echoMode = false;
terminalModesChanged = true;
// Send Device Attributes query[cite: 25].
stdout.write('\x1b[c');
await stdout.flush();
final responseBytes = <int>[];
final completer = Completer<bool>();
final sub = (inputStream ?? stdin).listen((List<int> data) {
for (var byte in data) {
responseBytes.add(byte);
// Wait for the 'c' terminating character[cite: 25].
if (byte == 99) {
if (!completer.isCompleted) completer.complete(true);
}
}
});
try {
// Wait up to 1 second for the terminal to respond[cite: 25].
await completer.future.timeout(const Duration(seconds: 1));
} catch (_) {
// Timeout occurred
} finally {
sub.cancel();
}
final response = String.fromCharCodes(responseBytes);
// Some terminals include full CSI sequences like "\x1b[?62;4;6c".
// Keep only numeric attribute codes to mirror lsix's shell splitting behavior.
final attributeCodes = RegExp(
r'\d+',
).allMatches(response).map((m) => m.group(0)).whereType<String>().toSet();
// Split by ';' or '?' or 'c' to parse attributes[cite: 25].
final parts = response.split(RegExp(r'[;?c]'));
// Code "4" indicates Sixel support[cite: 26].
return parts.contains('4') || attributeCodes.contains('4');
} catch (e) {
return false;
} finally {
// Restore standard terminal settings
if (terminalModesChanged) {
try {
stdin.lineMode = originalLineMode;
stdin.echoMode = originalEchoMode;
} catch (_) {
// Ignore restoration failures to avoid crashing shutdown paths.
}
}
}
}
// ===========================================================================
// RENDERING ENGINE
// ===========================================================================
/// Builds a temporary framebuffer sized to the drawable terminal region.
FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) {
final int previousOffsetColumns = _offsetColumns;
final int previousOffsetRows = _offsetRows;
final int previousOutputWidth = _outputWidth;
final int previousOutputHeight = _outputHeight;
// Fit the sixel output inside the full terminal viewport while preserving
// a 4:3 presentation.
final int terminalColumns = math.max(1, terminalBuffer.width);
final int terminalRows = math.max(1, terminalBuffer.height);
final double cellPixelWidth =
_defaultLineHeightPx * _defaultCellWidthToHeight;
final int boundsPixelWidth = math.max(
1,
(terminalColumns * cellPixelWidth).floor(),
);
final int boundsPixelHeight = math.max(
1,
terminalRows * _defaultLineHeightPx,
);
// Terminal emulators can report approximate cell metrics, so reserve a
// safety margin to avoid right/bottom clipping and drift.
final int safePixelWidth = math.max(
1,
(boundsPixelWidth * _terminalViewportSafety).floor(),
);
final int safePixelHeight = math.max(
1,
(boundsPixelHeight * _terminalViewportSafety).floor(),
);
// Then translate terminal cells into approximate pixels so the Sixel image
// lands on a 4:3 surface inside the available bounds.
final double boundsAspect = safePixelWidth / safePixelHeight;
if (boundsAspect > _targetAspectRatio) {
_outputHeight = safePixelHeight;
_outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor());
} else {
_outputWidth = safePixelWidth;
_outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor());
}
// Horizontal: cell-width estimates vary by terminal/font and cause right-shift
// clipping, so keep the image at column 0.
// Vertical: line-height is reliable enough to center correctly.
final int imageRows = math.max(
1,
(_outputHeight / _defaultLineHeightPx).ceil(),
);
_offsetColumns = 0;
_offsetRows = math.max(0, (terminalRows - imageRows) ~/ 2);
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
/// Renders using a temporary scaled framebuffer sized for Sixel output,
/// then restores the original terminal-sized framebuffer.
String render(WolfEngine engine) {
final FrameBuffer originalBuffer = engine.frameBuffer;
final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);
// Sixel output references palette indices directly, so there is no need to
// materialize a 32-bit RGBA buffer during the rendering pass.
_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++) {
int texY = wallTexY(y, columnHeight);
int colorByte = texture.pixels[texX * 64 + texY];
_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++
) {
int texY = spriteTexY(y, drawStartY, spriteHeight);
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];
final bounds = weaponScreenBounds(engine);
final int weaponWidth = bounds.weaponWidth;
final int weaponHeight = bounds.weaponHeight;
final int startX = bounds.startX;
final int startY = bounds.startY;
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
/// Delegates the canonical Wolf3D VGA HUD draw sequence to the base class.
void drawHud(WolfEngine engine) {
drawStandardVgaHud(engine);
}
@override
/// Blits a VGA image into the Sixel index buffer HUD space (320x200).
void blitHudVgaImage(VgaImage image, int startX320, int startY200) {
_blitVgaImage(image, startX320, startY200);
}
@override
void drawMenu(WolfEngine engine) {
final int bgColor = _rgbToPaletteIndex(engine.menuBackgroundRgb);
final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb);
final int headingIndex = WolfMenuPalette.headerTextIndex;
final int selectedTextIndex = WolfMenuPalette.selectedTextIndex;
final int unselectedTextIndex = WolfMenuPalette.unselectedTextIndex;
for (int i = 0; i < _screen.length; i++) {
_screen[i] = bgColor;
}
_fillRect320(28, 70, 264, 82, panelColor);
final art = WolfClassicMenuArt(engine.data);
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
_drawMenuTextCentered('SELECT GAME', 48, headingIndex, scale: 2);
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 84;
const int rowStep = 18;
for (int i = 0; i < engine.availableGames.length; i++) {
final bool isSelected = i == engine.menuManager.selectedGameIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, 38, (rowYStart + (i * rowStep)) - 2);
}
_drawMenuText(
_gameTitle(engine.availableGames[i].version),
70,
rowYStart + (i * rowStep),
isSelected ? selectedTextIndex : unselectedTextIndex,
scale: 1,
);
}
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.episodeSelect) {
_fillRect320(12, 20, 296, 158, panelColor);
_drawMenuTextCentered(
'WHICH EPISODE TO PLAY?',
6,
headingIndex,
scale: 2,
);
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 30;
const int rowStep = 24;
for (int i = 0; i < engine.data.episodes.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected = i == engine.menuManager.selectedEpisodeIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, 16, y + 2);
}
final image = art.episodeOption(i);
if (image != null) {
_blitVgaImage(image, 40, y);
}
final parts = engine.data.episodes[i].name.split('\n');
if (parts.isNotEmpty) {
_drawMenuText(
parts.first,
98,
y + 1,
isSelected ? selectedTextIndex : unselectedTextIndex,
scale: 1,
);
}
if (parts.length > 1) {
_drawMenuText(
parts.sublist(1).join(' '),
98,
y + 12,
isSelected ? selectedTextIndex : unselectedTextIndex,
scale: 1,
);
}
}
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
if (_useCompactMenuLayout) {
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
_drawMenuTextCentered(
Difficulty.menuText,
48,
headingIndex,
scale: _menuHeadingScale,
);
final bottom = art.pic(15);
if (bottom != null) {
_blitVgaImage(bottom, (320 - bottom.width) ~/ 2, 200 - bottom.height - 8);
}
final face = art.difficultyOption(
Difficulty.values[selectedDifficultyIndex],
);
if (face != null) {
_blitVgaImage(face, 28 + 264 - face.width - 10, 92);
}
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const rowYStart = 86;
const rowStep = 15;
const textX = 70;
for (int i = 0; i < Difficulty.values.length; i++) {
final y = rowYStart + (i * rowStep);
final isSelected = i == selectedDifficultyIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, 38, y - 2);
}
_drawMenuText(
Difficulty.values[i].title,
textX,
y,
isSelected ? selectedTextIndex : unselectedTextIndex,
scale: _menuOptionScale,
);
}
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
}
void _drawMenuFooterArt(WolfClassicMenuArt art) {
final bottom = art.pic(15);
if (bottom == null) {
return;
}
_blitVgaImage(bottom, (320 - bottom.width) ~/ 2, 200 - bottom.height - 8);
}
String _gameTitle(GameVersion version) {
switch (version) {
case GameVersion.shareware:
return 'SHAREWARE';
case GameVersion.retail:
return 'RETAIL';
case GameVersion.spearOfDestiny:
return 'SPEAR OF DESTINY';
case GameVersion.spearOfDestinyDemo:
return 'SOD DEMO';
}
}
void _applyMenuFade(double alpha, int bgColor) {
if (alpha <= 0.0) {
return;
}
final int threshold = (alpha * 3).round().clamp(1, 3);
for (int y = 0; y < height; y++) {
final int rowOffset = y * width;
for (int x = 0; x < width; x++) {
if (((x + y) % 3) < threshold) {
_screen[rowOffset + x] = bgColor;
}
}
}
}
bool get _useCompactMenuLayout =>
width < _compactMenuMinWidthPx || height < _compactMenuMinHeightPx;
int get _menuHeadingScale => width < 250 ? 1 : 2;
int get _menuOptionScale => width < 220 ? 1 : 1;
/// Draws a compact fallback menu layout for narrow render targets.
void _drawCompactMenu(
int selectedDifficultyIndex,
int headingIndex,
int panelColor,
) {
_fillRect320(16, 52, 288, 112, panelColor);
_drawMenuTextCentered(Difficulty.menuText, 60, headingIndex, scale: 1);
const int rowYStart = 86;
const int rowStep = 13;
for (int i = 0; i < Difficulty.values.length; i++) {
final bool isSelected = i == selectedDifficultyIndex;
final String prefix = isSelected ? '> ' : ' ';
_drawMenuText(
prefix + Difficulty.values[i].title,
42,
rowYStart + (i * rowStep),
isSelected
? WolfMenuPalette.selectedTextIndex
: WolfMenuPalette.unselectedTextIndex,
scale: 1,
);
}
}
/// Draws bitmap menu text into the indexed Sixel back buffer.
void _drawMenuText(
String text,
int startX,
int startY,
int colorIndex, {
int scale = 1,
}) {
final double scaleX = width / 320.0;
final double scaleY = height / 200.0;
int x320 = startX;
for (final rune in text.runes) {
final char = String.fromCharCode(rune).toUpperCase();
final pattern = WolfMenuFont.glyphFor(char);
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 int px320 = x320 + (col * scale) + sx;
final int py200 = startY + (row * scale) + sy;
final int drawX = (px320 * scaleX).toInt();
final int drawY = (py200 * scaleY).toInt();
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
_screen[drawY * width + drawX] = colorIndex;
}
}
}
}
}
x320 += WolfMenuFont.glyphAdvance(char, scale);
}
}
/// Draws bitmap menu text centered in 320x200 menu space.
void _drawMenuTextCentered(
String text,
int y,
int colorIndex, {
int scale = 1,
}) {
final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
final int x = ((320 - textWidth) ~/ 2).clamp(0, 319);
_drawMenuText(text, x, y, colorIndex, scale: scale);
}
@override
String finalizeFrame() {
if (!isSixelSupported) {
return _renderNotSupportedMessage();
}
final String clearPrefix = _needsBackgroundClear
? '$_terminalTealBackground\x1b[2J\x1b[0m'
: '';
_needsBackgroundClear = false;
return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}';
}
// ===========================================================================
// UI FALLBACK
// ===========================================================================
/// Returns an ANSI fallback UI when Sixel capability is unavailable.
String _renderNotSupportedMessage() {
int cols = 80;
int rows = 24;
try {
if (stdout.hasTerminal) {
cols = stdout.terminalColumns;
rows = stdout.terminalLines;
}
} catch (_) {}
const String msg1 = "Terminal does not support Sixel.";
const String msg2 = "Press TAB to switch renderers.";
final int boxWidth = math.max(msg1.length, msg2.length) + 6;
const int boxHeight = 5;
final int startX = math.max(1, (cols - boxWidth) ~/ 2);
final int startY = math.max(1, (rows - boxHeight) ~/ 2);
final StringBuffer sb = StringBuffer();
if (_needsBackgroundClear) {
sb.write('\x1b[0m\x1b[2J');
_needsBackgroundClear = false;
}
String center(String text) {
final int padLeft = (boxWidth - 2 - text.length) ~/ 2;
final int padRight = boxWidth - 2 - text.length - padLeft;
return (' ' * padLeft) + text + (' ' * padRight);
}
// Draw centered box
sb.write('\x1b[$startY;${startX}H┌${'' * (boxWidth - 2)}');
sb.write('\x1b[${startY + 1};${startX}H│${center(msg1)}');
sb.write('\x1b[${startY + 2};${startX}H│${center("")}');
sb.write('\x1b[${startY + 3};${startX}H│${center(msg2)}');
sb.write('\x1b[${startY + 4};${startX}H└${'' * (boxWidth - 2)}');
// Park the cursor at the bottom out of the way
sb.write('\x1b[$rows;1H');
return sb.toString();
}
// ===========================================================================
// SIXEL ENCODER
// ===========================================================================
/// Encodes the current indexed frame buffer as a Sixel image stream.
String toSixelString() {
StringBuffer sb = StringBuffer();
sb.write('\x1bPq');
sb.write('"1;1;$_outputWidth;$_outputHeight');
double damageIntensity = engine.difficulty == null
? 0.0
: 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);
}
int sixelR = (r * 100) ~/ 255;
int sixelG = (g * 100) ~/ 255;
int sixelB = (b * 100) ~/ 255;
sb.write('#$i;2;$sixelR;$sixelG;$sixelB');
}
for (int band = 0; band < _outputHeight; band += 6) {
Map<int, Uint8List> colorMap = {};
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);
}
colorMap[colorIdx]![x] |= (1 << yOffset);
}
}
bool firstColor = true;
for (var entry in colorMap.entries) {
if (!firstColor) {
sb.write('\$');
}
firstColor = false;
sb.write('#${entry.key}');
Uint8List cols = entry.value;
int currentVal = -1;
int runLength = 0;
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('-');
}
}
sb.write('\x1b\\');
return sb.toString();
}
/// Samples the render buffer at output-space coordinates using nearest-neighbor
/// mapping from Sixel output pixels to source pixels.
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];
}
/// Emits run-length encoded Sixel data for one repeated column mask value.
void _writeSixelRle(StringBuffer sb, int value, int runLength) {
String char = String.fromCharCode(value + 63);
if (runLength > 3) {
sb.write('!$runLength$char');
} else {
sb.write(char * runLength);
}
}
// ===========================================================================
// PRIVATE HUD HELPERS
// ===========================================================================
/// Blits a VGA image into the Sixel back buffer with 320x200 scaling.
void _blitVgaImage(VgaImage image, int startX, int startY) {
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 colorByte = image.decodePixel(srcX, srcY);
if (colorByte != 255) {
_screen[drawY * width + drawX] = colorByte;
}
}
}
}
}
/// Fills a rectangle in 320x200 virtual space into the scaled Sixel buffer.
void _fillRect320(int startX, int startY, int w, int h, int colorIndex) {
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, (w * scaleX).toInt());
final int destHeight = math.max(1, (h * scaleY).toInt());
for (int dy = 0; dy < destHeight; dy++) {
for (int dx = 0; dx < destWidth; dx++) {
final int drawX = destStartX + dx;
final int drawY = destStartY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
_screen[drawY * width + drawX] = colorIndex;
}
}
}
}
/// Maps an RGB color to the nearest VGA palette index.
int _rgbToPaletteIndex(int rgb) {
return ColorPalette.findClosestPaletteIndex(rgb);
}
}
@@ -0,0 +1,549 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
import 'package:wolf_3d_dart/src/rendering/menu_font.dart';
import 'package:wolf_3d_dart/src/rendering/renderer_backend.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';
/// Pixel-accurate software renderer that writes directly into [FrameBuffer].
///
/// This is the canonical "modern framebuffer" implementation and serves as a
/// visual reference for terminal renderers.
class SoftwareRenderer extends RendererBackend<FrameBuffer> {
static const int _menuFooterY = 184;
static const int _menuFooterHeight = 12;
late FrameBuffer _buffer;
@override
void prepareFrame(WolfEngine engine) {
_buffer = engine.frameBuffer;
// 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++) {
int texY = wallTexY(y, columnHeight);
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++
) {
int texY = spriteTexY(y, drawStartY, spriteHeight);
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];
final bounds = weaponScreenBounds(engine);
final int weaponWidth = bounds.weaponWidth;
final int weaponHeight = bounds.weaponHeight;
final int startX = bounds.startX;
final int startY = bounds.startY;
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
/// Delegates the canonical Wolf3D VGA HUD draw sequence to the base class.
void drawHud(WolfEngine engine) {
drawStandardVgaHud(engine);
}
@override
/// Blits a VGA image into the software framebuffer HUD space (320x200).
void blitHudVgaImage(VgaImage image, int startX320, int startY200) {
_blitVgaImage(image, startX320, startY200);
}
@override
void drawMenu(WolfEngine engine) {
final int bgColor = _rgbToFrameColor(engine.menuBackgroundRgb);
final int panelColor = _rgbToFrameColor(engine.menuPanelRgb);
final int headingColor = WolfMenuPalette.headerTextColor;
final int selectedTextColor = WolfMenuPalette.selectedTextColor;
final int unselectedTextColor = WolfMenuPalette.unselectedTextColor;
for (int i = 0; i < _buffer.pixels.length; i++) {
_buffer.pixels[i] = bgColor;
}
final art = WolfClassicMenuArt(engine.data);
switch (engine.menuManager.activeMenu) {
case WolfMenuScreen.gameSelect:
_drawGameSelectMenu(
engine,
art,
panelColor,
headingColor,
selectedTextColor,
unselectedTextColor,
);
break;
case WolfMenuScreen.episodeSelect:
_drawEpisodeSelectMenu(
engine,
art,
panelColor,
headingColor,
selectedTextColor,
unselectedTextColor,
);
break;
case WolfMenuScreen.difficultySelect:
_drawDifficultyMenu(
engine,
art,
panelColor,
headingColor,
selectedTextColor,
unselectedTextColor,
);
break;
}
_drawCenteredMenuFooter(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
}
void _drawGameSelectMenu(
WolfEngine engine,
WolfClassicMenuArt art,
int panelColor,
int headingColor,
int selectedTextColor,
int unselectedTextColor,
) {
const int panelX = 28;
const int panelY = 58;
const int panelW = 264;
const int panelH = 104;
_fillMenuPanel(panelX, panelY, panelW, panelH, panelColor);
_drawMenuTextCentered('SELECT GAME', 38, headingColor, scale: 2);
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 78;
const int rowStep = 20;
const int textX = 70;
final int selectedIndex = engine.menuManager.selectedGameIndex;
for (int i = 0; i < engine.availableGames.length; i++) {
final bool isSelected = i == selectedIndex;
final int y = rowYStart + (i * rowStep);
if (isSelected && cursor != null) {
_blitVgaImage(cursor, panelX + 10, y - 2);
}
_drawMenuText(
_gameTitle(engine.availableGames[i].version),
textX,
y,
isSelected ? selectedTextColor : unselectedTextColor,
);
}
}
void _drawEpisodeSelectMenu(
WolfEngine engine,
WolfClassicMenuArt art,
int panelColor,
int headingColor,
int selectedTextColor,
int unselectedTextColor,
) {
const int panelX = 12;
const int panelY = 20;
const int panelW = 296;
const int panelH = 158;
_fillMenuPanel(panelX, panelY, panelW, panelH, panelColor);
_drawMenuTextCentered('WHICH EPISODE TO PLAY?', 6, headingColor, scale: 2);
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 30;
const int rowStep = 24;
const int imageX = 40;
const int textX = 98;
final int selectedIndex = engine.menuManager.selectedEpisodeIndex;
for (int i = 0; i < engine.data.episodes.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected = i == selectedIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, 16, y + 2);
}
final image = art.episodeOption(i);
if (image != null) {
_blitVgaImage(image, imageX, y);
}
final parts = engine.data.episodes[i].name.split('\n');
if (parts.isNotEmpty) {
_drawMenuText(
parts.first,
textX,
y + 1,
isSelected ? selectedTextColor : unselectedTextColor,
);
}
if (parts.length > 1) {
_drawMenuText(
parts.sublist(1).join(' '),
textX,
y + 12,
isSelected ? selectedTextColor : unselectedTextColor,
);
}
}
}
void _drawCenteredMenuFooter(WolfClassicMenuArt art) {
final bottom = art.pic(15);
if (bottom != null) {
final int x = ((width - bottom.width) ~/ 2).clamp(0, width - 1);
final int y = (height - bottom.height - 8).clamp(0, height - 1);
_blitVgaImage(bottom, x, y);
return;
}
final int hintKeyColor = ColorPalette.vga32Bit[12];
final int hintLabelColor = ColorPalette.vga32Bit[4];
final int hintBackground = ColorPalette.vga32Bit[0];
final List<(String text, int color)> segments = <(String, int)>[
('UP/DN', hintKeyColor),
(' MOVE ', hintLabelColor),
('RET', hintKeyColor),
(' SELECT ', hintLabelColor),
('ESC', hintKeyColor),
(' BACK', hintLabelColor),
];
int textWidth = 0;
for (final (text, _) in segments) {
textWidth += WolfMenuFont.measureTextWidth(text, 1);
}
final int panelWidth = (textWidth + 12).clamp(1, width);
final int panelX = ((width - panelWidth) ~/ 2).clamp(0, width - 1);
_fillMenuPanel(
panelX,
_menuFooterY,
panelWidth,
_menuFooterHeight,
hintBackground,
);
int cursorX = panelX + 6;
const int textY = _menuFooterY + 2;
for (final (text, color) in segments) {
_drawMenuText(text, cursorX, textY, color, scale: 1);
cursorX += WolfMenuFont.measureTextWidth(text, 1);
}
}
void _drawDifficultyMenu(
WolfEngine engine,
WolfClassicMenuArt art,
int panelColor,
int headingColor,
int selectedTextColor,
int unselectedTextColor,
) {
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
const int panelX = 28;
const int panelY = 70;
const int panelW = 264;
const int panelH = 82;
_fillMenuPanel(panelX, panelY, panelW, panelH, panelColor);
_drawMenuTextCentered(Difficulty.menuText, 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[selectedDifficultyIndex],
);
if (face != null) {
_blitVgaImage(face, panelX + panelW - face.width - 10, panelY + 22);
}
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = panelY + 16;
const int rowStep = 15;
const int textX = panelX + 42;
for (int i = 0; i < Difficulty.values.length; i++) {
final y = rowYStart + (i * rowStep);
final isSelected = i == selectedDifficultyIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, panelX + 10, y - 2);
}
_drawMenuText(
Difficulty.values[i].title,
textX,
y,
isSelected ? selectedTextColor : unselectedTextColor,
);
}
}
void _fillMenuPanel(
int panelX,
int panelY,
int panelW,
int panelH,
int color,
) {
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] = color;
}
}
}
}
String _gameTitle(GameVersion version) {
switch (version) {
case GameVersion.shareware:
return 'SHAREWARE';
case GameVersion.retail:
return 'RETAIL';
case GameVersion.spearOfDestiny:
return 'SPEAR OF DESTINY';
case GameVersion.spearOfDestinyDemo:
return 'SOD DEMO';
}
}
void _applyMenuFade(double alpha, int fadeColor) {
if (alpha <= 0.0) {
return;
}
final int fadeR = fadeColor & 0xFF;
final int fadeG = (fadeColor >> 8) & 0xFF;
final int fadeB = (fadeColor >> 16) & 0xFF;
for (int i = 0; i < _buffer.pixels.length; i++) {
final int c = _buffer.pixels[i];
final int r = c & 0xFF;
final int g = (c >> 8) & 0xFF;
final int b = (c >> 16) & 0xFF;
final int outR = (r + ((fadeR - r) * alpha)).round().clamp(0, 255);
final int outG = (g + ((fadeG - g) * alpha)).round().clamp(0, 255);
final int outB = (b + ((fadeB - b) * alpha)).round().clamp(0, 255);
_buffer.pixels[i] = (0xFF << 24) | (outB << 16) | (outG << 8) | outR;
}
}
/// Converts an `RRGGBB` menu color into the framebuffer's packed channel
/// order (`0xAABBGGRR`) used throughout this renderer.
int _rgbToFrameColor(int rgb) {
final int r = (rgb >> 16) & 0xFF;
final int g = (rgb >> 8) & 0xFF;
final int b = rgb & 0xFF;
// Framebuffer expects bytes in RGBA order; this packed int produces that
// layout on little-endian platforms.
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
/// Draws bitmap menu text directly into the framebuffer.
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 = WolfMenuFont.glyphFor(char);
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 += WolfMenuFont.glyphAdvance(char, scale);
}
}
/// Draws bitmap menu text centered in the current framebuffer width.
void _drawMenuTextCentered(
String text,
int y,
int color, {
int scale = 1,
}) {
final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
final int 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.difficulty != null && engine.player.damageFlash > 0) {
_applyDamageFlash();
}
return _buffer; // Return the fully painted pixel array
}
// ===========================================================================
// PRIVATE HELPER METHODS
// ===========================================================================
/// Maps planar VGA image data directly to 32-bit framebuffer pixels.
///
/// This renderer assumes a 1:1 mapping with the canonical 320x200 layout.
void _blitVgaImage(VgaImage image, int startX, int startY) {
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 colorByte = image.decodePixel(srcX, srcY);
if (colorByte != 255) {
_buffer.pixels[drawY * width + drawX] =
ColorPalette.vga32Bit[colorByte];
}
}
}
}
}
/// Tints the 3D view 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;
}
}
}
}