From 4d5b30f0071954eaee4b4e05a26ae44dce0ba92d Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Fri, 20 Mar 2026 14:46:08 +0100 Subject: [PATCH] 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 --- apps/wolf_3d_cli/lib/cli_game_loop.dart | 39 +++- .../lib/screens/debug_tools_screen.dart | 66 ++++++ .../lib/screens/episode_screen.dart | 127 ---------- apps/wolf_3d_gui/lib/screens/game_screen.dart | 14 ++ .../lib/screens/sprite_gallery.dart | 8 +- apps/wolf_3d_gui/lib/screens/vga_gallery.dart | 143 ++++++++++-- .../flutter/generated_plugin_registrant.cc | 8 + .../linux/flutter/generated_plugins.cmake | 2 + apps/wolf_3d_gui/pubspec.yaml | 1 + .../lib/src/engine/wolf_3d_engine_base.dart | 1 + .../wolf_3d_dart/lib/src/input/cli_input.dart | 54 +++++ .../lib/src/menu/menu_manager.dart | 3 + .../lib/src/rendering/ascii_renderer.dart | 218 +++++++++++------- .../lib/src/rendering/sixel_renderer.dart | 211 +++++++++++------ .../lib/src/rendering/software_renderer.dart | 169 +++++++++++--- 15 files changed, 740 insertions(+), 324 deletions(-) create mode 100644 apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart delete mode 100644 apps/wolf_3d_gui/lib/screens/episode_screen.dart diff --git a/apps/wolf_3d_cli/lib/cli_game_loop.dart b/apps/wolf_3d_cli/lib/cli_game_loop.dart index 34ace6a..5e2facc 100644 --- a/apps/wolf_3d_cli/lib/cli_game_loop.dart +++ b/apps/wolf_3d_cli/lib/cli_game_loop.dart @@ -115,9 +115,8 @@ class CliGameLoop { return; } - if (bytes.contains(9)) { - // Tab swaps between renderers so renderer debugging stays available - // without restarting the process. + if (input.matchesRendererToggleShortcut(bytes)) { + // Allow dynamic renderer-switch bindings configured on the CLI input. _renderer = identical(_renderer, secondaryRenderer) ? primaryRenderer : secondaryRenderer; @@ -125,7 +124,7 @@ class CliGameLoop { return; } - if (bytes.contains(116) || bytes.contains(84)) { + if (input.matchesAsciiThemeCycleShortcut(bytes)) { _cycleAsciiTheme(); return; } @@ -188,5 +187,37 @@ class CliGameLoop { engine.tick(elapsed); stdout.write(_renderer.render(engine)); + _writeShortcutHintLine(); + } + + void _writeShortcutHintLine() { + if (!stdout.hasTerminal) { + return; + } + + final int cols = stdout.terminalColumns; + if (cols <= 0) { + return; + } + + final String hint = _buildShortcutHintText(); + final String visible = hint.length > cols ? hint.substring(0, cols) : hint; + final String padded = visible.padRight(cols); + + // Draw an overlay line without disturbing the renderer's cursor position. + stdout.write('\x1b[s\x1b[1;1H\x1b[0m\x1b[2m$padded\x1b[0m\x1b[u'); + } + + String _buildShortcutHintText() { + final String rendererMode = _renderer is SixelRenderer ? 'sixel' : 'ascii'; + final String rendererHint = + '<${input.rendererToggleKeyLabel}> $rendererMode'; + + if (_renderer is AsciiRenderer) { + final AsciiRenderer ascii = _renderer as AsciiRenderer; + return '$rendererHint <${input.asciiThemeCycleKeyLabel}> ${ascii.activeTheme.name}'; + } + + return rendererHint; } } diff --git a/apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart b/apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart new file mode 100644 index 0000000..01e9857 --- /dev/null +++ b/apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart @@ -0,0 +1,66 @@ +/// Debug tools launcher for art and asset inspection screens. +library; + +import 'package:flutter/material.dart'; +import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; +import 'package:wolf_3d_gui/screens/sprite_gallery.dart'; +import 'package:wolf_3d_gui/screens/vga_gallery.dart'; + +/// Presents debug-only navigation shortcuts for asset galleries. +class DebugToolsScreen extends StatelessWidget { + /// Shared app facade used to access active game assets. + final Wolf3d wolf3d; + + /// Creates the debug tools screen for [wolf3d]. + const DebugToolsScreen({super.key, required this.wolf3d}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Debug Tools'), + automaticallyImplyLeading: true, + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text( + 'Asset Galleries', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + Card( + child: ListTile( + leading: const Icon(Icons.image_search), + title: const Text('Sprite Gallery'), + subtitle: const Text('Browse decoded sprite frames.'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SpriteGallery(wolf3d: wolf3d), + ), + ); + }, + ), + ), + Card( + child: ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('VGA Gallery'), + subtitle: const Text('Browse decoded VGA images.'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VgaGallery(images: wolf3d.vgaImages), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/apps/wolf_3d_gui/lib/screens/episode_screen.dart b/apps/wolf_3d_gui/lib/screens/episode_screen.dart deleted file mode 100644 index 2309315..0000000 --- a/apps/wolf_3d_gui/lib/screens/episode_screen.dart +++ /dev/null @@ -1,127 +0,0 @@ -/// Episode picker and asset-browser entry point for the selected game version. -library; - -import 'package:flutter/material.dart'; -import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; -import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; -import 'package:wolf_3d_gui/screens/game_screen.dart'; -import 'package:wolf_3d_gui/screens/sprite_gallery.dart'; -import 'package:wolf_3d_gui/screens/vga_gallery.dart'; - -/// Presents the episode list and shortcuts into the asset gallery screens. -class EpisodeScreen extends StatefulWidget { - /// Shared application facade whose active game must already be set. - final Wolf3d wolf3d; - - /// Creates the episode-selection screen for [wolf3d]. - const EpisodeScreen({super.key, required this.wolf3d}); - - @override - State createState() => _EpisodeScreenState(); -} - -class _EpisodeScreenState extends State { - @override - void initState() { - super.initState(); - widget.wolf3d.audio.playMenuMusic(); - } - - /// Persists the chosen episode and lets the engine present difficulty select. - void _selectEpisode(int index) { - widget.wolf3d.setActiveEpisode(index); - widget.wolf3d.clearActiveDifficulty(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => GameScreen(wolf3d: widget.wolf3d), - ), - ); - } - - @override - Widget build(BuildContext context) { - final List episodes = widget.wolf3d.activeGame.episodes; - - return Scaffold( - backgroundColor: Colors.teal, - floatingActionButtonLocation: - FloatingActionButtonLocation.miniCenterFloat, - floatingActionButton: Row( - children: [ - ElevatedButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return VgaGallery(images: widget.wolf3d.vgaImages); - }, - ), - ); - }, - child: Text('VGA Gallery'), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return SpriteGallery( - wolf3d: widget.wolf3d, - ); - }, - ), - ); - }, - child: Text('Sprites'), - ), - ], - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'WHICH EPISODE TO PLAY?', - style: TextStyle( - color: Colors.red, - fontSize: 32, - fontWeight: FontWeight.bold, - fontFamily: 'Courier', - ), - ), - const SizedBox(height: 40), - ListView.builder( - shrinkWrap: true, - itemCount: episodes.length, - itemBuilder: (context, index) { - final Episode episode = episodes[index]; - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 32.0, - ), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueGrey[900], - foregroundColor: Colors.white, - minimumSize: const Size(300, 60), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - ), - onPressed: () => _selectEpisode(index), - child: Text( - episode.name, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 18), - ), - ), - ); - }, - ), - ], - ), - ), - ); - } -} diff --git a/apps/wolf_3d_gui/lib/screens/game_screen.dart b/apps/wolf_3d_gui/lib/screens/game_screen.dart index ddde9ac..d0f4338 100644 --- a/apps/wolf_3d_gui/lib/screens/game_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/game_screen.dart @@ -12,6 +12,7 @@ import 'package:wolf_3d_dart/wolf_3d_input.dart'; import 'package:wolf_3d_dart/wolf_3d_renderer.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart'; +import 'package:wolf_3d_gui/screens/debug_tools_screen.dart'; import 'package:wolf_3d_renderer/wolf_3d_ascii_renderer.dart'; import 'package:wolf_3d_renderer/wolf_3d_flutter_renderer.dart'; import 'package:wolf_3d_renderer/wolf_3d_glsl_renderer.dart'; @@ -173,6 +174,11 @@ class _GameScreenState extends State { } }, child: Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: _openDebugTools, + tooltip: 'Open Debug Tools', + child: const Icon(Icons.bug_report), + ), body: LayoutBuilder( builder: (context, constraints) { return Listener( @@ -336,6 +342,14 @@ class _GameScreenState extends State { _glslEffectsEnabled = !_glslEffectsEnabled; } + void _openDebugTools() { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DebugToolsScreen(wolf3d: widget.wolf3d), + ), + ); + } + bool _handleHostShortcut(KeyEvent event) { final HostShortcutHandler? customHandler = widget.hostShortcutHandler; if (customHandler != null) { diff --git a/apps/wolf_3d_gui/lib/screens/sprite_gallery.dart b/apps/wolf_3d_gui/lib/screens/sprite_gallery.dart index 92a4a59..2fbefc0 100644 --- a/apps/wolf_3d_gui/lib/screens/sprite_gallery.dart +++ b/apps/wolf_3d_gui/lib/screens/sprite_gallery.dart @@ -62,9 +62,11 @@ class SpriteGallery extends StatelessWidget { textAlign: TextAlign.center, ), Expanded( - child: AspectRatio( - aspectRatio: 4 / 3, - child: WolfAssetPainter.sprite(wolf3d.sprites[index]), + child: Center( + child: AspectRatio( + aspectRatio: 1, + child: WolfAssetPainter.sprite(wolf3d.sprites[index]), + ), ), ), ], diff --git a/apps/wolf_3d_gui/lib/screens/vga_gallery.dart b/apps/wolf_3d_gui/lib/screens/vga_gallery.dart index 7066c62..8347307 100644 --- a/apps/wolf_3d_gui/lib/screens/vga_gallery.dart +++ b/apps/wolf_3d_gui/lib/screens/vga_gallery.dart @@ -1,7 +1,12 @@ /// Visual browser for decoded VGA pictures and UI art. library; +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; +import 'package:super_clipboard/super_clipboard.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_renderer/wolf_3d_asset_painter.dart'; @@ -13,6 +18,110 @@ class VgaGallery extends StatelessWidget { /// Creates the gallery for [images]. const VgaGallery({super.key, required this.images}); + Future _buildVgaUiImage(VgaImage image) { + final completer = Completer(); + final Uint8List pixels = Uint8List(image.width * image.height * 4); + + for (int y = 0; y < image.height; y++) { + for (int x = 0; x < image.width; x++) { + final int colorByte = image.decodePixel(x, y); + final int abgr = ColorPalette.vga32Bit[colorByte]; + final int offset = (y * image.width + x) * 4; + pixels[offset] = abgr & 0xFF; // R + pixels[offset + 1] = (abgr >> 8) & 0xFF; // G + pixels[offset + 2] = (abgr >> 16) & 0xFF; // B + pixels[offset + 3] = (abgr >> 24) & 0xFF; // A + } + } + + ui.decodeImageFromPixels( + pixels, + image.width, + image.height, + ui.PixelFormat.rgba8888, + (ui.Image decoded) => completer.complete(decoded), + ); + return completer.future; + } + + Future _copyImageToClipboard(BuildContext context, int index) async { + final clipboard = SystemClipboard.instance; + if (clipboard == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Clipboard API is unavailable here.')), + ); + } + return; + } + + final VgaImage image = images[index]; + ui.Image? uiImage; + try { + uiImage = await _buildVgaUiImage(image); + final ByteData? pngData = await uiImage.toByteData( + format: ui.ImageByteFormat.png, + ); + if (pngData == null) { + throw StateError('Failed to encode image as PNG.'); + } + + final item = DataWriterItem(); + item.add(Formats.png(pngData.buffer.asUint8List())); + await clipboard.write([item]); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Copied VGA image #$index to clipboard.')), + ); + } + } catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Copy failed: $error')), + ); + } + } finally { + uiImage?.dispose(); + } + } + + void _showImagePreviewDialog(BuildContext context, int index) { + final VgaImage image = images[index]; + + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text( + 'Index: $index ${image.width} x ${image.height}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Close'), + ), + ElevatedButton.icon( + onPressed: () => _copyImageToClipboard(dialogContext, index), + icon: const Icon(Icons.copy), + label: const Text('Copy'), + ), + ], + content: Center( + child: AspectRatio( + aspectRatio: image.width / image.height, + child: WolfAssetPainter.vga(image), + ), + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -28,21 +137,27 @@ class VgaGallery extends StatelessWidget { itemBuilder: (context, index) { return Card( color: Colors.blueGrey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 8, - children: [ - Text( - "Index: $index\n${images[index].width} x ${images[index].height}", - style: const TextStyle(color: Colors.white, fontSize: 12), - textAlign: TextAlign.center, - ), - Expanded( - child: Center( - child: WolfAssetPainter.vga(images[index]), + child: InkWell( + onTap: () => _showImagePreviewDialog(context, index), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, + children: [ + Text( + "Index: $index\n${images[index].width} x ${images[index].height}", + style: const TextStyle(color: Colors.white, fontSize: 12), + textAlign: TextAlign.center, ), - ), - ], + Expanded( + child: Center( + child: AspectRatio( + aspectRatio: images[index].width / images[index].height, + child: WolfAssetPainter.vga(images[index]), + ), + ), + ), + ], + ), ), ); }, diff --git a/apps/wolf_3d_gui/linux/flutter/generated_plugin_registrant.cc b/apps/wolf_3d_gui/linux/flutter/generated_plugin_registrant.cc index d7f5477..b63cbe9 100644 --- a/apps/wolf_3d_gui/linux/flutter/generated_plugin_registrant.cc +++ b/apps/wolf_3d_gui/linux/flutter/generated_plugin_registrant.cc @@ -7,16 +7,24 @@ #include "generated_plugin_registrant.h" #include +#include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); + g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin"); + irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); + super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/apps/wolf_3d_gui/linux/flutter/generated_plugins.cmake b/apps/wolf_3d_gui/linux/flutter/generated_plugins.cmake index de49b37..9ea952a 100644 --- a/apps/wolf_3d_gui/linux/flutter/generated_plugins.cmake +++ b/apps/wolf_3d_gui/linux/flutter/generated_plugins.cmake @@ -4,7 +4,9 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux + irondash_engine_context screen_retriever_linux + super_native_extensions window_manager ) diff --git a/apps/wolf_3d_gui/pubspec.yaml b/apps/wolf_3d_gui/pubspec.yaml index 4e47cdf..fb59259 100644 --- a/apps/wolf_3d_gui/pubspec.yaml +++ b/apps/wolf_3d_gui/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: flutter: sdk: flutter + super_clipboard: ^0.9.1 dev_dependencies: flutter_test: diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index ff69867..fdf4c0b 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -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. diff --git a/packages/wolf_3d_dart/lib/src/input/cli_input.dart b/packages/wolf_3d_dart/lib/src/input/cli_input.dart index 32b0869..2588225 100644 --- a/packages/wolf_3d_dart/lib/src/input/cli_input.dart +++ b/packages/wolf_3d_dart/lib/src/input/cli_input.dart @@ -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 bytes) => + _matchesShortcut(bytes, rendererToggleKey); + + /// Returns true when [bytes] triggers the ASCII-theme shortcut. + bool matchesAsciiThemeCycleShortcut(List 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 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; diff --git a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart index 57f8966..e69fcea 100644 --- a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart +++ b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart @@ -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; diff --git a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart index fa73b33..ba55b86 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart @@ -110,6 +110,7 @@ class AsciiRenderer extends CliRendererBackend { 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 { late List> _screen; late List> _scenePixels; + List? _mainMenuBandFirstColumn; String? _lastLoggedThemeName; static const List _quadrantByMask = [ @@ -403,7 +405,9 @@ class AsciiRenderer extends CliRendererBackend { @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 { } 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 { _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 { } 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 { .toList(growable: false); _drawMenuTextCentered( 'SELECT GAME', - 48, + _headerHeadingY, headingColor, scale: menuTypography.headingScale, ); @@ -525,6 +543,11 @@ class AsciiRenderer extends CliRendererBackend { } 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 { if (menuTypography.usesCompactRows) { _drawMenuTextCentered( 'WHICH EPISODE TO PLAY?', - 8, + _headerHeadingY, headingColor, scale: menuTypography.headingScale, ); @@ -580,7 +603,7 @@ class AsciiRenderer extends CliRendererBackend { _drawMenuTextCentered( 'WHICH EPISODE TO PLAY?', - 8, + _headerHeadingY, headingColor, scale: menuTypography.headingScale, ); @@ -622,6 +645,12 @@ class AsciiRenderer extends CliRendererBackend { 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 { if (menuTypography.usesCompactRows) { _drawMenuTextCentered( Difficulty.menuText, - 48, + _headerHeadingY, headingColor, scale: menuTypography.headingScale, ); @@ -671,7 +700,7 @@ class AsciiRenderer extends CliRendererBackend { _drawMenuTextCentered( Difficulty.menuText, - 48, + _headerHeadingY, headingColor, scale: menuTypography.headingScale, ); @@ -719,7 +748,7 @@ class AsciiRenderer extends CliRendererBackend { 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 { 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 { return bestIndex; } + void _drawHeaderBarStack({ + required int headingY200, + required int backgroundColor, + required int barColor, + }) { + final List? 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 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 _cacheFirstColumn(VgaImage image) { + final List column = List.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 { 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? 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 { } } - 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++) { diff --git a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart index 4603e5b..e803102 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart @@ -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 { 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? _mainMenuBandFirstColumn; int _offsetColumns = 0; int _offsetRows = 0; int _outputWidth = 1; @@ -357,7 +360,9 @@ class SixelRenderer extends CliRendererBackend { @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 { } 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 { _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 { } 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 { } 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 { 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 { _drawMenuTextCentered( Difficulty.menuText, - 48, + _headerHeadingY, headingIndex, scale: _menuHeadingScale, ); @@ -567,7 +606,7 @@ class SixelRenderer extends CliRendererBackend { 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 { 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 { } } + void _drawHeaderBarStack({ + required int headingY200, + required int backgroundColor, + required int barColor, + }) { + final List? 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 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 _cacheFirstColumn(VgaImage image) { + final List column = List.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 { 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? 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 { } 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 { } } - 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); diff --git a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart index ffb827e..f5a3fdc 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart @@ -15,8 +15,10 @@ class SoftwareRenderer extends RendererBackend { static const int _menuFooterBottomMargin = 1; static const int _menuFooterY = 184; static const int _menuFooterHeight = 12; + static const int _headerHeadingY = 24; late FrameBuffer _buffer; + List? _mainMenuBandFirstColumn; @override void prepareFrame(WolfEngine engine) { @@ -143,7 +145,7 @@ class SoftwareRenderer extends RendererBackend { @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 { } 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 { 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 { 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 { _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 { 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 { 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 { _drawCanonicalMenuTextCentered( 'WHICH EPISODE TO PLAY?', - 6, + _headerHeadingY, headingColor, scale: 2, ); @@ -546,6 +586,12 @@ class SoftwareRenderer extends RendererBackend { 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 { _drawCanonicalMenuTextCentered( Difficulty.menuText, - 48, + _headerHeadingY, headingColor, scale: 2, ); @@ -655,6 +701,38 @@ class SoftwareRenderer extends RendererBackend { return (0xFF000000) | (b << 16) | (g << 8) | r; } + void _drawHeaderBarStack({ + required int headingY200, + required int backgroundColor, + required int barColor, + }) { + final List? 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 { } 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 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 _cacheFirstColumn(VgaImage image) { + final List column = List.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 { 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? 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,