Refactor menu structure and add Flutter-specific input and persistence layers

- Moved menu-related classes to a new structure under `src/menu/`.
- Introduced `WolfMenuPresentation` to handle menu art and mappings.
- Added `MenuManager` tests to ensure menu state reflects game status.
- Implemented `FlutterRendererSettingsPersistence` and `FlutterSaveGamePersistence` for managing settings and save files on desktop platforms.
- Created `Wolf3dFlutterInput` to handle keyboard and mouse input in a Flutter environment.
- Updated README to reflect new package structure and usage instructions.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-24 18:45:34 +01:00
parent 9f3651b122
commit 5c309c2240
37 changed files with 2356 additions and 1565 deletions
@@ -0,0 +1,257 @@
/// Flutter-specific input adapter for the Wolf3D engine.
///
/// This class merges keyboard and pointer events into the frame-based fields
/// exposed by [Wolf3dInput]. It is designed to be owned by higher-level app
/// code and reused across menu and gameplay screens.
library;
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
/// Translates Flutter keyboard and mouse state into engine-friendly actions.
class Wolf3dFlutterInput extends Wolf3dInput {
/// Keyboard shortcut used by the Flutter host to cycle renderer modes.
LogicalKeyboardKey rendererToggleKey = LogicalKeyboardKey.keyR;
/// Keyboard shortcut used by the Flutter host to cycle ASCII themes.
LogicalKeyboardKey asciiThemeCycleKey = LogicalKeyboardKey.keyT;
/// Keyboard shortcut used by the Flutter host to toggle the FPS counter.
LogicalKeyboardKey fpsToggleKey = LogicalKeyboardKey.backquote;
/// Human-friendly label for [rendererToggleKey] shown in on-screen hints.
String get rendererToggleKeyLabel => _formatShortcutLabel(rendererToggleKey);
/// Human-friendly label for [asciiThemeCycleKey] shown in on-screen hints.
String get asciiThemeCycleKeyLabel =>
_formatShortcutLabel(asciiThemeCycleKey);
/// Human-friendly label for [fpsToggleKey] shown in on-screen hints.
String get fpsToggleKeyLabel => _formatShortcutLabel(fpsToggleKey);
String _formatShortcutLabel(LogicalKeyboardKey key) {
if (key == LogicalKeyboardKey.space) {
return 'SPACE';
}
if (key == LogicalKeyboardKey.tab) {
return 'TAB';
}
final label = key.keyLabel;
if (label.isNotEmpty) {
return label.toUpperCase();
}
final debugName = key.debugName;
if (debugName == null || debugName.isEmpty) {
return 'KEY';
}
return debugName.replaceFirst('Logical Keyboard Key ', '').toUpperCase();
}
/// Mapping from logical game actions to one or more keyboard bindings.
///
/// Each action can be rebound by replacing the matching set. The defaults
/// support both WASD and arrow-key movement for desktop hosts.
Map<WolfInputAction, Set<LogicalKeyboardKey>> bindings = {
WolfInputAction.forward: {
LogicalKeyboardKey.keyW,
LogicalKeyboardKey.arrowUp,
},
WolfInputAction.backward: {
LogicalKeyboardKey.keyS,
LogicalKeyboardKey.arrowDown,
},
WolfInputAction.turnLeft: {
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.arrowLeft,
},
WolfInputAction.turnRight: {
LogicalKeyboardKey.keyD,
LogicalKeyboardKey.arrowRight,
},
WolfInputAction.mapToggle: {LogicalKeyboardKey.tab},
WolfInputAction.fire: {
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.controlRight,
},
WolfInputAction.interact: {
LogicalKeyboardKey.space,
LogicalKeyboardKey.enter,
LogicalKeyboardKey.numpadEnter,
},
WolfInputAction.back: {LogicalKeyboardKey.escape},
WolfInputAction.weapon1: {LogicalKeyboardKey.digit1},
WolfInputAction.weapon2: {LogicalKeyboardKey.digit2},
WolfInputAction.weapon3: {LogicalKeyboardKey.digit3},
WolfInputAction.weapon4: {LogicalKeyboardKey.digit4},
};
/// Whether the primary mouse button is currently held.
bool isMouseLeftDown = false;
/// Whether the secondary mouse button is currently held.
bool isMouseRightDown = false;
double _mouseDeltaX = 0.0;
double _mouseDeltaY = 0.0;
bool _previousMouseRightDown = false;
bool _queuedBack = false;
// One-frame suppression lets host shortcuts (for example Alt+Enter) consume
// overlapping gameplay keys before the engine reads keyboard state.
final Set<WolfInputAction> _suppressedActionsOnce = <WolfInputAction>{};
// Mouse-look is optional so touch or keyboard-only hosts can keep the same
// adapter without incurring accidental pointer-driven movement.
bool _isMouseLookEnabled = false;
/// Whether pointer deltas should be interpreted as movement/turn input.
bool get mouseLookEnabled => _isMouseLookEnabled;
/// Enables or disables mouse-look style control.
set mouseLookEnabled(bool value) {
_isMouseLookEnabled = value;
// Clear any built-up delta when turning it off so it doesn't
// suddenly jump if they turn it back on.
if (!value) {
_mouseDeltaX = 0.0;
_mouseDeltaY = 0.0;
}
}
Set<LogicalKeyboardKey> _previousKeys = {};
/// Rebinds [action] to a single [key], replacing any previous bindings.
void bindKey(WolfInputAction action, LogicalKeyboardKey key) {
bindings[action] = {};
bindings[action]?.add(key);
}
/// Removes [key] from the current binding set for [action].
void unbindKey(WolfInputAction action, LogicalKeyboardKey key) {
bindings[action]?.remove(key);
}
/// Updates button state for a newly pressed pointer.
void onPointerDown(PointerDownEvent event) {
if (event.buttons & kPrimaryMouseButton != 0) isMouseLeftDown = true;
if (event.buttons & kSecondaryMouseButton != 0) isMouseRightDown = true;
}
/// Updates button state when a pointer is released.
void onPointerUp(PointerUpEvent event) {
if (event.buttons & kPrimaryMouseButton == 0) isMouseLeftDown = false;
if (event.buttons & kSecondaryMouseButton == 0) isMouseRightDown = false;
}
/// Accumulates pointer delta so it can be consumed on the next engine frame.
void onPointerMove(PointerEvent event) {
// Only capture movement if mouselook is actually enabled
if (_isMouseLookEnabled) {
_mouseDeltaX += event.delta.dx;
_mouseDeltaY += event.delta.dy;
}
}
/// Queues a one-frame back action, typically from system back gestures.
void queueBackAction() {
_queuedBack = true;
}
/// Suppresses [action] for the next [update] tick only.
///
/// This is primarily used by host-level shortcuts that share physical keys
/// with gameplay/menu actions.
void suppressActionOnce(WolfInputAction action) {
_suppressedActionsOnce.add(action);
}
/// Convenience helper for host shortcuts that consume Enter/Interact.
void suppressInteractOnce() {
suppressActionOnce(WolfInputAction.interact);
}
/// Returns whether any bound key for [action] is currently pressed.
bool _isActive(WolfInputAction action, Set<LogicalKeyboardKey> pressedKeys) {
// Host-consumed actions should appear inactive for exactly one update.
if (_suppressedActionsOnce.contains(action)) {
return false;
}
return bindings[action]!.any((key) => pressedKeys.contains(key));
}
/// Returns whether [action] was pressed during the current frame only.
bool _isNewlyPressed(
WolfInputAction action,
Set<LogicalKeyboardKey> newlyPressed,
) {
// Suppressed actions also block edge-triggered press detection.
if (_suppressedActionsOnce.contains(action)) {
return false;
}
return bindings[action]!.any((key) => newlyPressed.contains(key));
}
@override
void update() {
final pressedKeys = HardwareKeyboard.instance.logicalKeysPressed;
final newlyPressedKeys = pressedKeys.difference(_previousKeys);
// Resolve digital keyboard state first so mouse-look only augments input.
bool kbForward = _isActive(WolfInputAction.forward, pressedKeys);
bool kbBackward = _isActive(WolfInputAction.backward, pressedKeys);
bool kbLeft = _isActive(WolfInputAction.turnLeft, pressedKeys);
bool kbRight = _isActive(WolfInputAction.turnRight, pressedKeys);
// Mouse-look intentionally maps pointer deltas back onto the engine's
// simple boolean input contract instead of introducing analog turns.
isMovingForward = kbForward || (_isMouseLookEnabled && _mouseDeltaY < -1.5);
isMovingBackward =
kbBackward || (_isMouseLookEnabled && _mouseDeltaY > 1.5);
isTurningLeft = kbLeft || (_isMouseLookEnabled && _mouseDeltaX < -1.5);
isTurningRight = kbRight || (_isMouseLookEnabled && _mouseDeltaX > 1.5);
isMapToggle = _isNewlyPressed(WolfInputAction.mapToggle, newlyPressedKeys);
// Deltas are one-frame impulses, so consume them immediately after use.
_mouseDeltaX = 0.0;
_mouseDeltaY = 0.0;
// Right click or Spacebar to interact
isInteracting =
_isNewlyPressed(WolfInputAction.interact, newlyPressedKeys) ||
(mouseLookEnabled && isMouseRightDown && !_previousMouseRightDown);
isBack =
_isNewlyPressed(WolfInputAction.back, newlyPressedKeys) || _queuedBack;
menuTapX = null;
menuTapY = null;
// Left click or Ctrl to fire
isFiring =
_isActive(WolfInputAction.fire, pressedKeys) ||
(mouseLookEnabled && isMouseLeftDown);
requestedWeapon = null;
if (_isNewlyPressed(WolfInputAction.weapon1, newlyPressedKeys)) {
requestedWeapon = WeaponType.knife;
}
if (_isNewlyPressed(WolfInputAction.weapon2, newlyPressedKeys)) {
requestedWeapon = WeaponType.pistol;
}
if (_isNewlyPressed(WolfInputAction.weapon3, newlyPressedKeys)) {
requestedWeapon = WeaponType.machineGun;
}
if (_isNewlyPressed(WolfInputAction.weapon4, newlyPressedKeys)) {
requestedWeapon = WeaponType.chainGun;
}
// Preserve prior frame state so edge-triggered actions like interact and
// weapon switching only fire once per physical key press.
_previousKeys = Set.from(pressedKeys);
_previousMouseRightDown = isMouseRightDown;
_queuedBack = false;
// Clear one-shot suppression so the action behaves normally next frame.
_suppressedActionsOnce.clear();
}
}