WIP fixing menu rendering in CLI ASCII mode

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-18 14:08:21 +01:00
parent dc1acb7a2f
commit 839fae700f
8 changed files with 413 additions and 301 deletions

View File

@@ -1,11 +1,14 @@
/// Defines the game difficulty levels, matching the original titles.
enum Difficulty {
baby(0, "Can I play, Daddy?"),
easy(0, "Don't hurt me."),
medium(1, "Bring em' on!"),
hard(2, "I am Death incarnate!"),
baby(0, "CAN I PLAY, DADDY?"),
easy(0, "DON'T HURT ME."),
medium(1, "BRING 'EM ON!"),
hard(2, "I AM DEATH INCARNATE!"),
;
/// Shared heading used by classic difficulty menus.
static String get menuText => 'HOW TOUGH ARE YOU?';
/// The friendly string shown in menus.
final String title;

View File

@@ -184,6 +184,8 @@ class WolfEngine {
void _tickDifficultyMenu(EngineInput input) {
final menuResult = menuManager.updateDifficultySelection(input);
if (menuResult.goBack) {
// Explicitly keep the engine in menu mode when leaving this screen.
difficulty = null;
onGameWon();
return;
}

View File

@@ -6,6 +6,7 @@ import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
import 'cli_rasterizer.dart';
import 'menu_font.dart';
class AsciiTheme {
final String name;
@@ -63,62 +64,19 @@ class ColoredChar {
}
class AsciiRasterizer extends CliRasterizer<dynamic> {
static const int _unsetMenuSubPixel = -1;
static const Map<int, String> _quadrantGlyphByMask = {
0x0: ' ',
0x1: '',
0x2: '',
0x3: '',
0x4: '',
0x5: '',
0x6: '',
0x7: '',
0x8: '',
0x9: '',
0xA: '',
0xB: '',
0xC: '',
0xD: '',
0xE: '',
0xF: '',
};
static const Map<String, List<String>> _menuFont = {
'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'],
'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'],
'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'],
'W': ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
'Y': ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
'?': ['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', '00000', '00000', '00000', '00000', '00000', '00000'],
};
static const double _targetAspectRatio = 4 / 3;
static const int _terminalBackdropArgb = 0xFF009688;
static const int _terminalBackdropPaletteIndex = 153;
static const int _minimumTerminalColumns = 80;
static const int _minimumTerminalRows = 24;
static const int _simpleHudMinWidth = 84;
static const int _simpleHudMinRows = 7;
static const int _menuSelectedTextPaletteIndex = 19;
static const int _menuUnselectedTextPaletteIndex = 23;
static const int _menuHintKeyPaletteIndex = 12;
static const int _menuHintLabelPaletteIndex = 4;
static const int _menuHintBackgroundPaletteIndex = 0;
AsciiRasterizer({
this.activeTheme = AsciiThemes.blocks,
this.isTerminal = false,
@@ -132,7 +90,6 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
late List<List<ColoredChar>> _screen;
late List<List<int>> _scenePixels;
late WolfEngine _engine;
List<List<int>>? _menuTextSubPixels;
@override
final double aspectMultiplier;
@@ -170,7 +127,8 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
int get _viewportRightX => projectionOffsetX + projectionWidth;
int get _terminalBackdropColor => _argbToRawColor(_terminalBackdropArgb);
int get _terminalBackdropColor =>
ColorPalette.vga32Bit[_terminalBackdropPaletteIndex];
// Intercept the base render call to initialize our text grid
@override
@@ -251,7 +209,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
if (isTerminal) {
_scenePixels[y][x] = _scaleColor(pixelColor, brightness);
} else {
String wallChar = activeTheme.getByBrightness(brightness);
final String wallChar = activeTheme.getByBrightness(brightness);
_screen[y][x] = ColoredChar(wallChar, pixelColor);
}
}
@@ -295,7 +253,6 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
if (isTerminal) {
_scenePixels[y][stripeX] = shadedColor;
} else {
// Force sprites to be SOLID so they don't vanish into the terminal background
_screen[y][stripeX] = ColoredChar(activeTheme.solid, shadedColor);
}
}
@@ -379,33 +336,30 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
void drawMenu(WolfEngine engine) {
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
final int bgColor = _rgbToRawColor(engine.menuBackgroundRgb);
final int panelColor = _rgbToRawColor(engine.menuPanelRgb);
final int headingColor = ColorPalette.vga32Bit[119];
final int selectedTextColor = ColorPalette.vga32Bit[19];
final int unselectedTextColor = ColorPalette.vga32Bit[23];
final int bgColor = _rgbToPaletteColor(engine.menuBackgroundRgb);
final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb);
// Exact title tint requested for menu heading text (#fff700).
final int headingColor = _rgbToRawColor(0xFFF700);
final int selectedTextColor =
ColorPalette.vga32Bit[_menuSelectedTextPaletteIndex];
final int unselectedTextColor =
ColorPalette.vga32Bit[_menuUnselectedTextPaletteIndex];
if (isTerminal) {
_fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor);
} else {
_fillRect(0, 0, width, height, activeTheme.solid, bgColor);
_menuTextSubPixels = List.generate(
height * 2,
(_) => List<int>.filled(width * 2, _unsetMenuSubPixel),
);
}
_fillRect320(28, 70, 264, 82, panelColor);
_drawMenuTextCentered('HOW TOUGH ARE YOU?', 48, headingColor, scale: 2);
final art = WolfClassicMenuArt(engine.data);
final face = art.difficultyOption(
Difficulty.values[selectedDifficultyIndex],
);
if (face != null) {
_blitVgaImageAscii(face, 28 + 264 - face.width - 10, 92);
_blitVgaImageAscii(face, 28 + 264 - face.width - 18, 92);
}
final cursor = art.pic(
@@ -414,6 +368,38 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
const rowYStart = 86;
const rowStep = 15;
if (_useMinimalMenuText) {
_drawMenuTextCentered(
Difficulty.menuText,
48,
headingColor,
scale: 2,
);
_drawMinimalMenuText(
selectedDifficultyIndex,
selectedTextColor,
unselectedTextColor,
panelColor,
);
if (cursor != null) {
_blitVgaImageAscii(
cursor,
38,
(rowYStart + (selectedDifficultyIndex * rowStep)) - 2,
);
}
_drawCenteredMenuFooter();
return;
}
final int headingScale = _fullMenuHeadingScale;
_drawMenuTextCentered(
Difficulty.menuText,
48,
headingColor,
scale: headingScale,
);
for (int i = 0; i < Difficulty.values.length; i++) {
final y = rowYStart + (i * rowStep);
final isSelected = i == selectedDifficultyIndex;
@@ -422,87 +408,14 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
}
_drawMenuText(
_difficultyLabel(Difficulty.values[i]),
Difficulty.values[i].title,
70,
y,
isSelected ? selectedTextColor : unselectedTextColor,
);
}
if (!isTerminal) {
_composeMenuTextSubPixels();
_menuTextSubPixels = null;
}
final int hintKeyColor = _rgbToRawColor(0xFF5555);
final int hintLabelColor = _rgbToRawColor(0x900303);
final int hintBackground = _rgbToRawColor(0x000000);
_fillRect320(0, 176, 320, 24, hintBackground);
final hintY = ((186 / 200) * height).toInt().clamp(0, height - 1);
int hintX = ((24 / 320) * width).toInt().clamp(0, width - 1);
_writeString(
hintX,
hintY,
'^/v',
hintKeyColor,
hintBackground,
);
hintX += 4;
_writeString(
hintX,
hintY,
' MOVE ',
hintLabelColor,
hintBackground,
);
hintX += 7;
_writeString(
hintX,
hintY,
'RET',
hintKeyColor,
hintBackground,
);
hintX += 4;
_writeString(
hintX,
hintY,
' SELECT ',
hintLabelColor,
hintBackground,
);
hintX += 9;
_writeString(
hintX,
hintY,
'ESC',
hintKeyColor,
hintBackground,
);
hintX += 4;
_writeString(
hintX,
hintY,
' BACK',
hintLabelColor,
hintBackground,
);
}
String _difficultyLabel(Difficulty difficulty) {
switch (difficulty) {
case Difficulty.baby:
return 'CAN I PLAY, DADDY?';
case Difficulty.easy:
return "DON'T HURT ME.";
case Difficulty.medium:
return "BRING 'EM ON!";
case Difficulty.hard:
return 'I AM DEATH INCARNATE!';
}
_drawCenteredMenuFooter();
}
void _drawMenuText(
@@ -515,7 +428,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
int x320 = startX320;
for (final rune in text.runes) {
final String char = String.fromCharCode(rune).toUpperCase();
final List<String> pattern = _menuFont[char] ?? _menuFont[' ']!;
final List<String> pattern = WolfMenuFont.glyphFor(char);
for (int row = 0; row < pattern.length; row++) {
final String bits = pattern[row];
@@ -535,7 +448,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
}
}
x320 += _menuGlyphAdvance(char, scale);
x320 += WolfMenuFont.glyphAdvance(char, scale);
}
}
@@ -545,46 +458,170 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
int color, {
int scale = 1,
}) {
final int textWidth = _measureMenuTextWidth(text, scale);
final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
final int x320 = ((320 - textWidth) ~/ 2).clamp(0, 319);
_drawMenuText(text, x320, y200, color, scale: scale);
}
int _measureMenuTextWidth(String text, int scale) {
int width320 = 0;
for (final rune in text.runes) {
final char = String.fromCharCode(rune).toUpperCase();
width320 += _menuGlyphAdvance(char, scale);
int get _fullMenuHeadingScale {
if (!isTerminal) {
return 2;
}
return width320;
return projectionWidth < 140 ? 1 : 2;
}
int _menuGlyphAdvance(String char, int scale) {
switch (char) {
case 'I':
case '!':
case '.':
case ',':
case "'":
return 4 * scale;
case ' ':
return 5 * scale;
default:
return 6 * scale;
bool get _useMinimalMenuText => _menuGlyphHeightInRows(scale: 1) <= 4;
int _menuGlyphHeightInRows({required int scale}) {
final double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0;
// Use pre-compose pixel scale so terminal mode can graduate back to
// bitmap menu text at practical window sizes.
final double rasterHeight = 7.0 * scale * scaleY;
return math.max(1, rasterHeight.ceil());
}
int _menuY200ToRow(int y200) {
final double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0;
final int pixelY = (y200 * scaleY).toInt();
final int row = isTerminal ? (pixelY / 2).round() : pixelY;
return row.clamp(0, height - 1);
}
int _menuX320ToColumn(int x320) {
final double scaleX = (isTerminal ? projectionWidth : width) / 320.0;
final int offsetX = isTerminal ? projectionOffsetX : 0;
final int pixelX = offsetX + (x320 * scaleX).toInt();
return pixelX.clamp(0, width - 1);
}
void _drawMinimalMenuText(
int selectedDifficultyIndex,
int selectedTextColor,
int unselectedTextColor,
int panelColor,
) {
const int panelX320 = 28;
const int panelW320 = 264;
final int panelX =
projectionOffsetX + ((panelX320 / 320.0) * projectionWidth).toInt();
final int panelW = math.max(
1,
((panelW320 / 320.0) * projectionWidth).toInt(),
);
final int panelRight = panelX + panelW - 1;
final int textLeft = _menuX320ToColumn(70);
final int textWidth = math.max(1, panelRight - textLeft);
const int rowYStart = 86;
const int rowStep = 15;
for (int i = 0; i < Difficulty.values.length; i++) {
final bool isSelected = i == selectedDifficultyIndex;
final int baseRowY = _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;
_writeLeftClipped(
rowY,
rowText,
isSelected ? selectedTextColor : unselectedTextColor,
panelColor,
textWidth,
textLeft,
);
}
}
void _drawCenteredMenuFooter() {
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)>[
('^/v', hintKeyColor),
(' MOVE ', hintLabelColor),
('RET', hintKeyColor),
(' SELECT ', hintLabelColor),
('ESC', hintKeyColor),
(' BACK', hintLabelColor),
];
int textWidth = 0;
for (final (text, _) in segments) {
textWidth += text.length;
}
final int boxWidth = math.min(width, textWidth + 2);
final int boxHeight = 3;
final int boxX = ((width - boxWidth) ~/ 2).clamp(0, width - boxWidth);
final int boxY = math.max(0, height - boxHeight - 1);
if (isTerminal) {
_fillTerminalRect(
boxX,
boxY * 2,
boxWidth,
boxHeight * 2,
hintBackground,
);
} else {
_fillRect(boxX, boxY, boxWidth, boxHeight, ' ', hintBackground);
}
final int contentWidth = math.max(0, boxWidth - 2);
final int textY = boxY + 1;
int textX = boxX + 1;
int remaining = contentWidth;
for (final (text, color) in segments) {
if (remaining <= 0) {
break;
}
final String clipped = text.length <= remaining
? text
: text.substring(0, remaining);
_writeString(textX, textY, clipped, color, hintBackground);
textX += clipped.length;
remaining -= clipped.length;
}
}
void _writeLeftClipped(
int y,
String text,
int color,
int backgroundColor,
int maxWidth,
int left,
) {
if (y < 0 || y >= height || maxWidth <= 0) {
return;
}
final String clipped = _clipWithEllipsis(text, maxWidth);
_writeString(left, y, clipped, color, backgroundColor);
}
String _clipWithEllipsis(String text, int maxWidth) {
if (text.length <= maxWidth) {
return text;
}
if (maxWidth <= 3) {
return text.substring(0, maxWidth);
}
return '${text.substring(0, maxWidth - 3)}...';
}
void _plotMenuPixel320(int x320, int y200, int color) {
final double scaleX = (isTerminal ? projectionWidth : width) / 320.0;
final double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0;
final int offsetX = isTerminal ? projectionOffsetX : 0;
final int startX = offsetX + (x320 * scaleX).toInt();
final int startY = (y200 * scaleY).toInt();
final int pixelW = math.max(1, scaleX.ceil());
final int pixelH = math.max(1, scaleY.ceil());
if (isTerminal) {
final int startY = (y200 * scaleY).toInt();
final int pixelH = math.max(1, scaleY.ceil());
for (int dy = 0; dy < pixelH; dy++) {
final int y = startY + dy;
if (y < 0 || y >= _terminalPixelHeight) {
@@ -601,77 +638,17 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
return;
}
final overlay = _menuTextSubPixels;
if (overlay == null) {
return;
}
final double subScaleX = (width * 2) / 320.0;
final double subScaleY = (height * 2) / 200.0;
final int startXSub = (x320 * subScaleX).toInt();
final int startYSub = (y200 * subScaleY).toInt();
final int pixelWSub = math.max(1, subScaleX.ceil());
final int pixelHSub = math.max(1, subScaleY.ceil());
for (int dy = 0; dy < pixelHSub; dy++) {
final int y = startYSub + dy;
if (y < 0 || y >= overlay.length) {
for (int dy = 0; dy < pixelH; dy++) {
final int y = startY + dy;
if (y < 0 || y >= height) {
continue;
}
for (int dx = 0; dx < pixelWSub; dx++) {
final int x = startXSub + dx;
if (x < 0 || x >= (width * 2)) {
for (int dx = 0; dx < pixelW; dx++) {
final int x = startX + dx;
if (x < 0 || x >= width) {
continue;
}
overlay[y][x] = color;
}
}
}
void _composeMenuTextSubPixels() {
final overlay = _menuTextSubPixels;
if (overlay == null) {
return;
}
for (int y = 0; y < height; y++) {
final int topY = y * 2;
final int bottomY = math.min(topY + 1, overlay.length - 1);
for (int x = 0; x < width; x++) {
final int leftX = x * 2;
final int rightX = leftX + 1;
final int tl = overlay[topY][leftX];
final int tr = overlay[topY][rightX];
final int bl = overlay[bottomY][leftX];
final int br = overlay[bottomY][rightX];
int mask = 0;
if (tl != _unsetMenuSubPixel) mask |= 0x1;
if (tr != _unsetMenuSubPixel) mask |= 0x2;
if (bl != _unsetMenuSubPixel) mask |= 0x4;
if (br != _unsetMenuSubPixel) mask |= 0x8;
if (mask == 0) {
continue;
}
final int baseColor = _screen[y][x].rawColor;
final int fgColor = tl != _unsetMenuSubPixel
? tl
: tr != _unsetMenuSubPixel
? tr
: bl != _unsetMenuSubPixel
? bl
: br;
final String glyph = _quadrantGlyphByMask[mask] ?? '';
if (mask == 0xF) {
_screen[y][x] = ColoredChar('', fgColor);
} else {
_screen[y][x] = ColoredChar(glyph, fgColor, baseColor);
}
_screen[y][x] = ColoredChar(activeTheme.solid, color);
}
}
}
@@ -1109,13 +1086,6 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
}
}
int _argbToRawColor(int argb) {
int r = (argb >> 16) & 0xFF;
int g = (argb >> 8) & 0xFF;
int b = argb & 0xFF;
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
int _applyDamageFlashToColor(int color) {
double intensity = _engine.player.damageFlash;
int redBoost = (150 * intensity).toInt();
@@ -1211,11 +1181,42 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
return buffer;
}
int _rgbToPaletteColor(int 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;
// ColoredChar values use the same raw pixel packing as the framebuffer.
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
int _rgbToPaletteIndex(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;
}
}

View File

@@ -0,0 +1,80 @@
class WolfMenuFont {
const WolfMenuFont._();
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 * scale;
case ' ':
return 5 * scale;
default:
return 6 * 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;
}
}

View File

@@ -11,6 +11,7 @@ import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
import 'cli_rasterizer.dart';
import 'menu_font.dart';
/// Renders the game into an indexed off-screen buffer and emits Sixel output.
///
@@ -18,41 +19,13 @@ import 'cli_rasterizer.dart';
/// preserving a 4:3 presentation while falling back to size warnings when the
/// terminal is too small.
class SixelRasterizer extends CliRasterizer<String> {
static const Map<String, List<String>> _menuFont = {
'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'],
'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'],
'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'],
'W': ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
'Y': ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
'?': ['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', '00000', '00000', '00000', '00000', '00000', '00000'],
};
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 _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';
@@ -374,7 +347,17 @@ class SixelRasterizer extends CliRasterizer<String> {
_fillRect320(28, 70, 264, 82, panelColor);
final art = WolfClassicMenuArt(engine.data);
_drawMenuTextCentered('HOW TOUGH ARE YOU?', 48, headingIndex, scale: 2);
if (_useCompactMenuLayout) {
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
return;
}
_drawMenuTextCentered(
Difficulty.menuText,
48,
headingIndex,
scale: _menuHeadingScale,
);
final bottom = art.pic(15);
if (bottom != null) {
@@ -394,12 +377,6 @@ class SixelRasterizer extends CliRasterizer<String> {
const rowYStart = 86;
const rowStep = 15;
const textX = 70;
const labels = [
'CAN I PLAY, DADDY?',
"DON'T HURT ME.",
"BRING 'EM ON!",
'I AM DEATH INCARNATE!',
];
for (int i = 0; i < Difficulty.values.length; i++) {
final y = rowYStart + (i * rowStep);
final isSelected = i == selectedDifficultyIndex;
@@ -409,10 +386,41 @@ class SixelRasterizer extends CliRasterizer<String> {
}
_drawMenuText(
labels[i],
Difficulty.values[i].title,
textX,
y,
isSelected ? selectedTextIndex : unselectedTextIndex,
scale: _menuOptionScale,
);
}
}
bool get _useCompactMenuLayout =>
width < _compactMenuMinWidthPx || height < _compactMenuMinHeightPx;
int get _menuHeadingScale => width < 250 ? 1 : 2;
int get _menuOptionScale => width < 220 ? 1 : 1;
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 ? 19 : 23,
scale: 1,
);
}
}
@@ -430,7 +438,7 @@ class SixelRasterizer extends CliRasterizer<String> {
int x320 = startX;
for (final rune in text.runes) {
final char = String.fromCharCode(rune).toUpperCase();
final pattern = _menuFont[char] ?? _menuFont[' ']!;
final pattern = WolfMenuFont.glyphFor(char);
for (int row = 0; row < pattern.length; row++) {
final bits = pattern[row];
@@ -452,7 +460,7 @@ class SixelRasterizer extends CliRasterizer<String> {
}
}
x320 += (6 * scale);
x320 += WolfMenuFont.glyphAdvance(char, scale);
}
}
@@ -462,7 +470,7 @@ class SixelRasterizer extends CliRasterizer<String> {
int colorIndex, {
int scale = 1,
}) {
final int textWidth = text.length * 6 * scale;
final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
final int x = ((320 - textWidth) ~/ 2).clamp(0, 319);
_drawMenuText(text, x, y, colorIndex, scale: scale);
}

View File

@@ -206,7 +206,7 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
}
final art = WolfClassicMenuArt(engine.data);
_drawMenuTextCentered('HOW TOUGH ARE YOU?', 48, headingColor, scale: 2);
_drawMenuTextCentered(Difficulty.menuText, 48, headingColor, scale: 2);
final bottom = art.pic(15);
if (bottom != null) {
@@ -228,13 +228,6 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
const rowYStart = panelY + 16;
const rowStep = 15;
const textX = panelX + 42;
const labels = [
'CAN I PLAY, DADDY?',
"DON'T HURT ME.",
"BRING 'EM ON!",
'I AM DEATH INCARNATE!',
];
for (int i = 0; i < Difficulty.values.length; i++) {
final y = rowYStart + (i * rowStep);
final isSelected = i == selectedDifficultyIndex;
@@ -244,7 +237,7 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
}
_drawMenuText(
labels[i],
Difficulty.values[i].title,
textX,
y,
isSelected ? selectedTextColor : unselectedTextColor,

View File

@@ -84,26 +84,47 @@ class AsciiFrameWidget extends StatelessWidget {
// Merge adjacent cells with the same color to keep the rich
// text tree smaller and reduce per-frame layout overhead.
Color currentColor = Color(row[0].argb);
Color? currentBackground = row[0].rawBackgroundColor == null
? null
: Color(
0xFF000000 | (row[0].rawBackgroundColor! & 0x00FFFFFF),
);
StringBuffer currentSegment = StringBuffer(row[0].char);
for (int i = 1; i < row.length; i++) {
if (Color(row[i].argb) == currentColor) {
final Color nextColor = Color(row[i].argb);
final Color? nextBackground =
row[i].rawBackgroundColor == null
? null
: Color(
0xFF000000 |
(row[i].rawBackgroundColor! & 0x00FFFFFF),
);
if (nextColor == currentColor &&
nextBackground == currentBackground) {
currentSegment.write(row[i].char);
} else {
optimizedSpans.add(
TextSpan(
text: currentSegment.toString(),
style: TextStyle(color: currentColor),
style: TextStyle(
color: currentColor,
backgroundColor: currentBackground,
),
),
);
currentColor = Color(row[i].argb);
currentColor = nextColor;
currentBackground = nextBackground;
currentSegment = StringBuffer(row[i].char);
}
}
optimizedSpans.add(
TextSpan(
text: currentSegment.toString(),
style: TextStyle(color: currentColor),
style: TextStyle(
color: currentColor,
backgroundColor: currentBackground,
),
),
);
}