Refactor ASCII rasterizer to support terminal ANSI mode and improve menu text rendering

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-18 16:06:20 +01:00
parent 839fae700f
commit be03bd45c8
8 changed files with 204 additions and 88 deletions

View File

@@ -25,7 +25,9 @@ class CliGameLoop {
'CliGameLoop requires a CliInput instance.', 'CliGameLoop requires a CliInput instance.',
), ),
primaryRasterizer = AsciiRasterizer(isTerminal: true), primaryRasterizer = AsciiRasterizer(
mode: AsciiRasterizerMode.terminalAnsi,
),
secondaryRasterizer = SixelRasterizer() { secondaryRasterizer = SixelRasterizer() {
_rasterizer = primaryRasterizer; _rasterizer = primaryRasterizer;
} }

View File

@@ -61,6 +61,21 @@ class ColoredChar {
// Outputs standard AARRGGBB for Flutter's Color(int) constructor // Outputs standard AARRGGBB for Flutter's Color(int) constructor
int get argb => (0xFF000000) | (r << 16) | (g << 8) | b; int get argb => (0xFF000000) | (r << 16) | (g << 8) | b;
// Same AABBGGRR → AARRGGBB conversion for the background channel.
int? get backgroundArgb {
final bg = rawBackgroundColor;
if (bg == null) return null;
final int bgR = bg & 0xFF;
final int bgG = (bg >> 8) & 0xFF;
final int bgB = (bg >> 16) & 0xFF;
return 0xFF000000 | (bgR << 16) | (bgG << 8) | bgB;
}
}
enum AsciiRasterizerMode {
terminalAnsi,
terminalGrid,
} }
class AsciiRasterizer extends CliRasterizer<dynamic> { class AsciiRasterizer extends CliRasterizer<dynamic> {
@@ -71,21 +86,23 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
static const int _simpleHudMinWidth = 84; static const int _simpleHudMinWidth = 84;
static const int _simpleHudMinRows = 7; static const int _simpleHudMinRows = 7;
static const int _menuSelectedTextPaletteIndex = 19;
static const int _menuUnselectedTextPaletteIndex = 23;
static const int _menuHintKeyPaletteIndex = 12; static const int _menuHintKeyPaletteIndex = 12;
static const int _menuHintLabelPaletteIndex = 4; static const int _menuHintLabelPaletteIndex = 4;
static const int _menuHintBackgroundPaletteIndex = 0; static const int _menuHintBackgroundPaletteIndex = 0;
AsciiRasterizer({ AsciiRasterizer({
this.activeTheme = AsciiThemes.blocks, this.activeTheme = AsciiThemes.blocks,
this.isTerminal = false, this.mode = AsciiRasterizerMode.terminalGrid,
this.aspectMultiplier = 1.0, this.aspectMultiplier = 1.0,
this.verticalStretch = 1.0, this.verticalStretch = 1.0,
}); });
AsciiTheme activeTheme = AsciiThemes.blocks; AsciiTheme activeTheme = AsciiThemes.blocks;
final bool isTerminal; final AsciiRasterizerMode mode;
bool get _usesTerminalLayout => true;
bool get _emitAnsi => mode == AsciiRasterizerMode.terminalAnsi;
late List<List<ColoredChar>> _screen; late List<List<ColoredChar>> _screen;
late List<List<int>> _scenePixels; late List<List<int>> _scenePixels;
@@ -97,7 +114,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
final double verticalStretch; final double verticalStretch;
@override @override
int get projectionWidth => isTerminal int get projectionWidth => _usesTerminalLayout
? math.max( ? math.max(
1, 1,
math.min(width, (_terminalPixelHeight * _targetAspectRatio).floor()), math.min(width, (_terminalPixelHeight * _targetAspectRatio).floor()),
@@ -105,14 +122,16 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
: width; : width;
@override @override
int get projectionOffsetX => isTerminal ? (width - projectionWidth) ~/ 2 : 0; int get projectionOffsetX =>
_usesTerminalLayout ? (width - projectionWidth) ~/ 2 : 0;
@override @override
int get projectionViewHeight => isTerminal ? viewHeight * 2 : viewHeight; int get projectionViewHeight =>
_usesTerminalLayout ? viewHeight * 2 : viewHeight;
@override @override
bool isTerminalSizeSupported(int columns, int rows) { bool isTerminalSizeSupported(int columns, int rows) {
if (!isTerminal) { if (!_usesTerminalLayout) {
return true; return true;
} }
return columns >= _minimumTerminalColumns && rows >= _minimumTerminalRows; return columns >= _minimumTerminalColumns && rows >= _minimumTerminalRows;
@@ -123,7 +142,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
'ASCII renderer requires a minimum resolution of ' 'ASCII renderer requires a minimum resolution of '
'${_minimumTerminalColumns}x$_minimumTerminalRows.'; '${_minimumTerminalColumns}x$_minimumTerminalRows.';
int get _terminalPixelHeight => isTerminal ? height * 2 : height; int get _terminalPixelHeight => _usesTerminalLayout ? height * 2 : height;
int get _viewportRightX => projectionOffsetX + projectionWidth; int get _viewportRightX => projectionOffsetX + projectionWidth;
@@ -149,7 +168,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
// Just grab the raw ints! // Just grab the raw ints!
final int ceilingColor = ColorPalette.vga32Bit[25]; final int ceilingColor = ColorPalette.vga32Bit[25];
final int floorColor = ColorPalette.vga32Bit[29]; final int floorColor = ColorPalette.vga32Bit[29];
final int backdropColor = isTerminal final int backdropColor = _usesTerminalLayout
? _terminalBackdropColor ? _terminalBackdropColor
: ColorPalette.vga32Bit[0]; : ColorPalette.vga32Bit[0];
@@ -167,7 +186,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
} }
} }
if (!isTerminal) { if (!_usesTerminalLayout) {
for (int y = 0; y < height; y++) { for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) { for (int x = 0; x < width; x++) {
if (y < viewHeight / 2) { if (y < viewHeight / 2) {
@@ -206,7 +225,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
pixelColor = shadeColor(pixelColor); pixelColor = shadeColor(pixelColor);
} }
if (isTerminal) { if (_usesTerminalLayout) {
_scenePixels[y][x] = _scaleColor(pixelColor, brightness); _scenePixels[y][x] = _scaleColor(pixelColor, brightness);
} else { } else {
final String wallChar = activeTheme.getByBrightness(brightness); final String wallChar = activeTheme.getByBrightness(brightness);
@@ -250,7 +269,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
int shadedColor = (0xFF000000) | (b << 16) | (g << 8) | r; int shadedColor = (0xFF000000) | (b << 16) | (g << 8) | r;
if (isTerminal) { if (_usesTerminalLayout) {
_scenePixels[y][stripeX] = shadedColor; _scenePixels[y][stripeX] = shadedColor;
} else { } else {
_screen[y][stripeX] = ColoredChar(activeTheme.solid, shadedColor); _screen[y][stripeX] = ColoredChar(activeTheme.solid, shadedColor);
@@ -274,7 +293,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
int startY = int startY =
projectionViewHeight - projectionViewHeight -
weaponHeight + weaponHeight +
(engine.player.weaponAnimOffset * (isTerminal ? 2 : 1) ~/ 4); (engine.player.weaponAnimOffset * (_usesTerminalLayout ? 2 : 1) ~/ 4);
for (int dy = 0; dy < weaponHeight; dy++) { for (int dy = 0; dy < weaponHeight; dy++) {
for (int dx = 0; dx < weaponWidth; dx++) { for (int dx = 0; dx < weaponWidth; dx++) {
@@ -288,9 +307,9 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
if (sceneX >= projectionOffsetX && if (sceneX >= projectionOffsetX &&
sceneX < _viewportRightX && sceneX < _viewportRightX &&
drawY >= 0) { drawY >= 0) {
if (isTerminal && drawY < projectionViewHeight) { if (_usesTerminalLayout && drawY < projectionViewHeight) {
_scenePixels[drawY][sceneX] = ColorPalette.vga32Bit[colorByte]; _scenePixels[drawY][sceneX] = ColorPalette.vga32Bit[colorByte];
} else if (!isTerminal && drawY < viewHeight) { } else if (!_usesTerminalLayout && drawY < viewHeight) {
_screen[drawY][sceneX] = ColoredChar( _screen[drawY][sceneX] = ColoredChar(
activeTheme.solid, activeTheme.solid,
ColorPalette.vga32Bit[colorByte], ColorPalette.vga32Bit[colorByte],
@@ -324,7 +343,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
void drawHud(WolfEngine engine) { void drawHud(WolfEngine engine) {
// If the terminal is at least 160 columns wide and 50 rows tall, // If the terminal is at least 160 columns wide and 50 rows tall,
// there are enough "pixels" to downscale the VGA image clearly. // there are enough "pixels" to downscale the VGA image clearly.
int hudWidth = isTerminal ? projectionWidth : width; int hudWidth = _usesTerminalLayout ? projectionWidth : width;
if (hudWidth >= 160 && height >= 50) { if (hudWidth >= 160 && height >= 50) {
_drawFullVgaHud(engine); _drawFullVgaHud(engine);
} else { } else {
@@ -338,14 +357,11 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
engine.menuManager.selectedDifficultyIndex; engine.menuManager.selectedDifficultyIndex;
final int bgColor = _rgbToPaletteColor(engine.menuBackgroundRgb); final int bgColor = _rgbToPaletteColor(engine.menuBackgroundRgb);
final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb); final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb);
// Exact title tint requested for menu heading text (#fff700). final int headingColor = WolfMenuPalette.headerTextColor;
final int headingColor = _rgbToRawColor(0xFFF700); final int selectedTextColor = WolfMenuPalette.selectedTextColor;
final int selectedTextColor = final int unselectedTextColor = WolfMenuPalette.unselectedTextColor;
ColorPalette.vga32Bit[_menuSelectedTextPaletteIndex];
final int unselectedTextColor =
ColorPalette.vga32Bit[_menuUnselectedTextPaletteIndex];
if (isTerminal) { if (_usesTerminalLayout) {
_fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor); _fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor);
} else { } else {
_fillRect(0, 0, width, height, activeTheme.solid, bgColor); _fillRect(0, 0, width, height, activeTheme.solid, bgColor);
@@ -464,7 +480,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
} }
int get _fullMenuHeadingScale { int get _fullMenuHeadingScale {
if (!isTerminal) { if (!_usesTerminalLayout) {
return 2; return 2;
} }
return projectionWidth < 140 ? 1 : 2; return projectionWidth < 140 ? 1 : 2;
@@ -473,7 +489,8 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
bool get _useMinimalMenuText => _menuGlyphHeightInRows(scale: 1) <= 4; bool get _useMinimalMenuText => _menuGlyphHeightInRows(scale: 1) <= 4;
int _menuGlyphHeightInRows({required int scale}) { int _menuGlyphHeightInRows({required int scale}) {
final double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0; final double scaleY =
(_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0;
// Use pre-compose pixel scale so terminal mode can graduate back to // Use pre-compose pixel scale so terminal mode can graduate back to
// bitmap menu text at practical window sizes. // bitmap menu text at practical window sizes.
final double rasterHeight = 7.0 * scale * scaleY; final double rasterHeight = 7.0 * scale * scaleY;
@@ -481,15 +498,17 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
} }
int _menuY200ToRow(int y200) { int _menuY200ToRow(int y200) {
final double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0; final double scaleY =
(_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0;
final int pixelY = (y200 * scaleY).toInt(); final int pixelY = (y200 * scaleY).toInt();
final int row = isTerminal ? (pixelY / 2).round() : pixelY; final int row = _usesTerminalLayout ? (pixelY / 2).round() : pixelY;
return row.clamp(0, height - 1); return row.clamp(0, height - 1);
} }
int _menuX320ToColumn(int x320) { int _menuX320ToColumn(int x320) {
final double scaleX = (isTerminal ? projectionWidth : width) / 320.0; final double scaleX =
final int offsetX = isTerminal ? projectionOffsetX : 0; (_usesTerminalLayout ? projectionWidth : width) / 320.0;
final int offsetX = _usesTerminalLayout ? projectionOffsetX : 0;
final int pixelX = offsetX + (x320 * scaleX).toInt(); final int pixelX = offsetX + (x320 * scaleX).toInt();
return pixelX.clamp(0, width - 1); return pixelX.clamp(0, width - 1);
} }
@@ -517,10 +536,8 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
const int rowStep = 15; const int rowStep = 15;
for (int i = 0; i < Difficulty.values.length; i++) { for (int i = 0; i < Difficulty.values.length; i++) {
final bool isSelected = i == selectedDifficultyIndex; final bool isSelected = i == selectedDifficultyIndex;
final int baseRowY = _menuY200ToRow(rowYStart + (i * rowStep)); final int rowY = _menuY200ToRow(rowYStart + (i * rowStep));
final int rowY = i == Difficulty.values.length - 1
? (baseRowY + 1).clamp(0, height - 1)
: baseRowY;
final String rowText = Difficulty.values[i].title; final String rowText = Difficulty.values[i].title;
_writeLeftClipped( _writeLeftClipped(
rowY, rowY,
@@ -534,6 +551,11 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
} }
void _drawCenteredMenuFooter() { void _drawCenteredMenuFooter() {
if (_usesTerminalLayout && !_emitAnsi) {
_drawFlutterGridMenuFooter();
return;
}
final int hintKeyColor = ColorPalette.vga32Bit[_menuHintKeyPaletteIndex]; final int hintKeyColor = ColorPalette.vga32Bit[_menuHintKeyPaletteIndex];
final int hintLabelColor = final int hintLabelColor =
ColorPalette.vga32Bit[_menuHintLabelPaletteIndex]; ColorPalette.vga32Bit[_menuHintLabelPaletteIndex];
@@ -558,7 +580,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
final int boxX = ((width - boxWidth) ~/ 2).clamp(0, width - boxWidth); final int boxX = ((width - boxWidth) ~/ 2).clamp(0, width - boxWidth);
final int boxY = math.max(0, height - boxHeight - 1); final int boxY = math.max(0, height - boxHeight - 1);
if (isTerminal) { if (_usesTerminalLayout) {
_fillTerminalRect( _fillTerminalRect(
boxX, boxX,
boxY * 2, boxY * 2,
@@ -587,6 +609,41 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
} }
} }
void _drawFlutterGridMenuFooter() {
final int hintKeyColor = ColorPalette.vga32Bit[_menuHintKeyPaletteIndex];
final int hintLabelColor =
ColorPalette.vga32Bit[_menuHintLabelPaletteIndex];
final int hintBackground =
ColorPalette.vga32Bit[_menuHintBackgroundPaletteIndex];
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, 320);
final int panelHeight = 12;
final int panelX = ((320 - panelWidth) ~/ 2).clamp(0, 319);
const int panelY = 184;
_fillRect320(panelX, panelY, panelWidth, panelHeight, hintBackground);
int cursorX = panelX + 6;
const int textY = panelY + 2;
for (final (text, color) in segments) {
_drawMenuText(text, cursorX, textY, color, scale: 1);
cursorX += WolfMenuFont.measureTextWidth(text, 1);
}
}
void _writeLeftClipped( void _writeLeftClipped(
int y, int y,
String text, String text,
@@ -613,15 +670,17 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
} }
void _plotMenuPixel320(int x320, int y200, int color) { void _plotMenuPixel320(int x320, int y200, int color) {
final double scaleX = (isTerminal ? projectionWidth : width) / 320.0; final double scaleX =
final double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0; (_usesTerminalLayout ? projectionWidth : width) / 320.0;
final double scaleY =
(_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0;
final int offsetX = isTerminal ? projectionOffsetX : 0; final int offsetX = _usesTerminalLayout ? projectionOffsetX : 0;
final int startX = offsetX + (x320 * scaleX).toInt(); final int startX = offsetX + (x320 * scaleX).toInt();
final int startY = (y200 * scaleY).toInt(); final int startY = (y200 * scaleY).toInt();
final int pixelW = math.max(1, scaleX.ceil()); final int pixelW = math.max(1, scaleX.ceil());
final int pixelH = math.max(1, scaleY.ceil()); final int pixelH = math.max(1, scaleY.ceil());
if (isTerminal) { if (_usesTerminalLayout) {
for (int dy = 0; dy < pixelH; dy++) { for (int dy = 0; dy < pixelH; dy++) {
final int y = startY + dy; final int y = startY + dy;
if (y < 0 || y >= _terminalPixelHeight) { if (y < 0 || y >= _terminalPixelHeight) {
@@ -654,7 +713,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
} }
void _drawSimpleHud(WolfEngine engine) { void _drawSimpleHud(WolfEngine engine) {
final int hudWidth = isTerminal ? projectionWidth : width; final int hudWidth = _usesTerminalLayout ? projectionWidth : width;
final int hudRows = height - viewHeight; final int hudRows = height - viewHeight;
if (hudWidth < _simpleHudMinWidth || hudRows < _simpleHudMinRows) { if (hudWidth < _simpleHudMinWidth || hudRows < _simpleHudMinRows) {
_drawMinimalHud(engine); _drawMinimalHud(engine);
@@ -693,7 +752,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
final int baseY = viewHeight + 1; final int baseY = viewHeight + 1;
// 3. Clear HUD Base // 3. Clear HUD Base
if (isTerminal) { if (_usesTerminalLayout) {
_fillTerminalRect( _fillTerminalRect(
projectionOffsetX, projectionOffsetX,
viewHeight * 2, viewHeight * 2,
@@ -722,7 +781,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
// 4. Panel Drawing Helper // 4. Panel Drawing Helper
void drawBorderedPanel(int startX, int startY, int w, int h) { void drawBorderedPanel(int startX, int startY, int w, int h) {
if (isTerminal) { if (_usesTerminalLayout) {
_fillTerminalRect(startX, startY * 2, w, h * 2, vgaPanelDark); _fillTerminalRect(startX, startY * 2, w, h * 2, vgaPanelDark);
_fillTerminalRect(startX, startY * 2, w, 1, white); _fillTerminalRect(startX, startY * 2, w, 1, white);
_fillTerminalRect(startX, (startY + h) * 2 - 1, w, 1, white); _fillTerminalRect(startX, (startY + h) * 2 - 1, w, 1, white);
@@ -821,7 +880,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
final int red = ColorPalette.vga32Bit[4]; final int red = ColorPalette.vga32Bit[4];
final int hudRows = height - viewHeight; final int hudRows = height - viewHeight;
if (isTerminal) { if (_usesTerminalLayout) {
_fillTerminalRect( _fillTerminalRect(
projectionOffsetX, projectionOffsetX,
viewHeight * 2, viewHeight * 2,
@@ -853,8 +912,8 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
final int lineY = viewHeight + 1; final int lineY = viewHeight + 1;
if (lineY >= height) return; if (lineY >= height) return;
final int drawStartX = isTerminal ? projectionOffsetX : 0; final int drawStartX = _usesTerminalLayout ? projectionOffsetX : 0;
final int drawWidth = isTerminal ? projectionWidth : width; final int drawWidth = _usesTerminalLayout ? projectionWidth : width;
final int maxTextLen = math.max(0, drawWidth - 2); final int maxTextLen = math.max(0, drawWidth - 2);
String clipped = hudText; String clipped = hudText;
if (clipped.length > maxTextLen) { if (clipped.length > maxTextLen) {
@@ -965,15 +1024,15 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
@override @override
dynamic finalizeFrame() { dynamic finalizeFrame() {
if (_engine.difficulty != null && _engine.player.damageFlash > 0.0) { if (_engine.difficulty != null && _engine.player.damageFlash > 0.0) {
if (isTerminal) { if (_usesTerminalLayout) {
_applyDamageFlashToScene(); _applyDamageFlashToScene();
} else { } else {
_applyDamageFlash(); _applyDamageFlash();
} }
} }
if (isTerminal) { if (_usesTerminalLayout) {
_composeTerminalScene(); _composeTerminalScene();
return toAnsiString(); return _emitAnsi ? toAnsiString() : _screen;
} }
return _screen; return _screen;
} }
@@ -983,14 +1042,16 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
void _blitVgaImageAscii(VgaImage image, int startX_320, int startY_200) { void _blitVgaImageAscii(VgaImage image, int startX_320, int startY_200) {
int planeWidth = image.width ~/ 4; int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height; int planeSize = planeWidth * image.height;
int maxDrawHeight = isTerminal ? _terminalPixelHeight : height; int maxDrawHeight = _usesTerminalLayout ? _terminalPixelHeight : height;
int maxDrawWidth = isTerminal ? _viewportRightX : width; int maxDrawWidth = _usesTerminalLayout ? _viewportRightX : width;
double scaleX = (isTerminal ? projectionWidth : width) / 320.0; double scaleX = (_usesTerminalLayout ? projectionWidth : width) / 320.0;
double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0; double scaleY =
(_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0;
int destStartX = int destStartX =
(isTerminal ? projectionOffsetX : 0) + (startX_320 * scaleX).toInt(); (_usesTerminalLayout ? projectionOffsetX : 0) +
(startX_320 * scaleX).toInt();
int destStartY = (startY_200 * scaleY).toInt(); int destStartY = (startY_200 * scaleY).toInt();
int destWidth = (image.width * scaleX).toInt(); int destWidth = (image.width * scaleX).toInt();
int destHeight = (image.height * scaleY).toInt(); int destHeight = (image.height * scaleY).toInt();
@@ -1013,7 +1074,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
int colorByte = image.pixels[index]; int colorByte = image.pixels[index];
if (colorByte != 255) { if (colorByte != 255) {
if (isTerminal) { if (_usesTerminalLayout) {
_scenePixels[drawY][drawX] = ColorPalette.vga32Bit[colorByte]; _scenePixels[drawY][drawX] = ColorPalette.vga32Bit[colorByte];
} else { } else {
_screen[drawY][drawX] = ColoredChar( _screen[drawY][drawX] = ColoredChar(
@@ -1046,16 +1107,19 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
int h200, int h200,
int color, int color,
) { ) {
final double scaleX = (isTerminal ? projectionWidth : width) / 320.0; final double scaleX =
final double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0; (_usesTerminalLayout ? projectionWidth : width) / 320.0;
final double scaleY =
(_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0;
final int startX = final int startX =
(isTerminal ? projectionOffsetX : 0) + (startX320 * scaleX).toInt(); (_usesTerminalLayout ? projectionOffsetX : 0) +
(startX320 * scaleX).toInt();
final int startY = (startY200 * scaleY).toInt(); final int startY = (startY200 * scaleY).toInt();
final int w = math.max(1, (w320 * scaleX).toInt()); final int w = math.max(1, (w320 * scaleX).toInt());
final int h = math.max(1, (h200 * scaleY).toInt()); final int h = math.max(1, (h200 * scaleY).toInt());
if (isTerminal) { if (_usesTerminalLayout) {
_fillTerminalRect(startX, startY, w, h, color); _fillTerminalRect(startX, startY, w, h, color);
} else { } else {
_fillRect(startX, startY, w, h, activeTheme.solid, color); _fillRect(startX, startY, w, h, activeTheme.solid, color);
@@ -1119,7 +1183,11 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
ColoredChar overlay = _screen[y][x]; ColoredChar overlay = _screen[y][x];
if (overlay.char != ' ') { if (overlay.char != ' ') {
if (overlay.rawBackgroundColor == null) { // In ANSI terminal mode, inject the bottom scene pixel as background
// so half-block rows pack two pixel rows into one cell. In Flutter
// grid mode any injected background renders as a colored rectangle
// behind the character, so we leave the cell as-is.
if (_emitAnsi && overlay.rawBackgroundColor == null) {
_screen[y][x] = ColoredChar( _screen[y][x] = ColoredChar(
overlay.char, overlay.char,
overlay.rawColor, overlay.rawColor,
@@ -1129,6 +1197,9 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
continue; continue;
} }
// Pack two scene rows into one cell for both terminal and Flutter grid
// modes. Overlay characters above keep a null background in Flutter
// mode, so this does not introduce text background artifacts.
_screen[y][x] = topColor == bottomColor _screen[y][x] = topColor == bottomColor
? ColoredChar('', topColor) ? ColoredChar('', topColor)
: ColoredChar('', topColor, bottomColor); : ColoredChar('', topColor, bottomColor);
@@ -1185,13 +1256,6 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
return ColorPalette.vga32Bit[_rgbToPaletteIndex(rgb)]; return ColorPalette.vga32Bit[_rgbToPaletteIndex(rgb)];
} }
int _rgbToRawColor(int rgb) {
final int r = (rgb >> 16) & 0xFF;
final int g = (rgb >> 8) & 0xFF;
final int b = rgb & 0xFF;
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
int _rgbToPaletteIndex(int rgb) { int _rgbToPaletteIndex(int rgb) {
final int targetR = (rgb >> 16) & 0xFF; final int targetR = (rgb >> 16) & 0xFF;
final int targetG = (rgb >> 8) & 0xFF; final int targetG = (rgb >> 8) & 0xFF;

View File

@@ -1,6 +1,8 @@
class WolfMenuFont { class WolfMenuFont {
const WolfMenuFont._(); const WolfMenuFont._();
static const int _letterSpacing = 2;
static const Map<String, List<String>> glyphs = { static const Map<String, List<String>> glyphs = {
'A': ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], 'A': ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
'B': ['11110', '10001', '10001', '11110', '10001', '10001', '11110'], 'B': ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
@@ -62,11 +64,11 @@ class WolfMenuFont {
case ',': case ',':
case "'": case "'":
case ':': case ':':
return 4 * scale; return (4 + _letterSpacing) * scale;
case ' ': case ' ':
return 5 * scale; return 5 * scale;
default: default:
return 6 * scale; return (6 + _letterSpacing) * scale;
} }
} }

View File

@@ -336,9 +336,9 @@ class SixelRasterizer extends CliRasterizer<String> {
engine.menuManager.selectedDifficultyIndex; engine.menuManager.selectedDifficultyIndex;
final int bgColor = _rgbToPaletteIndex(engine.menuBackgroundRgb); final int bgColor = _rgbToPaletteIndex(engine.menuBackgroundRgb);
final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb); final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb);
const int headingIndex = 119; final int headingIndex = WolfMenuPalette.headerTextIndex;
const int selectedTextIndex = 19; final int selectedTextIndex = WolfMenuPalette.selectedTextIndex;
const int unselectedTextIndex = 23; final int unselectedTextIndex = WolfMenuPalette.unselectedTextIndex;
for (int i = 0; i < _screen.length; i++) { for (int i = 0; i < _screen.length; i++) {
_screen[i] = bgColor; _screen[i] = bgColor;
@@ -419,7 +419,9 @@ class SixelRasterizer extends CliRasterizer<String> {
prefix + Difficulty.values[i].title, prefix + Difficulty.values[i].title,
42, 42,
rowYStart + (i * rowStep), rowYStart + (i * rowStep),
isSelected ? 19 : 23, isSelected
? WolfMenuPalette.selectedTextIndex
: WolfMenuPalette.unselectedTextIndex,
scale: 1, scale: 1,
); );
} }

View File

@@ -182,9 +182,9 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
engine.menuManager.selectedDifficultyIndex; engine.menuManager.selectedDifficultyIndex;
final int bgColor = _rgbToFrameColor(engine.menuBackgroundRgb); final int bgColor = _rgbToFrameColor(engine.menuBackgroundRgb);
final int panelColor = _rgbToFrameColor(engine.menuPanelRgb); final int panelColor = _rgbToFrameColor(engine.menuPanelRgb);
final int headingColor = ColorPalette.vga32Bit[119]; final int headingColor = WolfMenuPalette.headerTextColor;
final int selectedTextColor = ColorPalette.vga32Bit[19]; final int selectedTextColor = WolfMenuPalette.selectedTextColor;
final int unselectedTextColor = ColorPalette.vga32Bit[23]; final int unselectedTextColor = WolfMenuPalette.unselectedTextColor;
for (int i = 0; i < _buffer.pixels.length; i++) { for (int i = 0; i < _buffer.pixels.length; i++) {
_buffer.pixels[i] = bgColor; _buffer.pixels[i] = bgColor;

View File

@@ -43,6 +43,55 @@ abstract class WolfMenuPic {
]; ];
} }
/// Shared menu text colors resolved from the VGA palette.
///
/// Keep menu color choices centralized so renderers don't duplicate
/// hard-coded palette slots or RGB conversion logic.
abstract class WolfMenuPalette {
static const int selectedTextIndex = 19;
static const int unselectedTextIndex = 23;
static const int _headerTargetRgb = 0xFFF700;
static int? _cachedHeaderTextIndex;
static int get headerTextIndex =>
_cachedHeaderTextIndex ??= _nearestPaletteIndex(_headerTargetRgb);
static int get selectedTextColor => ColorPalette.vga32Bit[selectedTextIndex];
static int get unselectedTextColor =>
ColorPalette.vga32Bit[unselectedTextIndex];
static int get headerTextColor => ColorPalette.vga32Bit[headerTextIndex];
static int _nearestPaletteIndex(int rgb) {
final int targetR = (rgb >> 16) & 0xFF;
final int targetG = (rgb >> 8) & 0xFF;
final int targetB = rgb & 0xFF;
int bestIndex = 0;
int bestDistance = 1 << 30;
for (int i = 0; i < 256; i++) {
final int color = ColorPalette.vga32Bit[i];
final int r = color & 0xFF;
final int g = (color >> 8) & 0xFF;
final int b = (color >> 16) & 0xFF;
final int dr = targetR - r;
final int dg = targetG - g;
final int db = targetB - b;
final int distance = (dr * dr) + (dg * dg) + (db * db);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = i;
}
}
return bestIndex;
}
}
/// Structured accessors for classic Wolf3D menu art. /// Structured accessors for classic Wolf3D menu art.
class WolfClassicMenuArt { class WolfClassicMenuArt {
final WolfensteinData data; final WolfensteinData data;

View File

@@ -1,6 +1,7 @@
library; library;
export 'src/rasterizer/ascii_rasterizer.dart' show AsciiRasterizer, ColoredChar; export 'src/rasterizer/ascii_rasterizer.dart'
show AsciiRasterizer, AsciiRasterizerMode, ColoredChar;
export 'src/rasterizer/cli_rasterizer.dart'; export 'src/rasterizer/cli_rasterizer.dart';
export 'src/rasterizer/rasterizer.dart'; export 'src/rasterizer/rasterizer.dart';
export 'src/rasterizer/sixel_rasterizer.dart'; export 'src/rasterizer/sixel_rasterizer.dart';

View File

@@ -22,7 +22,9 @@ class _WolfAsciiRendererState extends BaseWolfRendererState<WolfAsciiRenderer> {
static const int _renderHeight = 100; static const int _renderHeight = 100;
List<List<ColoredChar>> _asciiFrame = []; List<List<ColoredChar>> _asciiFrame = [];
final AsciiRasterizer _asciiRasterizer = AsciiRasterizer(); final AsciiRasterizer _asciiRasterizer = AsciiRasterizer(
mode: AsciiRasterizerMode.terminalGrid,
);
@override @override
void initState() { void initState() {
@@ -84,22 +86,16 @@ class AsciiFrameWidget extends StatelessWidget {
// Merge adjacent cells with the same color to keep the rich // Merge adjacent cells with the same color to keep the rich
// text tree smaller and reduce per-frame layout overhead. // text tree smaller and reduce per-frame layout overhead.
Color currentColor = Color(row[0].argb); Color currentColor = Color(row[0].argb);
Color? currentBackground = row[0].rawBackgroundColor == null Color? currentBackground = row[0].backgroundArgb == null
? null ? null
: Color( : Color(row[0].backgroundArgb!);
0xFF000000 | (row[0].rawBackgroundColor! & 0x00FFFFFF),
);
StringBuffer currentSegment = StringBuffer(row[0].char); StringBuffer currentSegment = StringBuffer(row[0].char);
for (int i = 1; i < row.length; i++) { for (int i = 1; i < row.length; i++) {
final Color nextColor = Color(row[i].argb); final Color nextColor = Color(row[i].argb);
final Color? nextBackground = final Color? nextBackground = row[i].backgroundArgb == null
row[i].rawBackgroundColor == null
? null ? null
: Color( : Color(row[i].backgroundArgb!);
0xFF000000 |
(row[i].rawBackgroundColor! & 0x00FFFFFF),
);
if (nextColor == currentColor && if (nextColor == currentColor &&
nextBackground == currentBackground) { nextBackground == currentBackground) {
currentSegment.write(row[i].char); currentSegment.write(row[i].char);