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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
66
apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart
Normal file
66
apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart
Normal file
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<EpisodeScreen> createState() => _EpisodeScreenState();
|
||||
}
|
||||
|
||||
class _EpisodeScreenState extends State<EpisodeScreen> {
|
||||
@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<Episode> 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<GameScreen> {
|
||||
}
|
||||
},
|
||||
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<GameScreen> {
|
||||
_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) {
|
||||
|
||||
@@ -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]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<ui.Image> _buildVgaUiImage(VgaImage image) {
|
||||
final completer = Completer<ui.Image>();
|
||||
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<void> _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<void>(
|
||||
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]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -7,16 +7,24 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||
#include <irondash_engine_context/irondash_engine_context_plugin.h>
|
||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||
#include <super_native_extensions/super_native_extensions_plugin.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
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);
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
irondash_engine_context
|
||||
screen_retriever_linux
|
||||
super_native_extensions
|
||||
window_manager
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencies:
|
||||
|
||||
flutter:
|
||||
sdk: flutter
|
||||
super_clipboard: ^0.9.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -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