From 309bf5c699ab0a652c58018bed201a051f8925f4 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 18 Mar 2026 01:02:46 +0100 Subject: [PATCH] Added ability to swap between ASCII and sixel renderers when pressing tab Signed-off-by: Hans Kokx --- apps/wolf_3d_cli/bin/main.dart | 34 +++-- .../lib/src/data_types/color_palette.dart | 28 ++++ .../engine/rasterizer/ascii_rasterizer.dart | 19 ++- .../lib/src/engine/rasterizer/rasterizer.dart | 7 + .../engine/rasterizer/sixel_rasterizer.dart | 142 +++++++++++++++--- 5 files changed, 194 insertions(+), 36 deletions(-) diff --git a/apps/wolf_3d_cli/bin/main.dart b/apps/wolf_3d_cli/bin/main.dart index c09ddc1..33eab19 100644 --- a/apps/wolf_3d_cli/bin/main.dart +++ b/apps/wolf_3d_cli/bin/main.dart @@ -36,12 +36,9 @@ void main() async { recursive: true, ); - final data = availableGames.values.first; - - final input = CliInput(); - final cliAudio = CliSilentAudio(); - - final rasterizer = AsciiRasterizer(isTerminal: true); + final AsciiRasterizer asciiRasterizer = AsciiRasterizer(isTerminal: true); + final SixelRasterizer sixelRasterizer = SixelRasterizer(); + Rasterizer rasterizer = sixelRasterizer; FrameBuffer buffer = FrameBuffer( stdout.terminalColumns, @@ -49,11 +46,11 @@ void main() async { ); final engine = WolfEngine( - data: data, + data: availableGames.values.first, difficulty: Difficulty.medium, startingEpisode: 0, - audio: cliAudio, - input: input, + audio: CliSilentAudio(), + input: CliInput(), onGameWon: () { exitCleanly(0); print("YOU WON!"); @@ -67,7 +64,15 @@ void main() async { exitCleanly(0); } - input.handleKey(bytes); + if (bytes.contains(9)) { + rasterizer = identical(rasterizer, sixelRasterizer) + ? asciiRasterizer + : sixelRasterizer; + stdout.write('\x1b[2J\x1b[H'); + return; + } + + (engine.input as CliInput).handleKey(bytes); }); Stopwatch stopwatch = Stopwatch()..start(); @@ -78,13 +83,11 @@ void main() async { if (stdout.hasTerminal) { int cols = stdout.terminalColumns; int rows = stdout.terminalLines; - if (cols < 80 || rows < 24) { + if (!rasterizer.isTerminalSizeSupported(cols, rows)) { // Clear the screen and print the warning at the top left stdout.write('\x1b[2J\x1b[H'); stdout.write('\x1b[31m[ ERROR ] TERMINAL TOO SMALL\x1b[0m\n\n'); - stdout.write( - 'Wolfenstein 3D requires a minimum resolution of 120x40.\n', - ); + stdout.write('${rasterizer.terminalSizeRequirement}\n'); stdout.write( 'Current size: \x1b[33m${stdout.terminalColumns}x${stdout.terminalLines}\x1b[0m\n\n', ); @@ -110,8 +113,7 @@ void main() async { engine.tick(elapsed); rasterizer.render(engine, buffer); - rasterizer.finalizeFrame(); - stdout.write(rasterizer.toAnsiString()); + stdout.write(rasterizer.finalizeFrame()); }); } diff --git a/packages/wolf_3d_dart/lib/src/data_types/color_palette.dart b/packages/wolf_3d_dart/lib/src/data_types/color_palette.dart index be08a71..2dc5a4e 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/color_palette.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/color_palette.dart @@ -261,4 +261,32 @@ abstract class ColorPalette { 0xFF6D6D00, 0xFF890099, ]); + + static int findClosestPaletteIndex(int argb) { + final int targetR = (argb >> 16) & 0xFF; + final int targetG = (argb >> 8) & 0xFF; + final int targetB = argb & 0xFF; + + int bestIndex = 0; + int bestDistance = 1 << 30; + + for (int i = 0; i < 256; i++) { + final int color = vga32Bit[i]; + final int r = color & 0xFF; + final int g = (color >> 8) & 0xFF; + final int b = (color >> 16) & 0xFF; + + final int dr = r - targetR; + final int dg = g - targetG; + final int db = b - targetB; + final int distance = dr * dr + dg * dg + db * db; + + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = i; + } + } + + return bestIndex; + } } diff --git a/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart b/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart index 0627619..2942f5d 100644 --- a/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart @@ -62,6 +62,8 @@ class ColoredChar { class AsciiRasterizer extends Rasterizer { static const double _targetAspectRatio = 4 / 3; static const int _terminalBackdropArgb = 0xFF009688; + static const int _minimumTerminalColumns = 80; + static const int _minimumTerminalRows = 24; static const int _simpleHudMinWidth = 84; static const int _simpleHudMinRows = 7; @@ -98,6 +100,19 @@ class AsciiRasterizer extends Rasterizer { @override int get projectionViewHeight => isTerminal ? viewHeight * 2 : viewHeight; + @override + bool isTerminalSizeSupported(int columns, int rows) { + if (!isTerminal) { + return true; + } + return columns >= _minimumTerminalColumns && rows >= _minimumTerminalRows; + } + + @override + String get terminalSizeRequirement => + 'ASCII renderer requires a minimum resolution of ' + '${_minimumTerminalColumns}x$_minimumTerminalRows.'; + int get _terminalPixelHeight => isTerminal ? height * 2 : height; int get _viewportRightX => projectionOffsetX + projectionWidth; @@ -615,7 +630,7 @@ class AsciiRasterizer extends Rasterizer { } @override - dynamic finalizeFrame() { + String finalizeFrame() { if (_engine.player.damageFlash > 0.0) { if (isTerminal) { _applyDamageFlashToScene(); @@ -626,7 +641,7 @@ class AsciiRasterizer extends Rasterizer { if (isTerminal) { _composeTerminalScene(); } - return _screen; + return toAnsiString(); } // --- PRIVATE HUD DRAWING HELPERS --- diff --git a/packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart b/packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart index ce5df71..250a87c 100644 --- a/packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart @@ -31,6 +31,13 @@ abstract class Rasterizer { /// more vertical detail and collapse it into half-block glyphs. int get projectionViewHeight => viewHeight; + /// Whether the current terminal dimensions are supported by this renderer. + /// Default renderers accept all sizes. + bool isTerminalSizeSupported(int columns, int rows) => true; + + /// Human-readable requirement text used by the host app when size checks fail. + String get terminalSizeRequirement => 'Please resize your terminal window.'; + /// The main entry point called by the game loop. /// Orchestrates the mathematical rendering pipeline. dynamic render(WolfEngine engine, FrameBuffer buffer) { diff --git a/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart b/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart index 3d6e549..92a4236 100644 --- a/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart @@ -5,15 +5,92 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; class SixelRasterizer extends Rasterizer { + static const double _targetAspectRatio = 4 / 3; + static const int _defaultLineHeightPx = 18; + static const double _defaultCellWidthToHeight = 0.55; + static const int _minimumTerminalColumns = 117; + static const int _minimumTerminalRows = 34; + static const int _maxRenderWidth = 320; + static const int _maxRenderHeight = 240; + static const String _terminalTealBackground = '\x1b[48;2;0;150;136m'; + late Uint8List _screen; late WolfEngine _engine; + int _offsetColumns = 0; + int _offsetRows = 0; + int _outputWidth = 1; + int _outputHeight = 1; + bool _needsBackgroundClear = true; + + FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) { + final int previousOffsetColumns = _offsetColumns; + final int previousOffsetRows = _offsetRows; + final int previousOutputWidth = _outputWidth; + final int previousOutputHeight = _outputHeight; + + final double fitScale = math.min( + terminalBuffer.width / _minimumTerminalColumns, + terminalBuffer.height / _minimumTerminalRows, + ); + + final int targetColumns = math.max( + 1, + (_minimumTerminalColumns * fitScale).floor(), + ); + final int targetRows = math.max( + 1, + (_minimumTerminalRows * fitScale).floor(), + ); + + _offsetColumns = math.max(0, (terminalBuffer.width - targetColumns) ~/ 2); + _offsetRows = math.max(0, (terminalBuffer.height - targetRows) ~/ 2); + + final int boundsPixelWidth = math.max( + 1, + (targetColumns * _defaultLineHeightPx * _defaultCellWidthToHeight) + .floor(), + ); + final int boundsPixelHeight = math.max( + 1, + targetRows * _defaultLineHeightPx, + ); + + final double boundsAspect = boundsPixelWidth / boundsPixelHeight; + if (boundsAspect > _targetAspectRatio) { + _outputHeight = boundsPixelHeight; + _outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor()); + } else { + _outputWidth = boundsPixelWidth; + _outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor()); + } + + if (_offsetColumns != previousOffsetColumns || + _offsetRows != previousOffsetRows || + _outputWidth != previousOutputWidth || + _outputHeight != previousOutputHeight) { + _needsBackgroundClear = true; + } + + final double renderScale = math.min( + 1.0, + math.min( + _maxRenderWidth / _outputWidth, + _maxRenderHeight / _outputHeight, + ), + ); + final int renderWidth = math.max(1, (_outputWidth * renderScale).floor()); + final int renderHeight = math.max(1, (_outputHeight * renderScale).floor()); + + return FrameBuffer(renderWidth, renderHeight); + } @override dynamic render(WolfEngine engine, FrameBuffer buffer) { _engine = engine; + final FrameBuffer scaledBuffer = _createScaledBuffer(buffer); // We only need 8-bit indices for the 256 VGA colors - _screen = Uint8List(buffer.width * buffer.height); - return super.render(engine, buffer); + _screen = Uint8List(scaledBuffer.width * scaledBuffer.height); + return super.render(engine, scaledBuffer); } @override @@ -129,8 +206,12 @@ class SixelRasterizer extends Rasterizer { } @override - dynamic finalizeFrame() { - return toSixelString(); + String finalizeFrame() { + final String clearPrefix = _needsBackgroundClear + ? '$_terminalTealBackground\x1b[2J\x1b[0m' + : ''; + _needsBackgroundClear = false; + return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}'; } // =========================================================================== @@ -169,19 +250,19 @@ class SixelRasterizer extends Rasterizer { sb.write('#$i;2;$sixelR;$sixelG;$sixelB'); } - // 2. Encode Image in 6-pixel vertical bands - for (int band = 0; band < height; band += 6) { + // 2. Encode scaled image in 6-pixel vertical bands. + for (int band = 0; band < _outputHeight; band += 6) { Map colorMap = {}; // Map out which pixels use which color in this 6px high band - for (int x = 0; x < width; x++) { + for (int x = 0; x < _outputWidth; x++) { for (int yOffset = 0; yOffset < 6; yOffset++) { int y = band + yOffset; - if (y >= height) break; + if (y >= _outputHeight) break; - int colorIdx = _screen[y * width + x]; + int colorIdx = _sampleScaledPixel(x, y); if (!colorMap.containsKey(colorIdx)) { - colorMap[colorIdx] = Uint8List(width); + colorMap[colorIdx] = Uint8List(_outputWidth); } // Set the bit corresponding to the vertical position (0-5) colorMap[colorIdx]![x] |= (1 << yOffset); @@ -205,7 +286,7 @@ class SixelRasterizer extends Rasterizer { int runLength = 0; // Run-Length Encoding (RLE) loop - for (int x = 0; x < width; x++) { + for (int x = 0; x < _outputWidth; x++) { int val = cols[x]; if (val == currentVal) { runLength++; @@ -218,7 +299,9 @@ class SixelRasterizer extends Rasterizer { if (runLength > 0) _writeSixelRle(sb, currentVal, runLength); } - sb.write('-'); // Move down to the next 6-pixel band + if (band + 6 < _outputHeight) { + sb.write('-'); + } } // End Sixel sequence @@ -226,6 +309,22 @@ class SixelRasterizer extends Rasterizer { return sb.toString(); } + int _sampleScaledPixel(int outX, int outY) { + final int srcX = ((((outX + 0.5) * width) / _outputWidth) - 0.5) + .round() + .clamp( + 0, + width - 1, + ); + final int srcY = ((((outY + 0.5) * height) / _outputHeight) - 0.5) + .round() + .clamp( + 0, + height - 1, + ); + return _screen[srcY * width + srcX]; + } + void _writeSixelRle(StringBuffer sb, int value, int runLength) { String char = String.fromCharCode(value + 63); // Sixel RLE format: ! (only worth it if count > 3) @@ -243,15 +342,22 @@ class SixelRasterizer extends Rasterizer { void _blitVgaImage(VgaImage image, int startX, int startY) { int planeWidth = image.width ~/ 4; int planeSize = planeWidth * image.height; + final double scaleX = width / 320.0; + final double scaleY = height / 200.0; - for (int dy = 0; dy < image.height; dy++) { - for (int dx = 0; dx < image.width; dx++) { - int drawX = startX + dx; - int drawY = startY + dy; + 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.clamp(0, image.width - 1); - int srcY = dy.clamp(0, image.height - 1); + int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1); + int srcY = (dy / scaleY).toInt().clamp(0, image.height - 1); int plane = srcX % 4; int sx = srcX ~/ 4;