From 839fae700f6bad1bea708331ede80cf51114f94f Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 18 Mar 2026 14:08:21 +0100 Subject: [PATCH] WIP fixing menu rendering in CLI ASCII mode Signed-off-by: Hans Kokx --- apps/wolf_3d_gui/lib/screens/game_screen.dart | 10 +- .../lib/src/data_types/difficulty.dart | 11 +- .../lib/src/engine/wolf_3d_engine_base.dart | 2 + .../lib/src/rasterizer/ascii_rasterizer.dart | 481 +++++++++--------- .../lib/src/rasterizer/menu_font.dart | 80 +++ .../lib/src/rasterizer/sixel_rasterizer.dart | 90 ++-- .../src/rasterizer/software_rasterizer.dart | 11 +- .../lib/wolf_3d_ascii_renderer.dart | 29 +- 8 files changed, 413 insertions(+), 301 deletions(-) create mode 100644 packages/wolf_3d_dart/lib/src/rasterizer/menu_font.dart diff --git a/apps/wolf_3d_gui/lib/screens/game_screen.dart b/apps/wolf_3d_gui/lib/screens/game_screen.dart index d0ab008..f6d5998 100644 --- a/apps/wolf_3d_gui/lib/screens/game_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/game_screen.dart @@ -31,15 +31,19 @@ class _GameScreenState extends State { void initState() { super.initState(); _engine = widget.wolf3d.launchEngine( - onGameWon: () => Navigator.of(context).pop(), + onGameWon: () { + _engine.difficulty = null; + widget.wolf3d.clearActiveDifficulty(); + Navigator.of(context).pop(); + }, ); } @override Widget build(BuildContext context) { - return PopScope( + return PopScope( canPop: _engine.difficulty != null, - onPopInvokedWithResult: (didPop, result) { + onPopInvokedWithResult: (didPop, _) { if (!didPop && _engine.difficulty == null) { widget.wolf3d.input.queueBackAction(); } diff --git a/packages/wolf_3d_dart/lib/src/data_types/difficulty.dart b/packages/wolf_3d_dart/lib/src/data_types/difficulty.dart index 3c00711..6b69033 100644 --- a/packages/wolf_3d_dart/lib/src/data_types/difficulty.dart +++ b/packages/wolf_3d_dart/lib/src/data_types/difficulty.dart @@ -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; diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index ba333f6..7556ecb 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -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; } diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart index c9e40a8..c332788 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart @@ -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 { - static const int _unsetMenuSubPixel = -1; - static const Map _quadrantGlyphByMask = { - 0x0: ' ', - 0x1: '▘', - 0x2: '▝', - 0x3: '▀', - 0x4: '▖', - 0x5: '▌', - 0x6: '▞', - 0x7: '▛', - 0x8: '▗', - 0x9: '▚', - 0xA: '▐', - 0xB: '▜', - 0xC: '▄', - 0xD: '▙', - 0xE: '▟', - 0xF: '█', - }; - static const Map> _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 { late List> _screen; late List> _scenePixels; late WolfEngine _engine; - List>? _menuTextSubPixels; @override final double aspectMultiplier; @@ -170,7 +127,8 @@ class AsciiRasterizer extends CliRasterizer { 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 { 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 { 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 { 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.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 { 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 { } _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 { int x320 = startX320; for (final rune in text.runes) { final String char = String.fromCharCode(rune).toUpperCase(); - final List pattern = _menuFont[char] ?? _menuFont[' ']!; + final List 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 { } } - x320 += _menuGlyphAdvance(char, scale); + x320 += WolfMenuFont.glyphAdvance(char, scale); } } @@ -545,46 +458,170 @@ class AsciiRasterizer extends CliRasterizer { 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 { 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 { } } - 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 { 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; + } } diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/menu_font.dart b/packages/wolf_3d_dart/lib/src/rasterizer/menu_font.dart new file mode 100644 index 0000000..42c2ddf --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/rasterizer/menu_font.dart @@ -0,0 +1,80 @@ +class WolfMenuFont { + const WolfMenuFont._(); + + static const Map> 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 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; + } +} diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart index 5bef064..2220f1f 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart @@ -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 { - static const Map> _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 { _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 { 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 { } _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 { 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 { } } - x320 += (6 * scale); + x320 += WolfMenuFont.glyphAdvance(char, scale); } } @@ -462,7 +470,7 @@ class SixelRasterizer extends CliRasterizer { 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); } diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart index c744f38..817606f 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart @@ -206,7 +206,7 @@ class SoftwareRasterizer extends Rasterizer { } 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 { 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 { } _drawMenuText( - labels[i], + Difficulty.values[i].title, textX, y, isSelected ? selectedTextColor : unselectedTextColor, diff --git a/packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart b/packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart index 5f40939..b0ed657 100644 --- a/packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart +++ b/packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart @@ -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, + ), ), ); }