feat: Enhance menu rendering and input handling

- Added support for new plugins: IrondashEngineContext and SuperNativeExtensions in the Flutter plugin registrant.
- Updated CMake configuration to include new plugins.
- Introduced a new dependency, super_clipboard, in pubspec.yaml.
- Enhanced the WolfEngine to set the menu background color.
- Implemented keyboard shortcuts for renderer mode toggling and ASCII theme cycling in CLI input handling.
- Updated menu manager to include a universal menu background color.
- Refactored ASCII and Sixel renderers to utilize the new menu background color and improved header drawing logic.
- Simplified the drawing of menu options sidebars and header bars across different renderers.
- Improved the layout and centering of menu titles in the header bar.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 14:46:08 +01:00
parent 297f6f0260
commit 4d5b30f007
15 changed files with 740 additions and 324 deletions

View File

@@ -43,6 +43,7 @@ class WolfEngine {
if (_availableGames.isEmpty) {
throw StateError('WolfEngine requires at least one game data set.');
}
menuManager.menuBackgroundRgb = menuBackgroundRgb;
}
/// Total milliseconds elapsed since the engine was initialized.

View File

@@ -6,6 +6,60 @@ import 'package:wolf_3d_dart/wolf_3d_entities.dart';
/// Buffers one-frame terminal key presses for consumption by the engine loop.
class CliInput extends Wolf3dInput {
/// Keyboard shortcut used by the CLI host to cycle renderer modes.
String rendererToggleKey = 'r';
/// Keyboard shortcut used by the CLI host to cycle ASCII themes.
String asciiThemeCycleKey = 't';
/// Human-friendly label for [rendererToggleKey] shown in CLI hints.
String get rendererToggleKeyLabel => _formatShortcutLabel(rendererToggleKey);
/// Human-friendly label for [asciiThemeCycleKey] shown in CLI hints.
String get asciiThemeCycleKeyLabel =>
_formatShortcutLabel(asciiThemeCycleKey);
/// Returns true when [bytes] triggers the renderer-toggle shortcut.
bool matchesRendererToggleShortcut(List<int> bytes) =>
_matchesShortcut(bytes, rendererToggleKey);
/// Returns true when [bytes] triggers the ASCII-theme shortcut.
bool matchesAsciiThemeCycleShortcut(List<int> bytes) =>
_matchesShortcut(bytes, asciiThemeCycleKey);
String _formatShortcutLabel(String key) {
final String trimmed = key.trim();
if (trimmed.isEmpty) {
return 'KEY';
}
if (trimmed == ' ') {
return 'SPACE';
}
return trimmed.toUpperCase();
}
bool _matchesShortcut(List<int> bytes, String key) {
final String trimmed = key.trim();
if (trimmed.isEmpty) {
return false;
}
if (trimmed == ' ') {
return bytes.length == 1 && bytes[0] == 32;
}
if (bytes.length != 1) {
return false;
}
final int expected = trimmed.codeUnitAt(0);
final int actual = bytes[0];
return actual == expected ||
actual ==
(expected >= 97 && expected <= 122 ? expected - 32 : expected) ||
actual == (expected >= 65 && expected <= 90 ? expected + 32 : expected);
}
// Raw stdin arrives asynchronously, so presses are staged here until the
// next engine frame snapshots them into the active state.
bool _pForward = false;

View File

@@ -93,6 +93,9 @@ class MenuManager {
bool _prevConfirm = false;
bool _prevBack = false;
/// Universal menu background color in 24-bit RGB used by menu screens.
int menuBackgroundRgb = 0x890000;
WolfMenuScreen get activeMenu => _activeMenu;
bool get isTransitioning => _transitionTarget != null;

View File

@@ -110,6 +110,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
static const int _menuHintKeyPaletteIndex = 12;
static const int _menuHintLabelPaletteIndex = 4;
static const int _menuHintBackgroundPaletteIndex = 0;
static const int _headerHeadingY = 24;
AsciiRenderer({
this.activeTheme = AsciiThemes.blocks,
@@ -129,6 +130,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
late List<List<ColoredChar>> _screen;
late List<List<int>> _scenePixels;
List<int>? _mainMenuBandFirstColumn;
String? _lastLoggedThemeName;
static const List<String> _quadrantByMask = <String>[
@@ -403,7 +405,9 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
@override
void drawMenu(WolfEngine engine) {
final int bgColor = _rgbToPaletteColor(engine.menuBackgroundRgb);
final int bgColor = _rgbToPaletteColor(
engine.menuManager.menuBackgroundRgb,
);
final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb);
final int headingColor = WolfMenuPalette.headerTextColor;
final int selectedTextColor = WolfMenuPalette.selectedTextColor;
@@ -418,6 +422,10 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
final art = WolfClassicMenuArt(engine.data);
final optionsLabel = art.optionsLabel;
if (optionsLabel != null) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
}
if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) {
_drawIntroSplash(engine, art, menuTypography);
@@ -433,9 +441,14 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
_blitVgaImageAscii(optionsLabel, optionsX, 0);
} else {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: ColorPalette.vga32Bit[0],
);
_drawMenuTextCentered(
'OPTIONS',
24,
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
@@ -480,6 +493,11 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: ColorPalette.vga32Bit[0],
);
_fillRect320(28, 58, 264, 104, panelColor);
final cursor = art.mappedPic(
@@ -492,7 +510,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
.toList(growable: false);
_drawMenuTextCentered(
'SELECT GAME',
48,
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
@@ -525,6 +543,11 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.episodeSelect) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: ColorPalette.vga32Bit[0],
);
_fillRect320(12, 18, 296, 168, panelColor);
final cursor = art.mappedPic(
@@ -539,7 +562,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
if (menuTypography.usesCompactRows) {
_drawMenuTextCentered(
'WHICH EPISODE TO PLAY?',
8,
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
@@ -580,7 +603,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_drawMenuTextCentered(
'WHICH EPISODE TO PLAY?',
8,
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
@@ -622,6 +645,12 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: ColorPalette.vga32Bit[0],
);
_fillRect320(28, 70, 264, 82, panelColor);
final face = art.difficultyOption(
@@ -640,7 +669,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
if (menuTypography.usesCompactRows) {
_drawMenuTextCentered(
Difficulty.menuText,
48,
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
@@ -671,7 +700,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_drawMenuTextCentered(
Difficulty.menuText,
48,
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
@@ -719,7 +748,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
};
int splashBg = _rgbToPaletteColor(engine.menuManager.introBackgroundRgb);
int splashBg = _rgbToPaletteColor(engine.menuManager.menuBackgroundRgb);
if (engine.menuManager.isIntroPg13Slide &&
image != null &&
image.pixels.isNotEmpty) {
@@ -759,16 +788,18 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final int black = ColorPalette.vga32Bit[0];
final int yellow = ColorPalette.vga32Bit[14];
final int white = ColorPalette.vga32Bit[15];
final int lineColor = ColorPalette.vga32Bit[4];
_fillRect320(0, 0, 320, 22, black);
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: backgroundColor,
barColor: black,
);
_drawMenuTextCentered(
'Attention',
6,
_headerHeadingY,
yellow,
scale: menuTypography.headingScale,
);
_fillRect320(0, 23, 320, 1, lineColor);
if (menuTypography.usesCompactRows) {
final int textLeft = _menuX320ToColumn(40);
@@ -823,6 +854,56 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
return bestIndex;
}
void _drawHeaderBarStack({
required int headingY200,
required int backgroundColor,
required int barColor,
}) {
final List<int>? cachedColumn = _mainMenuBandFirstColumn;
if (cachedColumn != null && cachedColumn.isNotEmpty) {
final int bandHeight = cachedColumn.length.clamp(0, 200);
for (int y = 0; y < bandHeight; y++) {
final int paletteIndex = cachedColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillRect320(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]);
}
return;
}
final int mainBarTop = (headingY200 - 4).clamp(0, 199);
_fillRect320(0, mainBarTop, 320, 18, barColor);
int stripeY = mainBarTop + 18;
for (int i = 0; i < 4; i++) {
_fillRect320(
0,
(stripeY + i).clamp(0, 199),
320,
1,
i.isEven ? barColor : backgroundColor,
);
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
final List<int> firstColumn = _mainMenuBandFirstColumn!;
for (int y = 0; y < optionsLabel.height; y++) {
final int paletteIndex = firstColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillRect320(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]);
}
}
List<int> _cacheFirstColumn(VgaImage image) {
final List<int> column = List<int>.filled(image.height, 0);
for (int y = 0; y < image.height; y++) {
column[y] = image.decodePixel(0, y);
}
return column;
}
void _applyMenuFade(double alpha, int fadeColor) {
if (alpha <= 0.0) {
return;
@@ -891,11 +972,52 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
int color, {
int scale = 1,
}) {
if (y200 == _headerHeadingY) {
y200 = _centerHeaderTitleInBlackBand(defaultY: y200, scale: 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 _centerHeaderTitleInBlackBand({
required int defaultY,
required int scale,
}) {
final List<int>? column = _mainMenuBandFirstColumn;
if (column == null || column.isEmpty) {
return defaultY;
}
int bestStart = -1;
int bestLength = 0;
int runStart = -1;
for (int i = 0; i <= column.length; i++) {
final bool isBlack = i < column.length && column[i] == 0;
if (isBlack) {
runStart = runStart == -1 ? i : runStart;
continue;
}
if (runStart != -1) {
final int runLength = i - runStart;
if (runLength > bestLength) {
bestLength = runLength;
bestStart = runStart;
}
runStart = -1;
}
}
if (bestStart == -1 || bestLength == 0) {
return defaultY;
}
final int textHeight = 7 * scale;
final int centered = bestStart + ((bestLength - textHeight) ~/ 2);
return centered.clamp(0, 199 - textHeight);
}
_AsciiMenuTypography _resolveMenuTypography() {
final bool usesCompactRows = _menuGlyphHeightInRows(scale: 1) <= 4;
return _AsciiMenuTypography(
@@ -1508,78 +1630,6 @@ 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++) {

View File

@@ -6,6 +6,7 @@ import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:wolf_3d_dart/src/input/cli_input.dart';
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
@@ -32,9 +33,11 @@ class SixelRenderer extends CliRendererBackend<String> {
static const int _maxRenderWidth = 320;
static const int _maxRenderHeight = 240;
static const int _menuFooterBottomMargin = 1;
static const int _headerHeadingY = 24;
static const String _terminalTealBackground = '\x1b[48;2;0;150;136m';
late Uint8List _screen;
List<int>? _mainMenuBandFirstColumn;
int _offsetColumns = 0;
int _offsetRows = 0;
int _outputWidth = 1;
@@ -357,7 +360,9 @@ class SixelRenderer extends CliRendererBackend<String> {
@override
void drawMenu(WolfEngine engine) {
final int bgColor = _rgbToPaletteIndex(engine.menuBackgroundRgb);
final int bgColor = _rgbToPaletteIndex(
engine.menuManager.menuBackgroundRgb,
);
final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb);
final int headingIndex = WolfMenuPalette.headerTextIndex;
final int selectedTextIndex = WolfMenuPalette.selectedTextIndex;
@@ -369,6 +374,10 @@ class SixelRenderer extends CliRendererBackend<String> {
}
final art = WolfClassicMenuArt(engine.data);
final optionsLabel = art.optionsLabel;
if (optionsLabel != null) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
}
// Draw footer first so menu panels can clip overlap in the center.
_drawMenuFooterArt(art);
@@ -386,7 +395,17 @@ class SixelRenderer extends CliRendererBackend<String> {
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
_blitVgaImage(optionsLabel, optionsX, 0);
} else {
_drawMenuTextCentered('OPTIONS', 24, headingIndex, scale: 2);
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: 0,
);
_drawMenuTextCentered(
'OPTIONS',
_headerHeadingY,
headingIndex,
scale: 2,
);
}
final cursor = art.mappedPic(
@@ -415,8 +434,18 @@ class SixelRenderer extends CliRendererBackend<String> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: 0,
);
_fillRect320(28, 58, 264, 104, panelColor);
_drawMenuTextCentered('SELECT GAME', 48, headingIndex, scale: 2);
_drawMenuTextCentered(
'SELECT GAME',
_headerHeadingY,
headingIndex,
scale: 2,
);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
@@ -440,10 +469,15 @@ class SixelRenderer extends CliRendererBackend<String> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.episodeSelect) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: 0,
);
_fillRect320(12, 20, 296, 158, panelColor);
_drawMenuTextCentered(
'WHICH EPISODE TO PLAY?',
6,
_headerHeadingY,
headingIndex,
scale: 2,
);
@@ -488,6 +522,11 @@ class SixelRenderer extends CliRendererBackend<String> {
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: 0,
);
_fillRect320(28, 70, 264, 82, panelColor);
if (_useCompactMenuLayout) {
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
@@ -497,7 +536,7 @@ class SixelRenderer extends CliRendererBackend<String> {
_drawMenuTextCentered(
Difficulty.menuText,
48,
_headerHeadingY,
headingIndex,
scale: _menuHeadingScale,
);
@@ -567,7 +606,7 @@ class SixelRenderer extends CliRendererBackend<String> {
WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
};
int splashBg = _rgbToPaletteIndex(engine.menuManager.introBackgroundRgb);
int splashBg = _rgbToPaletteIndex(engine.menuManager.menuBackgroundRgb);
if (engine.menuManager.isIntroPg13Slide &&
image != null &&
image.pixels.isNotEmpty) {
@@ -602,11 +641,13 @@ class SixelRenderer extends CliRendererBackend<String> {
const int black = 0;
const int yellow = 14;
const int white = 15;
const int lineColor = 4;
_fillRect320(0, 0, 320, 22, black);
_drawMenuTextCentered('Attention', 6, yellow, scale: 2);
_fillRect320(0, 23, 320, 1, lineColor);
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: backgroundColor,
barColor: black,
);
_drawMenuTextCentered('Attention', _headerHeadingY, yellow, scale: 2);
_drawMenuText('This game is NOT shareware.', 40, 56, white, scale: 1);
_drawMenuText('Please do not distribute it.', 40, 68, white, scale: 1);
@@ -650,6 +691,56 @@ class SixelRenderer extends CliRendererBackend<String> {
}
}
void _drawHeaderBarStack({
required int headingY200,
required int backgroundColor,
required int barColor,
}) {
final List<int>? cachedColumn = _mainMenuBandFirstColumn;
if (cachedColumn != null && cachedColumn.isNotEmpty) {
final int bandHeight = cachedColumn.length.clamp(0, 200);
for (int y = 0; y < bandHeight; y++) {
final int paletteIndex = cachedColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillRect320(0, y, 320, 1, fillIndex);
}
return;
}
final int mainBarTop = (headingY200 - 4).clamp(0, 199);
_fillRect320(0, mainBarTop, 320, 18, barColor);
int stripeY = mainBarTop + 18;
for (int i = 0; i < 4; i++) {
_fillRect320(
0,
(stripeY + i).clamp(0, 199),
320,
1,
i.isEven ? barColor : backgroundColor,
);
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
final List<int> firstColumn = _mainMenuBandFirstColumn!;
for (int y = 0; y < optionsLabel.height; y++) {
final int paletteIndex = firstColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillRect320(0, y, 320, 1, fillIndex);
}
}
List<int> _cacheFirstColumn(VgaImage image) {
final List<int> column = List<int>.filled(image.height, 0);
for (int y = 0; y < image.height; y++) {
column[y] = image.decodePixel(0, y);
}
return column;
}
bool get _useCompactMenuLayout =>
width < _compactMenuMinWidthPx || height < _compactMenuMinHeightPx;
@@ -730,11 +821,52 @@ class SixelRenderer extends CliRendererBackend<String> {
int colorIndex, {
int scale = 1,
}) {
if (y == _headerHeadingY) {
y = _centerHeaderTitleInBlackBand(defaultY: y, scale: scale);
}
final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
final int x = ((320 - textWidth) ~/ 2).clamp(0, 319);
_drawMenuText(text, x, y, colorIndex, scale: scale);
}
int _centerHeaderTitleInBlackBand({
required int defaultY,
required int scale,
}) {
final List<int>? column = _mainMenuBandFirstColumn;
if (column == null || column.isEmpty) {
return defaultY;
}
int bestStart = -1;
int bestLength = 0;
int runStart = -1;
for (int i = 0; i <= column.length; i++) {
final bool isBlack = i < column.length && column[i] == 0;
if (isBlack) {
runStart = runStart == -1 ? i : runStart;
continue;
}
if (runStart != -1) {
final int runLength = i - runStart;
if (runLength > bestLength) {
bestLength = runLength;
bestStart = runStart;
}
runStart = -1;
}
}
if (bestStart == -1 || bestLength == 0) {
return defaultY;
}
final int textHeight = 7 * scale;
final int centered = bestStart + ((bestLength - textHeight) ~/ 2);
return centered.clamp(0, 199 - textHeight);
}
@override
String finalizeFrame() {
if (!isSixelSupported) {
@@ -764,7 +896,10 @@ class SixelRenderer extends CliRendererBackend<String> {
} catch (_) {}
const String msg1 = "Terminal does not support Sixel.";
const String msg2 = "Press TAB to switch renderers.";
final String shortcutLabel = engine.input is CliInput
? (engine.input as CliInput).rendererToggleKeyLabel
: 'TAB';
final String msg2 = "Press $shortcutLabel to switch renderers.";
final int boxWidth = math.max(msg1.length, msg2.length) + 6;
const int boxHeight = 5;
@@ -969,60 +1104,6 @@ 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);

View File

@@ -15,8 +15,10 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
static const int _menuFooterBottomMargin = 1;
static const int _menuFooterY = 184;
static const int _menuFooterHeight = 12;
static const int _headerHeadingY = 24;
late FrameBuffer _buffer;
List<int>? _mainMenuBandFirstColumn;
@override
void prepareFrame(WolfEngine engine) {
@@ -143,7 +145,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
@override
void drawMenu(WolfEngine engine) {
final int bgColor = _rgbToFrameColor(engine.menuBackgroundRgb);
final int bgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb);
final int panelColor = _rgbToFrameColor(engine.menuPanelRgb);
final int headingColor = WolfMenuPalette.headerTextColor;
final int selectedTextColor = WolfMenuPalette.selectedTextColor;
@@ -155,6 +157,10 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
}
final art = WolfClassicMenuArt(engine.data);
final optionsLabel = art.optionsLabel;
if (optionsLabel != null) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
}
// Draw footer first so menu panels can clip overlap in the center.
_drawCenteredMenuFooter(art);
@@ -215,7 +221,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
};
int splashBgColor = _rgbToFrameColor(engine.menuManager.introBackgroundRgb);
int splashBgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb);
int? matteIndex;
if (engine.menuManager.isIntroPg13Slide &&
image != null &&
@@ -257,11 +263,18 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
final int black = ColorPalette.vga32Bit[0];
final int yellow = ColorPalette.vga32Bit[14];
final int white = ColorPalette.vga32Bit[15];
final int lineColor = ColorPalette.vga32Bit[4];
_fillCanonicalRect(0, 0, 320, 22, black);
_drawCanonicalMenuTextCentered('Attention', 6, yellow, scale: 2);
_fillCanonicalRect(0, 23, 320, 1, lineColor);
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: backgroundColor,
barColor: black,
);
_drawCanonicalMenuTextCentered(
'Attention',
_headerHeadingY,
yellow,
scale: 2,
);
_drawCanonicalMenuText(
'This game is NOT shareware.',
@@ -358,7 +371,17 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
_blitVgaImage(optionsLabel, optionsX, 0);
} else {
_drawCanonicalMenuTextCentered('OPTIONS', 24, headingColor, scale: 2);
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
_drawCanonicalMenuTextCentered(
'OPTIONS',
_headerHeadingY,
headingColor,
scale: 2,
);
}
final cursor = art.mappedPic(
@@ -395,13 +418,24 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
int selectedTextColor,
int unselectedTextColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
const int panelX = 28;
const int panelY = 58;
const int panelW = 264;
const int panelH = 104;
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
_drawCanonicalMenuTextCentered('SELECT GAME', 38, headingColor, scale: 2);
_drawCanonicalMenuTextCentered(
'SELECT GAME',
_headerHeadingY,
headingColor,
scale: 2,
);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
@@ -435,6 +469,12 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
int selectedTextColor,
int unselectedTextColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
const int panelX = 12;
const int panelY = 20;
const int panelW = 296;
@@ -443,7 +483,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
_drawCanonicalMenuTextCentered(
'WHICH EPISODE TO PLAY?',
6,
_headerHeadingY,
headingColor,
scale: 2,
);
@@ -546,6 +586,12 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
int selectedTextColor,
int unselectedTextColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
const int panelX = 28;
@@ -556,7 +602,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
_drawCanonicalMenuTextCentered(
Difficulty.menuText,
48,
_headerHeadingY,
headingColor,
scale: 2,
);
@@ -655,6 +701,38 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
void _drawHeaderBarStack({
required int headingY200,
required int backgroundColor,
required int barColor,
}) {
final List<int>? cachedColumn = _mainMenuBandFirstColumn;
if (cachedColumn != null && cachedColumn.isNotEmpty) {
final int bandHeight = cachedColumn.length.clamp(0, 200);
for (int y = 0; y < bandHeight; y++) {
final int paletteIndex = cachedColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillCanonicalRect(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]);
}
return;
}
final int mainBarTop = (headingY200 - 4).clamp(0, 199);
_fillCanonicalRect(0, mainBarTop, 320, 18, barColor);
int stripeY = mainBarTop + 18;
for (int i = 0; i < 4; i++) {
_fillCanonicalRect(
0,
(stripeY + i).clamp(0, 199),
320,
1,
i.isEven ? barColor : backgroundColor,
);
}
}
double get _uiScaleX => width / 320.0;
double get _uiScaleY => height / 200.0;
@@ -683,27 +761,23 @@ 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);
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
final List<int> firstColumn = _mainMenuBandFirstColumn!;
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);
}
final int paletteIndex = firstColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillCanonicalRect(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]);
}
}
List<int> _cacheFirstColumn(VgaImage image) {
final List<int> column = List<int>.filled(image.height, 0);
for (int y = 0; y < image.height; y++) {
column[y] = image.decodePixel(0, y);
}
return column;
}
void _drawCanonicalMenuText(
String text,
int startX320,
@@ -740,11 +814,52 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
int color, {
int scale = 1,
}) {
if (y200 == _headerHeadingY) {
y200 = _centerHeaderTitleInBlackBand(defaultY: y200, scale: scale);
}
final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
final int x320 = ((320 - textWidth) ~/ 2).clamp(0, 319);
_drawCanonicalMenuText(text, x320, y200, color, scale: scale);
}
int _centerHeaderTitleInBlackBand({
required int defaultY,
required int scale,
}) {
final List<int>? column = _mainMenuBandFirstColumn;
if (column == null || column.isEmpty) {
return defaultY;
}
int bestStart = -1;
int bestLength = 0;
int runStart = -1;
for (int i = 0; i <= column.length; i++) {
final bool isBlack = i < column.length && column[i] == 0;
if (isBlack) {
runStart = runStart == -1 ? i : runStart;
continue;
}
if (runStart != -1) {
final int runLength = i - runStart;
if (runLength > bestLength) {
bestLength = runLength;
bestStart = runStart;
}
runStart = -1;
}
}
if (bestStart == -1 || bestLength == 0) {
return defaultY;
}
final int textHeight = 7 * scale;
final int centered = bestStart + ((bestLength - textHeight) ~/ 2);
return centered.clamp(0, 199 - textHeight);
}
/// Draws bitmap menu text directly into the framebuffer.
void _drawMenuText(
String text,