Refactor menu rendering and state management

- Introduced _AsciiMenuTypography and _AsciiMenuRowFont enums to manage typography settings for menu rendering.
- Updated AsciiRenderer to utilize new typography settings for main menu and game select screens.
- Enhanced SixelRenderer and SoftwareRenderer to support new menu rendering logic, including sidebars for options labels.
- Added disabled text color handling in WolfMenuPalette for better visual feedback on menu entries.
- Implemented a new method _drawSelectableMenuRows to streamline the drawing of menu rows based on selection state.
- Created a comprehensive test suite for level state carry-over and pause menu functionality, ensuring player state is preserved across levels and menus.
- Adjusted footer rendering to account for layout changes and improved visual consistency across different renderers.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 09:58:48 +01:00
parent 536a10d99e
commit 9b053e1c02
8 changed files with 1198 additions and 135 deletions
@@ -80,6 +80,23 @@ enum AsciiRendererMode {
terminalGrid,
}
enum _AsciiMenuRowFont {
bitmap,
compactText,
}
class _AsciiMenuTypography {
const _AsciiMenuTypography({
required this.headingScale,
required this.rowFont,
});
final int headingScale;
final _AsciiMenuRowFont rowFont;
bool get usesCompactRows => rowFont == _AsciiMenuRowFont.compactText;
}
/// Text-mode renderer that can render to ANSI escape output or a Flutter
/// grid model of colored characters.
class AsciiRenderer extends CliRendererBackend<dynamic> {
@@ -391,6 +408,8 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final int headingColor = WolfMenuPalette.headerTextColor;
final int selectedTextColor = WolfMenuPalette.selectedTextColor;
final int unselectedTextColor = WolfMenuPalette.unselectedTextColor;
final int disabledTextColor = WolfMenuPalette.disabledTextColor;
final _AsciiMenuTypography menuTypography = _resolveMenuTypography();
if (_usesTerminalLayout) {
_fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor);
@@ -398,11 +417,66 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_fillRect(0, 0, width, height, activeTheme.solid, bgColor);
}
_fillRect320(28, 70, 264, 82, panelColor);
final art = WolfClassicMenuArt(engine.data);
if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) {
_fillRect320(68, 52, 178, 136, panelColor);
final optionsLabel = art.optionsLabel;
if (optionsLabel != null) {
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
_blitVgaImageAscii(optionsLabel, optionsX, 0);
} else {
_drawMenuTextCentered(
'OPTIONS',
24,
headingColor,
scale: menuTypography.headingScale,
);
}
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 55;
const int rowStep = 13;
final entries = engine.menuManager.mainMenuEntries;
_drawSelectableMenuRows(
typography: menuTypography,
rows: entries.map((entry) => entry.label).toList(growable: false),
selectedIndex: engine.menuManager.selectedMainIndex,
rowYStart200: rowYStart,
rowStep200: rowStep,
textX320: 100,
panelX320: 68,
panelW320: 178,
colorForRow: (int index, bool isSelected) {
final entry = entries[index];
if (!entry.isEnabled) {
return disabledTextColor;
}
return isSelected ? selectedTextColor : unselectedTextColor;
},
);
if (cursor != null) {
_blitVgaImageAscii(
cursor,
72,
(rowYStart + (engine.menuManager.selectedMainIndex * rowStep)) - 2,
);
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
_fillRect320(28, 58, 264, 104, panelColor);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
@@ -411,50 +485,32 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final List<String> rows = engine.availableGames
.map((game) => _gameTitle(game.version))
.toList(growable: false);
if (_useMinimalMenuText) {
_drawMenuTextCentered('SELECT GAME', 48, headingColor, scale: 2);
_drawMinimalMenuRows(
rows: rows,
selectedIndex: engine.menuManager.selectedGameIndex,
rowYStart200: rowYStart,
rowStep200: rowStep,
textX320: 70,
panelX320: 28,
panelW320: 264,
selectedTextColor: selectedTextColor,
unselectedTextColor: unselectedTextColor,
panelColor: panelColor,
);
if (cursor != null) {
_blitVgaImageAscii(
cursor,
38,
(rowYStart + (engine.menuManager.selectedGameIndex * rowStep)) - 2,
);
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
_drawMenuTextCentered(
'SELECT GAME',
48,
headingColor,
scale: _fullMenuHeadingScale,
scale: menuTypography.headingScale,
);
for (int i = 0; i < rows.length; i++) {
final bool isSelected = i == engine.menuManager.selectedGameIndex;
if (isSelected && cursor != null) {
_blitVgaImageAscii(cursor, 38, (rowYStart + (i * rowStep)) - 2);
}
_drawMenuText(
rows[i],
70,
rowYStart + (i * rowStep),
isSelected ? selectedTextColor : unselectedTextColor,
_drawSelectableMenuRows(
typography: menuTypography,
rows: rows,
selectedIndex: engine.menuManager.selectedGameIndex,
rowYStart200: rowYStart,
rowStep200: rowStep,
textX320: 70,
panelX320: 28,
panelW320: 264,
colorForRow: (int _, bool isSelected) {
return isSelected ? selectedTextColor : unselectedTextColor;
},
);
if (cursor != null) {
_blitVgaImageAscii(
cursor,
38,
(rowYStart + (engine.menuManager.selectedGameIndex * rowStep)) - 2,
);
}
@@ -475,12 +531,12 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
.map((episode) => episode.name.replaceAll('\n', ' '))
.toList(growable: false);
if (_useMinimalMenuText) {
if (menuTypography.usesCompactRows) {
_drawMenuTextCentered(
'WHICH EPISODE TO PLAY?',
8,
headingColor,
scale: 2,
scale: menuTypography.headingScale,
);
_drawMinimalMenuRows(
rows: rows,
@@ -490,9 +546,10 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
textX320: 98,
panelX320: 12,
panelW320: 296,
selectedTextColor: selectedTextColor,
unselectedTextColor: unselectedTextColor,
panelColor: panelColor,
colorForRow: (int _, bool isSelected) {
return isSelected ? selectedTextColor : unselectedTextColor;
},
);
// Keep episode icons visible in compact ASCII layouts so this screen
@@ -520,7 +577,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
'WHICH EPISODE TO PLAY?',
8,
headingColor,
scale: _fullMenuHeadingScale,
scale: menuTypography.headingScale,
);
for (int i = 0; i < engine.data.episodes.length; i++) {
@@ -560,6 +617,8 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
_fillRect320(28, 70, 264, 82, panelColor);
final face = art.difficultyOption(
Difficulty.values[selectedDifficultyIndex],
);
@@ -573,18 +632,25 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
const rowYStart = 86;
const rowStep = 15;
if (_useMinimalMenuText) {
if (menuTypography.usesCompactRows) {
_drawMenuTextCentered(
Difficulty.menuText,
48,
headingColor,
scale: 2,
scale: menuTypography.headingScale,
);
_drawMinimalMenuText(
selectedDifficultyIndex,
selectedTextColor,
unselectedTextColor,
panelColor,
_drawSelectableMenuRows(
typography: menuTypography,
rows: Difficulty.values.map((d) => d.title).toList(growable: false),
selectedIndex: selectedDifficultyIndex,
rowYStart200: rowYStart,
rowStep200: rowStep,
textX320: 70,
panelX320: 28,
panelW320: 264,
colorForRow: (int _, bool isSelected) {
return isSelected ? selectedTextColor : unselectedTextColor;
},
);
if (cursor != null) {
_blitVgaImageAscii(
@@ -594,15 +660,15 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
);
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
final int headingScale = _fullMenuHeadingScale;
_drawMenuTextCentered(
Difficulty.menuText,
48,
headingColor,
scale: headingScale,
scale: menuTypography.headingScale,
);
for (int i = 0; i < Difficulty.values.length; i++) {
@@ -698,15 +764,23 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_drawMenuText(text, x320, y200, color, scale: scale);
}
int get _fullMenuHeadingScale {
_AsciiMenuTypography _resolveMenuTypography() {
final bool usesCompactRows = _menuGlyphHeightInRows(scale: 1) <= 4;
return _AsciiMenuTypography(
headingScale: usesCompactRows ? 2 : _defaultMenuHeadingScale,
rowFont: usesCompactRows
? _AsciiMenuRowFont.compactText
: _AsciiMenuRowFont.bitmap,
);
}
int get _defaultMenuHeadingScale {
if (!_usesTerminalLayout) {
return 2;
}
return projectionWidth < 140 ? 1 : 2;
}
bool get _useMinimalMenuText => _menuGlyphHeightInRows(scale: 1) <= 4;
int _menuGlyphHeightInRows({required int scale}) {
final double scaleY =
(_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0;
@@ -732,24 +806,41 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
return pixelX.clamp(0, width - 1);
}
void _drawMinimalMenuText(
int selectedDifficultyIndex,
int selectedTextColor,
int unselectedTextColor,
int panelColor,
) {
_drawMinimalMenuRows(
rows: Difficulty.values.map((d) => d.title).toList(growable: false),
selectedIndex: selectedDifficultyIndex,
rowYStart200: 86,
rowStep200: 15,
textX320: 70,
panelX320: 28,
panelW320: 264,
selectedTextColor: selectedTextColor,
unselectedTextColor: unselectedTextColor,
panelColor: panelColor,
);
void _drawSelectableMenuRows({
required _AsciiMenuTypography typography,
required List<String> rows,
required int selectedIndex,
required int rowYStart200,
required int rowStep200,
required int textX320,
required int panelX320,
required int panelW320,
required int Function(int index, bool isSelected) colorForRow,
}) {
if (typography.usesCompactRows) {
_drawMinimalMenuRows(
rows: rows,
selectedIndex: selectedIndex,
rowYStart200: rowYStart200,
rowStep200: rowStep200,
textX320: textX320,
panelX320: panelX320,
panelW320: panelW320,
panelColor: _rgbToPaletteColor(engine.menuPanelRgb),
colorForRow: colorForRow,
);
return;
}
for (int i = 0; i < rows.length; i++) {
final bool isSelected = i == selectedIndex;
_drawMenuText(
rows[i],
textX320,
rowYStart200 + (i * rowStep200),
colorForRow(i, isSelected),
);
}
}
void _drawMinimalMenuRows({
@@ -760,9 +851,8 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
required int textX320,
required int panelX320,
required int panelW320,
required int selectedTextColor,
required int unselectedTextColor,
required int panelColor,
required int Function(int index, bool isSelected) colorForRow,
}) {
final int panelX =
projectionOffsetX + ((panelX320 / 320.0) * projectionWidth).toInt();
@@ -780,7 +870,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_writeLeftClipped(
rowY,
rows[i],
isSelected ? selectedTextColor : unselectedTextColor,
colorForRow(i, isSelected),
panelColor,
textWidth,
textLeft,
@@ -816,7 +906,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
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);
final int boxY = math.max(0, height - boxHeight);
if (_usesTerminalLayout) {
_fillTerminalRect(
@@ -871,7 +961,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
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;
const int panelY = 188;
_fillRect320(panelX, panelY, panelWidth, panelHeight, hintBackground);
int cursorX = panelX + 6;
@@ -1286,6 +1376,78 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
final int barColor = ColorPalette.vga32Bit[0];
final int leftWidth = optionsX320.clamp(0, 320);
final int rightStart = (optionsX320 + optionsLabel.width).clamp(0, 320);
final int rightWidth = (320 - rightStart).clamp(0, 320);
for (int y = 0; y < optionsLabel.height; y++) {
final int leftEdge = optionsLabel.decodePixel(0, y);
final int rightEdge = optionsLabel.decodePixel(optionsLabel.width - 1, y);
if (leftEdge != 0 || rightEdge != 0) {
continue;
}
if (leftWidth > 0) {
_fillRect320Precise(0, y, leftWidth, y + 1, barColor);
}
if (rightWidth > 0) {
_fillRect320Precise(rightStart, y, 320, y + 1, barColor);
}
}
}
void _fillRect320Precise(
int startX320,
int startY200,
int endX320,
int endY200,
int color,
) {
if (endX320 <= startX320 || endY200 <= startY200) {
return;
}
final double scaleX =
(_usesTerminalLayout ? projectionWidth : width) / 320.0;
final double scaleY =
(_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0;
final int offsetX = _usesTerminalLayout ? projectionOffsetX : 0;
final int startX = offsetX + (startX320 * scaleX).floor();
final int endX = offsetX + (endX320 * scaleX).ceil();
final int startY = (startY200 * scaleY).floor();
final int endY = (endY200 * scaleY).ceil();
if (_usesTerminalLayout) {
for (int y = startY; y < endY; y++) {
if (y < 0 || y >= _terminalPixelHeight) {
continue;
}
for (int x = startX; x < endX; x++) {
if (x < 0 || x >= _terminalSceneWidth) {
continue;
}
_scenePixels[y][x] = color;
}
}
return;
}
for (int y = startY; y < endY; y++) {
if (y < 0 || y >= height) {
continue;
}
for (int x = startX; x < endX; x++) {
if (x < 0 || x >= width) {
continue;
}
_screen[y][x] = ColoredChar(activeTheme.solid, color);
}
}
}
// --- DAMAGE FLASH ---
void _applyDamageFlash() {
for (int y = 0; y < viewHeight; y++) {
@@ -77,7 +77,7 @@ abstract class RendererBackend<T>
// 1. Setup the frame (clear screen, draw floor/ceiling).
prepareFrame(engine);
if (engine.difficulty == null) {
if (engine.isMenuOpen) {
drawMenu(engine);
if (engine.showFpsCounter) {
drawFpsOverlay(engine);
@@ -21,15 +21,17 @@ import 'menu_font.dart';
/// terminal is too small.
class SixelRenderer extends CliRendererBackend<String> {
static const double _targetAspectRatio = 4 / 3;
static const int _defaultLineHeightPx = 18;
static const int _defaultLineHeightPx = 16;
static const double _defaultCellWidthToHeight = 0.55;
static const int _minimumTerminalColumns = 117;
static const int _minimumTerminalRows = 34;
static const double _terminalViewportSafety = 0.90;
static const int _terminalRowSafetyMargin = 1;
static const int _compactMenuMinWidthPx = 200;
static const int _compactMenuMinHeightPx = 130;
static const int _maxRenderWidth = 320;
static const int _maxRenderHeight = 240;
static const int _menuFooterBottomMargin = 1;
static const String _terminalTealBackground = '\x1b[48;2;0;150;136m';
late Uint8List _screen;
@@ -195,10 +197,12 @@ class SixelRenderer extends CliRendererBackend<String> {
// Horizontal: cell-width estimates vary by terminal/font and cause right-shift
// clipping, so keep the image at column 0.
// Vertical: line-height is reliable enough to center correctly.
// Vertical: use a conservative row estimate and keep one spare row so the
// terminal does not scroll the image upward when its actual cell height is
// smaller than our approximation.
final int imageRows = math.max(
1,
(_outputHeight / _defaultLineHeightPx).ceil(),
(_outputHeight / _defaultLineHeightPx).ceil() + _terminalRowSafetyMargin,
);
_offsetColumns = 0;
_offsetRows = math.max(0, (terminalRows - imageRows) ~/ 2);
@@ -358,15 +362,55 @@ class SixelRenderer extends CliRendererBackend<String> {
final int headingIndex = WolfMenuPalette.headerTextIndex;
final int selectedTextIndex = WolfMenuPalette.selectedTextIndex;
final int unselectedTextIndex = WolfMenuPalette.unselectedTextIndex;
final int disabledTextIndex = WolfMenuPalette.disabledTextIndex;
for (int i = 0; i < _screen.length; i++) {
_screen[i] = bgColor;
}
_fillRect320(28, 70, 264, 82, panelColor);
final art = WolfClassicMenuArt(engine.data);
// Draw footer first so menu panels can clip overlap in the center.
_drawMenuFooterArt(art);
if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) {
_fillRect320(68, 52, 178, 136, panelColor);
final optionsLabel = art.optionsLabel;
if (optionsLabel != null) {
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
_blitVgaImage(optionsLabel, optionsX, 0);
} else {
_drawMenuTextCentered('OPTIONS', 24, headingIndex, scale: 2);
}
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 55;
const int rowStep = 13;
final entries = engine.menuManager.mainMenuEntries;
for (int i = 0; i < entries.length; i++) {
final bool isSelected = i == engine.menuManager.selectedMainIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, 72, (rowYStart + (i * rowStep)) - 2);
}
_drawMenuText(
entries[i].label,
100,
rowYStart + (i * rowStep),
entries[i].isEnabled
? (isSelected ? selectedTextIndex : unselectedTextIndex)
: disabledTextIndex,
scale: 1,
);
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
_fillRect320(28, 58, 264, 104, panelColor);
_drawMenuTextCentered('SELECT GAME', 48, headingIndex, scale: 2);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
@@ -386,7 +430,6 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: 1,
);
}
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
@@ -434,13 +477,13 @@ class SixelRenderer extends CliRendererBackend<String> {
);
}
}
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
_fillRect320(28, 70, 264, 82, panelColor);
if (_useCompactMenuLayout) {
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
@@ -454,11 +497,6 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: _menuHeadingScale,
);
final bottom = art.mappedPic(15);
if (bottom != null) {
_blitVgaImage(bottom, (320 - bottom.width) ~/ 2, 200 - bottom.height - 8);
}
final face = art.difficultyOption(
Difficulty.values[selectedDifficultyIndex],
);
@@ -489,7 +527,6 @@ class SixelRenderer extends CliRendererBackend<String> {
);
}
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
}
@@ -498,7 +535,11 @@ class SixelRenderer extends CliRendererBackend<String> {
if (bottom == null) {
return;
}
_blitVgaImage(bottom, (320 - bottom.width) ~/ 2, 200 - bottom.height - 8);
_blitVgaImage(
bottom,
(320 - bottom.width) ~/ 2,
200 - bottom.height - _menuFooterBottomMargin,
);
}
String _gameTitle(GameVersion version) {
@@ -809,8 +850,14 @@ class SixelRenderer extends CliRendererBackend<String> {
int drawY = destStartY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1);
int srcY = (dy / scaleY).toInt().clamp(0, image.height - 1);
int srcX = ((dx / destWidth) * image.width).toInt().clamp(
0,
image.width - 1,
);
int srcY = ((dy / destHeight) * image.height).toInt().clamp(
0,
image.height - 1,
);
int colorByte = image.decodePixel(srcX, srcY);
if (colorByte != 255) {
@@ -842,6 +889,60 @@ class SixelRenderer extends CliRendererBackend<String> {
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
const int barColor = 0;
final int leftWidth = optionsX320.clamp(0, 320);
final int rightStart = (optionsX320 + optionsLabel.width).clamp(0, 320);
final int rightWidth = (320 - rightStart).clamp(0, 320);
for (int y = 0; y < optionsLabel.height; y++) {
final int leftEdge = optionsLabel.decodePixel(0, y);
final int rightEdge = optionsLabel.decodePixel(optionsLabel.width - 1, y);
if (leftEdge != 0 || rightEdge != 0) {
continue;
}
if (leftWidth > 0) {
_fillRect320Precise(0, y, leftWidth, y + 1, barColor);
}
if (rightWidth > 0) {
_fillRect320Precise(rightStart, y, 320, y + 1, barColor);
}
}
}
void _fillRect320Precise(
int startX320,
int startY200,
int endX320,
int endY200,
int colorIndex,
) {
if (endX320 <= startX320 || endY200 <= startY200) {
return;
}
final double scaleX = width / 320.0;
final double scaleY = height / 200.0;
final int startX = (startX320 * scaleX).floor();
final int endX = (endX320 * scaleX).ceil();
final int startY = (startY200 * scaleY).floor();
final int endY = (endY200 * scaleY).ceil();
for (int y = startY; y < endY; y++) {
if (y < 0 || y >= height) {
continue;
}
final int rowOffset = y * width;
for (int x = startX; x < endX; x++) {
if (x < 0 || x >= width) {
continue;
}
_screen[rowOffset + x] = colorIndex;
}
}
}
/// Maps an RGB color to the nearest VGA palette index.
int _rgbToPaletteIndex(int rgb) {
return ColorPalette.findClosestPaletteIndex(rgb);
@@ -12,6 +12,7 @@ import 'package:wolf_3d_dart/wolf_3d_menu.dart';
/// This is the canonical "modern framebuffer" implementation and serves as a
/// visual reference for terminal renderers.
class SoftwareRenderer extends RendererBackend<FrameBuffer> {
static const int _menuFooterBottomMargin = 1;
static const int _menuFooterY = 184;
static const int _menuFooterHeight = 12;
@@ -147,13 +148,28 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
final int headingColor = WolfMenuPalette.headerTextColor;
final int selectedTextColor = WolfMenuPalette.selectedTextColor;
final int unselectedTextColor = WolfMenuPalette.unselectedTextColor;
final int disabledTextColor = WolfMenuPalette.disabledTextColor;
for (int i = 0; i < _buffer.pixels.length; i++) {
_buffer.pixels[i] = bgColor;
}
final art = WolfClassicMenuArt(engine.data);
// Draw footer first so menu panels can clip overlap in the center.
_drawCenteredMenuFooter(art);
switch (engine.menuManager.activeMenu) {
case WolfMenuScreen.mainMenu:
_drawMainMenu(
engine,
art,
panelColor,
headingColor,
selectedTextColor,
unselectedTextColor,
disabledTextColor,
);
break;
case WolfMenuScreen.gameSelect:
_drawGameSelectMenu(
engine,
@@ -186,11 +202,59 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
break;
}
_drawCenteredMenuFooter(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
}
void _drawMainMenu(
WolfEngine engine,
WolfClassicMenuArt art,
int panelColor,
int headingColor,
int selectedTextColor,
int unselectedTextColor,
int disabledTextColor,
) {
const int panelX = 68;
const int panelY = 52;
const int panelW = 178;
const int panelH = 136;
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
final optionsLabel = art.optionsLabel;
if (optionsLabel != null) {
final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
_blitVgaImage(optionsLabel, optionsX, 0);
} else {
_drawCanonicalMenuTextCentered('OPTIONS', 24, headingColor, scale: 2);
}
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 55;
const int rowStep = 13;
const int textX = 100;
final entries = engine.menuManager.mainMenuEntries;
final int selectedIndex = engine.menuManager.selectedMainIndex;
for (int i = 0; i < entries.length; i++) {
final bool isSelected = i == selectedIndex;
final int y = rowYStart + (i * rowStep);
if (isSelected && cursor != null) {
_blitVgaImage(cursor, panelX + 4, y - 2);
}
_drawCanonicalMenuText(
entries[i].label,
textX,
y,
entries[i].isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor,
);
}
}
void _drawGameSelectMenu(
WolfEngine engine,
WolfClassicMenuArt art,
@@ -298,7 +362,10 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
final bottom = art.mappedPic(15);
if (bottom != null) {
final int x = ((320 - bottom.width) ~/ 2).clamp(0, 319);
final int y = (200 - bottom.height - 8).clamp(0, 199);
final int y = (200 - bottom.height - _menuFooterBottomMargin).clamp(
0,
199,
);
_blitVgaImage(bottom, x, y);
return;
}
@@ -362,13 +429,6 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
scale: 2,
);
final bottom = art.mappedPic(15);
if (bottom != null) {
final int x = ((320 - bottom.width) ~/ 2).clamp(0, 319);
final int y = (200 - bottom.height - 8).clamp(0, 199);
_blitVgaImage(bottom, x, y);
}
final face = art.difficultyOption(
Difficulty.values[selectedDifficultyIndex],
);
@@ -490,6 +550,28 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
final int barColor = ColorPalette.vga32Bit[0];
final int leftWidth = optionsX320.clamp(0, 320);
final int rightStart = (optionsX320 + optionsLabel.width).clamp(0, 320);
final int rightWidth = (320 - rightStart).clamp(0, 320);
for (int y = 0; y < optionsLabel.height; y++) {
final int leftEdge = optionsLabel.decodePixel(0, y);
final int rightEdge = optionsLabel.decodePixel(optionsLabel.width - 1, y);
if (leftEdge != 0 || rightEdge != 0) {
continue;
}
if (leftWidth > 0) {
_fillCanonicalRect(0, y, leftWidth, 1, barColor);
}
if (rightWidth > 0) {
_fillCanonicalRect(rightStart, y, rightWidth, 1, barColor);
}
}
}
void _drawCanonicalMenuText(
String text,
int startX320,