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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user