Added ability to swap between ASCII and sixel renderers when pressing tab

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-18 01:02:46 +01:00
parent 58838a1baa
commit 309bf5c699
5 changed files with 194 additions and 36 deletions

View File

@@ -36,12 +36,9 @@ void main() async {
recursive: true, recursive: true,
); );
final data = availableGames.values.first; final AsciiRasterizer asciiRasterizer = AsciiRasterizer(isTerminal: true);
final SixelRasterizer sixelRasterizer = SixelRasterizer();
final input = CliInput(); Rasterizer rasterizer = sixelRasterizer;
final cliAudio = CliSilentAudio();
final rasterizer = AsciiRasterizer(isTerminal: true);
FrameBuffer buffer = FrameBuffer( FrameBuffer buffer = FrameBuffer(
stdout.terminalColumns, stdout.terminalColumns,
@@ -49,11 +46,11 @@ void main() async {
); );
final engine = WolfEngine( final engine = WolfEngine(
data: data, data: availableGames.values.first,
difficulty: Difficulty.medium, difficulty: Difficulty.medium,
startingEpisode: 0, startingEpisode: 0,
audio: cliAudio, audio: CliSilentAudio(),
input: input, input: CliInput(),
onGameWon: () { onGameWon: () {
exitCleanly(0); exitCleanly(0);
print("YOU WON!"); print("YOU WON!");
@@ -67,7 +64,15 @@ void main() async {
exitCleanly(0); 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(); Stopwatch stopwatch = Stopwatch()..start();
@@ -78,13 +83,11 @@ void main() async {
if (stdout.hasTerminal) { if (stdout.hasTerminal) {
int cols = stdout.terminalColumns; int cols = stdout.terminalColumns;
int rows = stdout.terminalLines; 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 // Clear the screen and print the warning at the top left
stdout.write('\x1b[2J\x1b[H'); stdout.write('\x1b[2J\x1b[H');
stdout.write('\x1b[31m[ ERROR ] TERMINAL TOO SMALL\x1b[0m\n\n'); stdout.write('\x1b[31m[ ERROR ] TERMINAL TOO SMALL\x1b[0m\n\n');
stdout.write( stdout.write('${rasterizer.terminalSizeRequirement}\n');
'Wolfenstein 3D requires a minimum resolution of 120x40.\n',
);
stdout.write( stdout.write(
'Current size: \x1b[33m${stdout.terminalColumns}x${stdout.terminalLines}\x1b[0m\n\n', 'Current size: \x1b[33m${stdout.terminalColumns}x${stdout.terminalLines}\x1b[0m\n\n',
); );
@@ -110,8 +113,7 @@ void main() async {
engine.tick(elapsed); engine.tick(elapsed);
rasterizer.render(engine, buffer); rasterizer.render(engine, buffer);
rasterizer.finalizeFrame();
stdout.write(rasterizer.toAnsiString()); stdout.write(rasterizer.finalizeFrame());
}); });
} }

View File

@@ -261,4 +261,32 @@ abstract class ColorPalette {
0xFF6D6D00, 0xFF6D6D00,
0xFF890099, 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;
}
} }

View File

@@ -62,6 +62,8 @@ class ColoredChar {
class AsciiRasterizer extends Rasterizer { class AsciiRasterizer extends Rasterizer {
static const double _targetAspectRatio = 4 / 3; static const double _targetAspectRatio = 4 / 3;
static const int _terminalBackdropArgb = 0xFF009688; static const int _terminalBackdropArgb = 0xFF009688;
static const int _minimumTerminalColumns = 80;
static const int _minimumTerminalRows = 24;
static const int _simpleHudMinWidth = 84; static const int _simpleHudMinWidth = 84;
static const int _simpleHudMinRows = 7; static const int _simpleHudMinRows = 7;
@@ -98,6 +100,19 @@ class AsciiRasterizer extends Rasterizer {
@override @override
int get projectionViewHeight => isTerminal ? viewHeight * 2 : viewHeight; 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 _terminalPixelHeight => isTerminal ? height * 2 : height;
int get _viewportRightX => projectionOffsetX + projectionWidth; int get _viewportRightX => projectionOffsetX + projectionWidth;
@@ -615,7 +630,7 @@ class AsciiRasterizer extends Rasterizer {
} }
@override @override
dynamic finalizeFrame() { String finalizeFrame() {
if (_engine.player.damageFlash > 0.0) { if (_engine.player.damageFlash > 0.0) {
if (isTerminal) { if (isTerminal) {
_applyDamageFlashToScene(); _applyDamageFlashToScene();
@@ -626,7 +641,7 @@ class AsciiRasterizer extends Rasterizer {
if (isTerminal) { if (isTerminal) {
_composeTerminalScene(); _composeTerminalScene();
} }
return _screen; return toAnsiString();
} }
// --- PRIVATE HUD DRAWING HELPERS --- // --- PRIVATE HUD DRAWING HELPERS ---

View File

@@ -31,6 +31,13 @@ abstract class Rasterizer {
/// more vertical detail and collapse it into half-block glyphs. /// more vertical detail and collapse it into half-block glyphs.
int get projectionViewHeight => viewHeight; 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. /// The main entry point called by the game loop.
/// Orchestrates the mathematical rendering pipeline. /// Orchestrates the mathematical rendering pipeline.
dynamic render(WolfEngine engine, FrameBuffer buffer) { dynamic render(WolfEngine engine, FrameBuffer buffer) {

View File

@@ -5,15 +5,92 @@ 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_engine.dart';
class SixelRasterizer extends Rasterizer { 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 Uint8List _screen;
late WolfEngine _engine; 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 @override
dynamic render(WolfEngine engine, FrameBuffer buffer) { dynamic render(WolfEngine engine, FrameBuffer buffer) {
_engine = engine; _engine = engine;
final FrameBuffer scaledBuffer = _createScaledBuffer(buffer);
// We only need 8-bit indices for the 256 VGA colors // We only need 8-bit indices for the 256 VGA colors
_screen = Uint8List(buffer.width * buffer.height); _screen = Uint8List(scaledBuffer.width * scaledBuffer.height);
return super.render(engine, buffer); return super.render(engine, scaledBuffer);
} }
@override @override
@@ -129,8 +206,12 @@ class SixelRasterizer extends Rasterizer {
} }
@override @override
dynamic finalizeFrame() { String finalizeFrame() {
return toSixelString(); 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'); sb.write('#$i;2;$sixelR;$sixelG;$sixelB');
} }
// 2. Encode Image in 6-pixel vertical bands // 2. Encode scaled image in 6-pixel vertical bands.
for (int band = 0; band < height; band += 6) { for (int band = 0; band < _outputHeight; band += 6) {
Map<int, Uint8List> colorMap = {}; Map<int, Uint8List> colorMap = {};
// Map out which pixels use which color in this 6px high band // 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++) { for (int yOffset = 0; yOffset < 6; yOffset++) {
int y = band + 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)) { if (!colorMap.containsKey(colorIdx)) {
colorMap[colorIdx] = Uint8List(width); colorMap[colorIdx] = Uint8List(_outputWidth);
} }
// Set the bit corresponding to the vertical position (0-5) // Set the bit corresponding to the vertical position (0-5)
colorMap[colorIdx]![x] |= (1 << yOffset); colorMap[colorIdx]![x] |= (1 << yOffset);
@@ -205,7 +286,7 @@ class SixelRasterizer extends Rasterizer {
int runLength = 0; int runLength = 0;
// Run-Length Encoding (RLE) loop // Run-Length Encoding (RLE) loop
for (int x = 0; x < width; x++) { for (int x = 0; x < _outputWidth; x++) {
int val = cols[x]; int val = cols[x];
if (val == currentVal) { if (val == currentVal) {
runLength++; runLength++;
@@ -218,7 +299,9 @@ class SixelRasterizer extends Rasterizer {
if (runLength > 0) _writeSixelRle(sb, currentVal, runLength); 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 // End Sixel sequence
@@ -226,6 +309,22 @@ class SixelRasterizer extends Rasterizer {
return sb.toString(); 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) { void _writeSixelRle(StringBuffer sb, int value, int runLength) {
String char = String.fromCharCode(value + 63); String char = String.fromCharCode(value + 63);
// Sixel RLE format: !<count><char> (only worth it if count > 3) // Sixel RLE format: !<count><char> (only worth it if count > 3)
@@ -243,15 +342,22 @@ class SixelRasterizer extends Rasterizer {
void _blitVgaImage(VgaImage image, int startX, int startY) { void _blitVgaImage(VgaImage image, int startX, int startY) {
int planeWidth = image.width ~/ 4; int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height; 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++) { final int destStartX = (startX * scaleX).toInt();
for (int dx = 0; dx < image.width; dx++) { final int destStartY = (startY * scaleY).toInt();
int drawX = startX + dx; final int destWidth = math.max(1, (image.width * scaleX).toInt());
int drawY = startY + dy; 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) { if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
int srcX = dx.clamp(0, image.width - 1); int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1);
int srcY = dy.clamp(0, image.height - 1); int srcY = (dy / scaleY).toInt().clamp(0, image.height - 1);
int plane = srcX % 4; int plane = srcX % 4;
int sx = srcX ~/ 4; int sx = srcX ~/ 4;