diff --git a/doc/flame/inputs/keyboard_input.md b/doc/flame/inputs/keyboard_input.md index 8845ff8442a..b3a1f688b4f 100644 --- a/doc/flame/inputs/keyboard_input.md +++ b/doc/flame/inputs/keyboard_input.md @@ -116,6 +116,42 @@ add( ``` +### KeyboardCallbacks + +Add the `KeyboardCallbacks` mixin to any `Component`, including the `FlameGame` itself, to receive +keyboard events while the game has focus. It adds three callbacks, each receiving its own event +type: + +- `onKeyPress(KeyPressEvent)` when a key is pressed down. +- `onKeyHold(KeyHoldEvent)` repeatedly while a key is held down, at the OS's repeat rate. +- `onKeyRelease(KeyReleaseEvent)` when a key is released. + +All three extend `KeyboardEvent`, which carries the `logicalKey` and `physicalKey` of the key +stroke, the `keysPressed` at the time, and helpers such as `isShiftPressed`, `isControlPressed`, +`isAltPressed`, `isMetaPressed` and `isCapsLockOn`. Press and hold events also carry the +`character` the key stroke produced, if any. The original Flutter +[`KeyEvent`](https://api.flutter.dev/flutter/services/KeyEvent-class.html) is available as `raw`. + +```dart +class Player extends PositionComponent with KeyboardCallbacks { + @override + void onKeyPress(KeyPressEvent event) { + if (event.logicalKey == LogicalKeyboardKey.space) { + event.isShiftPressed ? jumpHigher() : jump(); + } + } +} +``` + +To handle every kind of key event in one place, override `onKeyboardEvent(KeyboardEvent)` instead. +Its default implementation is what calls the three callbacks above, so overriding it without +calling `super` means they are no longer called. + +Unlike tap events, keyboard events have no position, so they are delivered to **every** +`KeyboardCallbacks` component (or game) by default. A component can opt to set +`event.continuePropagation = false` to stop the event from reaching the components after it. + + ### Controlling focus On the widget level, it is possible to use the diff --git a/examples/games/trex/lib/trex_game.dart b/examples/games/trex/lib/trex_game.dart index da371855ce3..93938043d15 100644 --- a/examples/games/trex/lib/trex_game.dart +++ b/examples/games/trex/lib/trex_game.dart @@ -5,10 +5,8 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/flame.dart'; import 'package:flame/game.dart'; -import 'package:flame/input.dart'; import 'package:flame/text.dart'; import 'package:flutter/services.dart'; -import 'package:material_ui/material_ui.dart' hide Image; import 'package:trex_game/background/horizon.dart'; import 'package:trex_game/game_over.dart'; import 'package:trex_game/player.dart'; @@ -21,7 +19,7 @@ enum GameState() { class TRexGame() extends FlameGame - with KeyboardEvents, TapCallbacks, HasCollisionDetection { + with KeyboardCallbacks, TapCallbacks, HasCollisionDetection { static const String description = ''' A game similar to the game in chrome that you get to play while offline. Press space or tap/click the screen to jump, the more obstacles you manage @@ -94,15 +92,11 @@ class TRexGame() bool get isIntro => state == GameState.intro; @override - KeyEventResult onKeyEvent( - KeyEvent event, - Set keysPressed, - ) { - if (keysPressed.contains(LogicalKeyboardKey.enter) || - keysPressed.contains(LogicalKeyboardKey.space)) { + void onKeyPress(KeyPressEvent event) { + if (event.logicalKey + case LogicalKeyboardKey.enter || LogicalKeyboardKey.space) { onAction(); } - return KeyEventResult.handled; } @override diff --git a/examples/lib/stories/camera_and_viewport/camera_follow_and_world_bounds.dart b/examples/lib/stories/camera_and_viewport/camera_follow_and_world_bounds.dart index 92e3a11c1ab..61913e69074 100644 --- a/examples/lib/stories/camera_and_viewport/camera_follow_and_world_bounds.dart +++ b/examples/lib/stories/camera_and_viewport/camera_follow_and_world_bounds.dart @@ -2,14 +2,12 @@ import 'dart:math'; import 'dart:ui'; import 'package:flame/components.dart'; +import 'package:flame/events.dart'; import 'package:flame/experimental.dart'; import 'package:flame/game.dart'; -import 'package:flame/input.dart'; import 'package:flutter/services.dart'; -class CameraFollowAndWorldBoundsExample() - extends FlameGame - with HasKeyboardHandlerComponents { +class CameraFollowAndWorldBoundsExample() extends FlameGame { static const description = ''' This example demonstrates camera following the player, but also obeying the world bounds (which are set up to leave a small margin around the visible @@ -64,7 +62,7 @@ class Ground() extends PositionComponent { } } -class Player() extends PositionComponent with KeyboardHandler { +class Player() extends PositionComponent with KeyboardCallbacks { this : body = Path() ..moveTo(10, 0) @@ -141,8 +139,7 @@ class Player() extends PositionComponent with KeyboardHandler { } @override - bool onKeyEvent(KeyEvent event, Set keysPressed) { - final isKeyDown = event is KeyDownEvent; + void onKeyPress(KeyPressEvent event) { final keyLeft = (event.logicalKey == LogicalKeyboardKey.arrowLeft) || (event.logicalKey == LogicalKeyboardKey.keyA); @@ -153,32 +150,39 @@ class Player() extends PositionComponent with KeyboardHandler { (event.logicalKey == LogicalKeyboardKey.arrowUp) || (event.logicalKey == LogicalKeyboardKey.keyW); - if (isKeyDown) { - if (keyLeft) { - velocity.x = -runSpeed; - } else if (keyRight) { - velocity.x = runSpeed; - } else if (keyUp && nJumpsLeft > 0) { - velocity.y = -jumpSpeed; - nJumpsLeft -= 1; - } + if (keyLeft) { + velocity.x = -runSpeed; + } else if (keyRight) { + velocity.x = runSpeed; + } else if (keyUp && nJumpsLeft > 0) { + velocity.y = -jumpSpeed; + nJumpsLeft -= 1; + } + _updateFacing(); + } + + @override + void onKeyRelease(KeyReleaseEvent event) { + final keysPressed = event.keysPressed; + final hasLeft = + keysPressed.contains(LogicalKeyboardKey.arrowLeft) || + keysPressed.contains(LogicalKeyboardKey.keyA); + final hasRight = + keysPressed.contains(LogicalKeyboardKey.arrowRight) || + keysPressed.contains(LogicalKeyboardKey.keyD); + if (hasLeft && hasRight) { + // Leave the current speed unchanged + } else if (hasLeft) { + velocity.x = -runSpeed; + } else if (hasRight) { + velocity.x = runSpeed; } else { - final hasLeft = - keysPressed.contains(LogicalKeyboardKey.arrowLeft) || - keysPressed.contains(LogicalKeyboardKey.keyA); - final hasRight = - keysPressed.contains(LogicalKeyboardKey.arrowRight) || - keysPressed.contains(LogicalKeyboardKey.keyD); - if (hasLeft && hasRight) { - // Leave the current speed unchanged - } else if (hasLeft) { - velocity.x = -runSpeed; - } else if (hasRight) { - velocity.x = runSpeed; - } else { - velocity.x = 0; - } + velocity.x = 0; } + _updateFacing(); + } + + void _updateFacing() { if ((velocity.x > 0) && !facingRight) { facingRight = true; flipHorizontally(); @@ -187,6 +191,5 @@ class Player() extends PositionComponent with KeyboardHandler { facingRight = false; flipHorizontally(); } - return super.onKeyEvent(event, keysPressed); } } diff --git a/examples/lib/stories/camera_and_viewport/follow_component_example.dart b/examples/lib/stories/camera_and_viewport/follow_component_example.dart index 10c7eec9d1a..34769768191 100644 --- a/examples/lib/stories/camera_and_viewport/follow_component_example.dart +++ b/examples/lib/stories/camera_and_viewport/follow_component_example.dart @@ -8,13 +8,12 @@ import 'package:flame/events.dart'; import 'package:flame/experimental.dart'; import 'package:flame/extensions.dart'; import 'package:flame/game.dart'; -import 'package:flame/input.dart'; import 'package:flutter/services.dart'; import 'package:material_ui/material_ui.dart'; class FollowComponentExample({required final Vector2 viewportResolution}) extends FlameGame - with HasCollisionDetection, HasKeyboardHandlerComponents { + with HasCollisionDetection { static const String description = ''' Move around with W, A, S, D and notice how the camera follows the ember sprite.\n @@ -48,7 +47,7 @@ class FollowComponentExample({required final Vector2 viewportResolution}) class MovableEmber() extends Ember - with CollisionCallbacks, KeyboardHandler { + with CollisionCallbacks, KeyboardCallbacks { static const double speed = 300; static final TextPaint textRenderer = TextPaint( style: const TextStyle(color: Colors.white70, fontSize: 12), @@ -100,35 +99,28 @@ class MovableEmber() } @override - bool onKeyEvent(KeyEvent event, Set keysPressed) { - if (event is KeyRepeatEvent) { - return super.onKeyEvent(event, keysPressed); - } - final isKeyDown = event is KeyDownEvent; + void onKeyPress(KeyPressEvent event) { + _move(event.logicalKey, isKeyDown: true); + } + + @override + void onKeyRelease(KeyReleaseEvent event) { + _move(event.logicalKey, isKeyDown: false); + } - final bool handled; - if (event.logicalKey == LogicalKeyboardKey.keyA) { + void _move(LogicalKeyboardKey key, {required bool isKeyDown}) { + if (key == LogicalKeyboardKey.keyA) { velocity.x = isKeyDown ? -1 : 0; - handled = true; - } else if (event.logicalKey == LogicalKeyboardKey.keyD) { + } else if (key == LogicalKeyboardKey.keyD) { velocity.x = isKeyDown ? 1 : 0; - handled = true; - } else if (event.logicalKey == LogicalKeyboardKey.keyW) { + } else if (key == LogicalKeyboardKey.keyW) { velocity.y = isKeyDown ? -1 : 0; - handled = true; - } else if (event.logicalKey == LogicalKeyboardKey.keyS) { + } else if (key == LogicalKeyboardKey.keyS) { velocity.y = isKeyDown ? 1 : 0; - handled = true; - } else { - handled = false; - } - - if (handled) { - angle = -velocity.angleToSigned(Vector2(1, 0)); - return false; } else { - return super.onKeyEvent(event, keysPressed); + return; } + angle = -velocity.angleToSigned(Vector2(1, 0)); } } diff --git a/examples/lib/stories/input/keyboard_example.dart b/examples/lib/stories/input/keyboard_example.dart index 552f80d5b7c..0802532d567 100644 --- a/examples/lib/stories/input/keyboard_example.dart +++ b/examples/lib/stories/input/keyboard_example.dart @@ -1,11 +1,10 @@ import 'package:examples/commons/ember.dart'; import 'package:flame/components.dart'; +import 'package:flame/events.dart'; import 'package:flame/game.dart'; -import 'package:flame/input.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -class KeyboardExample() extends FlameGame with KeyboardEvents { +class KeyboardExample() extends FlameGame with KeyboardCallbacks { static const String description = ''' Example showcasing how to act on keyboard events. It also briefly showcases how to create a game without the FlameGame. @@ -38,26 +37,22 @@ class KeyboardExample() extends FlameGame with KeyboardEvents { } @override - KeyEventResult onKeyEvent( - KeyEvent event, - Set keysPressed, - ) { - final isKeyDown = event is KeyDownEvent; - - // Avoiding repeat event as we are interested only in - // key up and key down event. - if (key is! KeyRepeatEvent) { - if (event.logicalKey == LogicalKeyboardKey.keyA) { - _direction.x += isKeyDown ? -1 : 1; - } else if (event.logicalKey == LogicalKeyboardKey.keyD) { - _direction.x += isKeyDown ? 1 : -1; - } else if (event.logicalKey == LogicalKeyboardKey.keyW) { - _direction.y += isKeyDown ? -1 : 1; - } else if (event.logicalKey == LogicalKeyboardKey.keyS) { - _direction.y += isKeyDown ? 1 : -1; - } - } + void onKeyPress(KeyPressEvent event) => _steer(event.logicalKey, 1); - return super.onKeyEvent(event, keysPressed); + @override + void onKeyRelease(KeyReleaseEvent event) => _steer(event.logicalKey, -1); + + // A press adds the key's direction and its release takes it back off, so + // holding opposite keys cancels out + void _steer(LogicalKeyboardKey key, double sign) { + if (key == LogicalKeyboardKey.keyA) { + _direction.x -= sign; + } else if (key == LogicalKeyboardKey.keyD) { + _direction.x += sign; + } else if (key == LogicalKeyboardKey.keyW) { + _direction.y -= sign; + } else if (key == LogicalKeyboardKey.keyS) { + _direction.y += sign; + } } } diff --git a/packages/flame/lib/events.dart b/packages/flame/lib/events.dart index 13f7f00c4b0..a4b11528f5f 100644 --- a/packages/flame/lib/events.dart +++ b/packages/flame/lib/events.dart @@ -2,6 +2,7 @@ export 'src/events/callbacks/double_tap_callbacks.dart' show DoubleTapCallbacks; export 'src/events/callbacks/drag_callbacks.dart' show DragCallbacks; export 'src/events/callbacks/hover_callbacks.dart' show HoverCallbacks; export 'src/events/callbacks/input_callbacks.dart' show InputCallbacks; +export 'src/events/callbacks/keyboard_callbacks.dart' show KeyboardCallbacks; export 'src/events/callbacks/long_press_callbacks.dart' show LongPressCallbacks; export 'src/events/callbacks/mouse_move_callbacks.dart' show MouseMoveCallbacks; export 'src/events/callbacks/pointer_input_callbacks.dart' @@ -16,6 +17,8 @@ export 'src/events/callbacks/tertiary_tap_callbacks.dart' export 'src/events/dispatchers/dispatcher.dart' show Dispatcher; export 'src/events/dispatchers/double_tap_dispatcher.dart' show DoubleTapDispatcher, DoubleTapDispatcherKey; +export 'src/events/dispatchers/keyboard_dispatcher.dart' + show KeyboardDispatcher, KeyboardDispatcherKey; export 'src/events/dispatchers/long_press_dispatcher.dart' show LongPressDispatcher, LongPressDispatcherKey; export 'src/events/dispatchers/mouse_move_dispatcher.dart' @@ -40,6 +43,8 @@ export 'src/events/messages/drag_end_event.dart' show DragEndEvent; export 'src/events/messages/drag_start_event.dart' show DragStartEvent; export 'src/events/messages/drag_update_event.dart' show DragUpdateEvent; export 'src/events/messages/event.dart' show Event; +export 'src/events/messages/keyboard_event.dart' + show KeyHoldEvent, KeyPressEvent, KeyReleaseEvent, KeyboardEvent; export 'src/events/messages/location_context_event.dart' show LocationContextEvent; export 'src/events/messages/long_press_cancel_event.dart' diff --git a/packages/flame/lib/src/events/callbacks/keyboard_callbacks.dart b/packages/flame/lib/src/events/callbacks/keyboard_callbacks.dart new file mode 100644 index 00000000000..d43ba9c9ee7 --- /dev/null +++ b/packages/flame/lib/src/events/callbacks/keyboard_callbacks.dart @@ -0,0 +1,48 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:meta/meta.dart'; + +/// This mixin can be added to a [Component] allowing it to receive keyboard +/// events while the game has focus. +/// +/// Every [KeyboardCallbacks] component receives every key event, regardless of +/// its position. Set [Event.continuePropagation] to false inside a handler to +/// stop the event from reaching the components after this one. +/// +/// Override [onKeyPress], [onKeyHold] and [onKeyRelease] to react to a single +/// kind of key event, or [onKeyboardEvent] to receive all of them in one place. +/// +/// This callback uses [KeyboardDispatcher] to route events. +mixin KeyboardCallbacks on Component implements InputCallbacks { + /// Receives every keyboard event delivered to this component. + /// + /// By default it forwards the event to [onKeyPress], [onKeyHold] or + /// [onKeyRelease]. Overriding it without calling `super` means those three + /// are no longer called. + void onKeyboardEvent(KeyboardEvent event) { + switch (event) { + case final KeyPressEvent event: + onKeyPress(event); + case final KeyHoldEvent event: + onKeyHold(event); + case final KeyReleaseEvent event: + onKeyRelease(event); + } + } + + /// Called when a key is pressed down. + void onKeyPress(KeyPressEvent event) {} + + /// Called repeatedly while a key is held down. + void onKeyHold(KeyHoldEvent event) {} + + /// Called when a key is released. + void onKeyRelease(KeyReleaseEvent event) {} + + @override + @mustCallSuper + void onMount() { + super.onMount(); + KeyboardDispatcher.addDispatcher(this); + } +} diff --git a/packages/flame/lib/src/events/dispatchers/keyboard_dispatcher.dart b/packages/flame/lib/src/events/dispatchers/keyboard_dispatcher.dart new file mode 100644 index 00000000000..4d89d054b68 --- /dev/null +++ b/packages/flame/lib/src/events/dispatchers/keyboard_dispatcher.dart @@ -0,0 +1,56 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/services.dart' as flutter; +import 'package:meta/meta.dart'; + +/// **KeyboardDispatcher** facilitates dispatching of keyboard events to the +/// [KeyboardCallbacks] components in the component tree. It will be attached +/// to the [FlameGame] instance automatically whenever any +/// [KeyboardCallbacks] components are mounted into the component tree. +class KeyboardDispatcher() extends Dispatcher { + @mustCallSuper + void onKeyboardEvent(KeyboardEvent event) { + event.deliverToComponents( + gameRef, + (KeyboardCallbacks component) { + component.onKeyboardEvent(event); + }, + deliverToAll: true, + ); + } + + void _handleKeyEvent(flutter.KeyEvent event) { + onKeyboardEvent(KeyboardEvent.fromKeyEvent(event)); + } + + static void addDispatcher(Component component) { + Dispatcher.addDispatcher( + component, + const KeyboardDispatcherKey(), + KeyboardDispatcher.new, + ); + } + + @override + void onMount() { + gameRef.keyDetector = _handleKeyEvent; + } + + @override + void onRemove() { + gameRef.keyDetector = null; + Dispatcher.removeDispatcher(gameRef, const KeyboardDispatcherKey()); + } +} + +/// Unique key for the [KeyboardDispatcher] so the game can identify it. +class const KeyboardDispatcherKey() implements ComponentKey { + @override + int get hashCode => 'KeyboardDispatcherKey'.hashCode; + + @override + bool operator ==(Object other) { + return other is KeyboardDispatcherKey && other.hashCode == hashCode; + } +} diff --git a/packages/flame/lib/src/events/messages/event.dart b/packages/flame/lib/src/events/messages/event.dart index 6b7617ac38b..7a12f396533 100644 --- a/packages/flame/lib/src/events/messages/event.dart +++ b/packages/flame/lib/src/events/messages/event.dart @@ -14,20 +14,26 @@ abstract class Event({ /// The original Flutter raw event that triggered this Flame event. required var R raw, }) { - /// If this flag is false (default), the event will be delivered to the first - /// component that can handle it. If that component sets this flag to true, - /// the event will propagate further down the component tree to other eligible - /// components. + /// Whether the event should propagate further down the component tree to + /// other eligible components after the current one. + /// + /// It is reset before each component receives the event. For most events it + /// is reset to false, so only the first component that can handle the event + /// receives it unless that component sets this flag to true. Events that + /// are delivered to every eligible component, such as scroll and keyboard + /// events, reset it to true instead, and a component can set it to false to + /// stop them. bool continuePropagation = false; @internal void deliverToComponents( Component rootComponent, - void Function(T component) eventHandler, - ) { + void Function(T component) eventHandler, { + bool deliverToAll = false, + }) { rootComponent.propagateToChildren( (component) { - continuePropagation = false; + continuePropagation = deliverToAll; eventHandler(component); return continuePropagation; }, diff --git a/packages/flame/lib/src/events/messages/keyboard_event.dart b/packages/flame/lib/src/events/messages/keyboard_event.dart new file mode 100644 index 00000000000..e02e9db7426 --- /dev/null +++ b/packages/flame/lib/src/events/messages/keyboard_event.dart @@ -0,0 +1,123 @@ +import 'package:flame/events.dart'; +import 'package:flutter/services.dart'; + +/// Base class for the keyboard events delivered to [KeyboardCallbacks] +/// components: [KeyPressEvent], [KeyHoldEvent] and [KeyReleaseEvent]. +/// +/// Keyboard events are delivered to every [KeyboardCallbacks] component in the +/// game, from the topmost child down to the game itself. Any component may set +/// [continuePropagation] to false to stop the event from reaching the +/// components after it. +sealed class KeyboardEvent({ + required super.raw, + + /// The logical keys that were held down when this event happened, including + /// the key of this event itself unless it is a [KeyReleaseEvent]. + required final Set keysPressed, + + /// The lock modes (such as Caps Lock) that were turned on when + /// this event happened. + final Set lockModesEnabled = const {}, +}) extends Event { + /// The key that was pressed, held or released, according to the current + /// keyboard layout. + LogicalKeyboardKey get logicalKey => raw.logicalKey; + + /// The physical location of the key on the keyboard, regardless of layout. + PhysicalKeyboardKey get physicalKey => raw.physicalKey; + + bool _hasPressedAny(List keysToCheck) { + return keysPressed.any((key) => keysToCheck.contains(key)); + } + + /// True if Shift was held down when this event happened. + bool get isShiftPressed => _hasPressedAny([ + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + ]); + + /// True if Ctrl was held down when this event happened. + bool get isControlPressed => _hasPressedAny([ + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + ]); + + /// True if Alt was held down when this event happened. + bool get isAltPressed => _hasPressedAny([ + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.altRight, + ]); + + /// True if Meta (Cmd on macOS, Win on + /// Windows) was held down when this event happened. + bool get isMetaPressed => _hasPressedAny([ + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.metaRight, + ]); + + /// True if Caps Lock was turned on when this event happened. + bool get isCapsLockOn => lockModesEnabled.contains(KeyboardLockMode.capsLock); + + /// True if Num Lock was turned on when this event happened. + bool get isNumLockOn => lockModesEnabled.contains(KeyboardLockMode.numLock); + + /// True if Scroll Lock was turned on when this event happened. + bool get isScrollLockOn { + return lockModesEnabled.contains(KeyboardLockMode.scrollLock); + } + + /// Wraps a Flutter [KeyEvent], reading the pressed keys and lock modes from + /// [HardwareKeyboard]. + static KeyboardEvent fromKeyEvent(KeyEvent event) { + final keysPressed = HardwareKeyboard.instance.logicalKeysPressed; + final lockModesEnabled = HardwareKeyboard.instance.lockModesEnabled; + return switch (event) { + final KeyDownEvent raw => KeyPressEvent( + raw: raw, + keysPressed: keysPressed, + lockModesEnabled: lockModesEnabled, + ), + final KeyRepeatEvent raw => KeyHoldEvent( + raw: raw, + keysPressed: keysPressed, + lockModesEnabled: lockModesEnabled, + ), + final KeyUpEvent raw => KeyReleaseEvent( + raw: raw, + keysPressed: keysPressed, + lockModesEnabled: lockModesEnabled, + ), + _ => throw ArgumentError.value(event, 'event', 'Unknown KeyEvent type'), + }; + } +} + +/// Fired when a key is pressed down. Wraps Flutter's [KeyDownEvent]. +class KeyPressEvent({ + required super.raw, + required super.keysPressed, + super.lockModesEnabled, +}) extends KeyboardEvent { + /// The text this key stroke produced, if any. Null for keys that produce no + /// text, such as modifier or arrow keys. + String? get character => raw.character; +} + +/// Fired repeatedly while a key is held down, at the operating system's key +/// repeat rate. Wraps Flutter's [KeyRepeatEvent]. +class KeyHoldEvent({ + required super.raw, + required super.keysPressed, + super.lockModesEnabled, +}) extends KeyboardEvent { + /// The text this key repeat produced, if any. Null for keys that produce no + /// text, such as modifier or arrow keys. + String? get character => raw.character; +} + +/// Fired when a key is released. Wraps Flutter's [KeyUpEvent]. +class KeyReleaseEvent({ + required super.raw, + required super.keysPressed, + super.lockModesEnabled, +}) extends KeyboardEvent; diff --git a/packages/flame/lib/src/game/game.dart b/packages/flame/lib/src/game/game.dart index 416b6d7fdc6..185a7f090be 100644 --- a/packages/flame/lib/src/game/game.dart +++ b/packages/flame/lib/src/game/game.dart @@ -71,6 +71,12 @@ abstract mixin class Game() { refreshWidget(); } + /// Set by the KeyboardDispatcher to receive key events from the game widget. + /// + /// Unlike the other detectors, setting it doesn't refresh the widget: the + /// game widget always listens to key events while it has focus. + void Function(KeyEvent event)? keyDetector; + /// This should update the state of the game. void update(double dt); diff --git a/packages/flame/lib/src/game/game_widget/game_widget.dart b/packages/flame/lib/src/game/game_widget/game_widget.dart index 46cc6616808..8d02f10134d 100644 --- a/packages/flame/lib/src/game/game_widget/game_widget.dart +++ b/packages/flame/lib/src/game/game_widget/game_widget.dart @@ -369,6 +369,7 @@ class GameWidgetState() extends State> { return KeyEventResult.ignored; } + game.keyDetector?.call(event); if (game is KeyboardEvents) { return game.onKeyEvent( event, diff --git a/packages/flame/test/events/component_mixins/keyboard_callbacks_test.dart b/packages/flame/test/events/component_mixins/keyboard_callbacks_test.dart new file mode 100644 index 00000000000..99c78b38bf3 --- /dev/null +++ b/packages/flame/test/events/component_mixins/keyboard_callbacks_test.dart @@ -0,0 +1,223 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show KeyEventResult; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('KeyboardCallbacks', () { + testWithFlameGame( + 'mounting a KeyboardCallbacks component adds the dispatcher', + (game) async { + await game.ensureAdd(_KeyboardCallbacksComponent([])); + await game.ready(); + + expect(game.children.whereType(), hasLength(1)); + expect(game.keyDetector, isNotNull); + }, + ); + + testWithFlameGame( + 'removing the dispatcher clears the key detector', + (game) async { + await game.ensureAdd(_KeyboardCallbacksComponent([])); + await game.ready(); + + game.firstChild()!.removeFromParent(); + await game.ready(); + + expect(game.keyDetector, isNull); + }, + ); + + testWithFlameGame( + 'every component receives the event, topmost first', + (game) async { + final received = []; + final parent = _KeyboardCallbacksComponent(received, label: 'parent'); + final child = _KeyboardCallbacksComponent(received, label: 'child'); + final sibling = _KeyboardCallbacksComponent(received, label: 'sibling'); + parent.add(child); + game.addAll([parent, sibling]); + await game.ready(); + + _dispatch( + game, + createKeyPressEvent(logicalKey: LogicalKeyboardKey.keyA), + ); + + expect(received, ['sibling', 'child', 'parent']); + }, + ); + + testWithFlameGame( + 'a component can stop the event from reaching the ones after it', + (game) async { + final received = []; + final bottom = _KeyboardCallbacksComponent(received, label: 'bottom'); + final top = _KeyboardCallbacksComponent( + received, + label: 'top', + stopPropagation: true, + ); + game.addAll([bottom, top]); + await game.ready(); + + _dispatch( + game, + createKeyPressEvent(logicalKey: LogicalKeyboardKey.keyA), + ); + + expect(received, ['top']); + }, + ); + + testWithFlameGame( + 'onKeyboardEvent forwards each event to its specific callback', + (game) async { + final component = _SpecificCallbacksComponent(); + await game.ensureAdd(component); + + _dispatch( + game, + createKeyPressEvent(logicalKey: LogicalKeyboardKey.keyA), + ); + _dispatch( + game, + createKeyHoldEvent(logicalKey: LogicalKeyboardKey.keyA), + ); + _dispatch( + game, + createKeyReleaseEvent(logicalKey: LogicalKeyboardKey.keyA), + ); + + expect(component.calls, ['press', 'hold', 'release']); + }, + ); + + testWithFlameGame( + 'overriding onKeyboardEvent without super silences the specific ' + 'callbacks', + (game) async { + final component = _SilencingComponent(); + await game.ensureAdd(component); + + _dispatch( + game, + createKeyPressEvent(logicalKey: LogicalKeyboardKey.keyA), + ); + + expect(component.calls, ['general']); + }, + ); + + testWidgets( + 'key events reach the game through the widget tree', + (tester) async { + final game = _KeyboardCallbacksGame(); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + + expect( + game.events.map((e) => (e.runtimeType, e.logicalKey)), + [ + (KeyPressEvent, LogicalKeyboardKey.shiftLeft), + (KeyPressEvent, LogicalKeyboardKey.keyA), + (KeyHoldEvent, LogicalKeyboardKey.keyA), + (KeyReleaseEvent, LogicalKeyboardKey.keyA), + (KeyReleaseEvent, LogicalKeyboardKey.shiftLeft), + ], + ); + final pressA = game.events[1]; + expect(pressA.isShiftPressed, isTrue); + expect( + pressA.keysPressed, + {LogicalKeyboardKey.shiftLeft, LogicalKeyboardKey.keyA}, + ); + expect(game.events[3].isShiftPressed, isTrue); + expect(game.events[4].isShiftPressed, isFalse); + }, + ); + + testWidgets( + 'works alongside the KeyboardEvents game mixin', + (tester) async { + final game = _LegacyKeyboardGame(); + final component = _KeyboardCallbacksComponent([]); + await tester.pumpWidget(GameWidget(game: game)); + await game.ensureAdd(component); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA); + + expect(component.received, hasLength(1)); + expect(game.legacyEvents, hasLength(1)); + }, + ); + }); +} + +void _dispatch(FlameGame game, KeyboardEvent event) { + game.firstChild()!.onKeyboardEvent(event); +} + +class _KeyboardCallbacksComponent( + final List received, { + final String label = '', + final bool stopPropagation = false, +}) extends Component with KeyboardCallbacks { + @override + void onKeyboardEvent(KeyboardEvent event) { + received.add(label); + if (stopPropagation) { + event.continuePropagation = false; + } + } +} + +class _SpecificCallbacksComponent() extends Component with KeyboardCallbacks { + final List calls = []; + + @override + void onKeyPress(KeyPressEvent event) => calls.add('press'); + + @override + void onKeyHold(KeyHoldEvent event) => calls.add('hold'); + + @override + void onKeyRelease(KeyReleaseEvent event) => calls.add('release'); +} + +class _SilencingComponent() extends _SpecificCallbacksComponent { + @override + void onKeyboardEvent(KeyboardEvent event) => calls.add('general'); +} + +class _KeyboardCallbacksGame() extends FlameGame with KeyboardCallbacks { + final List events = []; + + @override + void onKeyboardEvent(KeyboardEvent event) => events.add(event); +} + +class _LegacyKeyboardGame() extends FlameGame with KeyboardEvents { + final List legacyEvents = []; + + @override + KeyEventResult onKeyEvent( + KeyEvent event, + Set keysPressed, + ) { + legacyEvents.add(event); + return KeyEventResult.handled; + } +} diff --git a/packages/flame/test/events/messages/keyboard_event_test.dart b/packages/flame/test/events/messages/keyboard_event_test.dart new file mode 100644 index 00000000000..ca4e7a0cace --- /dev/null +++ b/packages/flame/test/events/messages/keyboard_event_test.dart @@ -0,0 +1,135 @@ +import 'package:flame/events.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // fromKeyEvent reads HardwareKeyboard.instance, which throws without one + TestWidgetsFlutterBinding.ensureInitialized(); + + group('KeyboardEvent', () { + test('fromKeyEvent wraps each Flutter key event in its subclass', () { + const physicalKey = PhysicalKeyboardKey.keyA; + const logicalKey = LogicalKeyboardKey.keyA; + + final press = KeyboardEvent.fromKeyEvent( + const KeyDownEvent( + physicalKey: physicalKey, + logicalKey: logicalKey, + character: 'a', + timeStamp: Duration.zero, + ), + ); + final hold = KeyboardEvent.fromKeyEvent( + const KeyRepeatEvent( + physicalKey: physicalKey, + logicalKey: logicalKey, + timeStamp: Duration.zero, + ), + ); + final release = KeyboardEvent.fromKeyEvent( + const KeyUpEvent( + physicalKey: physicalKey, + logicalKey: logicalKey, + timeStamp: Duration.zero, + ), + ); + + expect( + press, + isA().having((e) => e.character, 'character', 'a'), + ); + expect(press.physicalKey, physicalKey); + expect(press.logicalKey, logicalKey); + expect(hold, isA()); + expect(release, isA()); + }); + + test('each subclass exposes its own Flutter event type as raw', () { + // Only compiles if raw is narrowed per subclass + _expectRawTypes( + createKeyPressEvent(logicalKey: LogicalKeyboardKey.keyA).raw, + createKeyHoldEvent(logicalKey: LogicalKeyboardKey.keyA).raw, + createKeyReleaseEvent(logicalKey: LogicalKeyboardKey.keyA).raw, + ); + }); + + test('fromKeyEvent rejects unknown Flutter key event types', () { + expect( + () => KeyboardEvent.fromKeyEvent(const _UnknownKeyEvent()), + throwsArgumentError, + ); + }); + + test('modifier helpers read either side of the keyboard', () { + final left = createKeyPressEvent( + logicalKey: LogicalKeyboardKey.keyA, + keysPressed: { + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.metaLeft, + }, + ); + final right = createKeyPressEvent( + logicalKey: LogicalKeyboardKey.keyA, + keysPressed: { + LogicalKeyboardKey.shiftRight, + LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.altRight, + LogicalKeyboardKey.metaRight, + }, + ); + final none = createKeyPressEvent(logicalKey: LogicalKeyboardKey.keyA); + + for (final event in [left, right]) { + expect(event.isShiftPressed, isTrue); + expect(event.isControlPressed, isTrue); + expect(event.isAltPressed, isTrue); + expect(event.isMetaPressed, isTrue); + } + expect(none.isShiftPressed, isFalse); + expect(none.isControlPressed, isFalse); + expect(none.isAltPressed, isFalse); + expect(none.isMetaPressed, isFalse); + }); + + test('lock helpers read the enabled lock modes', () { + final allOn = createKeyReleaseEvent( + logicalKey: LogicalKeyboardKey.keyA, + lockModesEnabled: { + KeyboardLockMode.capsLock, + KeyboardLockMode.numLock, + KeyboardLockMode.scrollLock, + }, + ); + final allOff = createKeyReleaseEvent( + logicalKey: LogicalKeyboardKey.keyA, + ); + + expect(allOn.isCapsLockOn, isTrue); + expect(allOn.isNumLockOn, isTrue); + expect(allOn.isScrollLockOn, isTrue); + expect(allOff.isCapsLockOn, isFalse); + expect(allOff.isNumLockOn, isFalse); + expect(allOff.isScrollLockOn, isFalse); + }); + }); +} + +void _expectRawTypes( + KeyDownEvent press, + KeyRepeatEvent hold, + KeyUpEvent release, +) { + expect([press, hold, release], everyElement(isA())); +} + +class const _UnknownKeyEvent() extends KeyEvent { + this + : super( + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + timeStamp: Duration.zero, + ); +} diff --git a/packages/flame_test/lib/flame_test.dart b/packages/flame_test/lib/flame_test.dart index 5088cea9feb..adc3c7bd2e5 100644 --- a/packages/flame_test/lib/flame_test.dart +++ b/packages/flame_test/lib/flame_test.dart @@ -12,6 +12,7 @@ export 'src/fails_assert.dart'; export 'src/flame_test.dart'; export 'src/mock_gesture_events.dart'; export 'src/mock_image.dart'; +export 'src/mock_keyboard_events.dart'; export 'src/mock_long_press_events.dart'; export 'src/mock_mouse_move_event.dart'; export 'src/mock_scroll_event.dart'; diff --git a/packages/flame_test/lib/src/mock_keyboard_events.dart b/packages/flame_test/lib/src/mock_keyboard_events.dart new file mode 100644 index 00000000000..305fea9fd77 --- /dev/null +++ b/packages/flame_test/lib/src/mock_keyboard_events.dart @@ -0,0 +1,70 @@ +import 'package:flame/events.dart'; +import 'package:flutter/services.dart'; + +/// Creates a [KeyPressEvent] for [logicalKey]. +/// +/// [keysPressed] defaults to just [logicalKey]. [physicalKey] defaults to a +/// key with no layout meaning; pass one if the code under test reads it. +KeyPressEvent createKeyPressEvent({ + required LogicalKeyboardKey logicalKey, + PhysicalKeyboardKey? physicalKey, + String? character, + Set? keysPressed, + Set? lockModesEnabled, +}) { + return KeyPressEvent( + raw: KeyDownEvent( + physicalKey: physicalKey ?? const PhysicalKeyboardKey(0), + logicalKey: logicalKey, + character: character, + timeStamp: Duration.zero, + ), + keysPressed: keysPressed ?? {logicalKey}, + lockModesEnabled: lockModesEnabled ?? const {}, + ); +} + +/// Creates a [KeyHoldEvent] for [logicalKey]. +/// +/// [keysPressed] defaults to just [logicalKey]. [physicalKey] defaults to a +/// key with no layout meaning; pass one if the code under test reads it. +KeyHoldEvent createKeyHoldEvent({ + required LogicalKeyboardKey logicalKey, + PhysicalKeyboardKey? physicalKey, + String? character, + Set? keysPressed, + Set? lockModesEnabled, +}) { + return KeyHoldEvent( + raw: KeyRepeatEvent( + physicalKey: physicalKey ?? const PhysicalKeyboardKey(0), + logicalKey: logicalKey, + character: character, + timeStamp: Duration.zero, + ), + keysPressed: keysPressed ?? {logicalKey}, + lockModesEnabled: lockModesEnabled ?? const {}, + ); +} + +/// Creates a [KeyReleaseEvent] for [logicalKey]. +/// +/// [keysPressed] defaults to empty, since the released key is no longer held. +/// [physicalKey] defaults to a key with no layout meaning; pass one if the +/// code under test reads it. +KeyReleaseEvent createKeyReleaseEvent({ + required LogicalKeyboardKey logicalKey, + PhysicalKeyboardKey? physicalKey, + Set? keysPressed, + Set? lockModesEnabled, +}) { + return KeyReleaseEvent( + raw: KeyUpEvent( + physicalKey: physicalKey ?? const PhysicalKeyboardKey(0), + logicalKey: logicalKey, + timeStamp: Duration.zero, + ), + keysPressed: keysPressed ?? const {}, + lockModesEnabled: lockModesEnabled ?? const {}, + ); +}