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:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user