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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user