From c833319c49cb774e6a657a12fd4070de9efd10b7 Mon Sep 17 00:00:00 2001 From: Renan <6718144+renancaraujo@users.noreply.github.com> Date: Tue, 31 Aug 2021 23:38:21 +0100 Subject: [PATCH] Feat: Add keyboard with focus node implementation (#909) * Add keyboard with focus node implementation * a * format and make it stabel compatible * Add mixin to game * fixes * add tests * format * docs * more docs * Update doc/keyboard-input.md Co-authored-by: Erick * rename test * Apply suggestions from code review Co-authored-by: Luan Nico * fix test * Update tutorials/2_sprite_animations_gestures/README.md Co-authored-by: Luan Nico * docs * Apply suggestions from code review Co-authored-by: Jochum van der Ploeg * yo Co-authored-by: Erick Co-authored-by: Luan Nico Co-authored-by: Jochum van der Ploeg --- doc/{input.md => gesture-input.md} | 157 +----------------- doc/keyboard-input.md | 94 +++++++++++ doc/other-inputs.md | 123 ++++++++++++++ doc/summary.md | 6 +- .../coordinate_systems.dart | 22 ++- .../camera_and_viewport/follow_object.dart | 44 +++-- examples/lib/stories/input/keyboard.dart | 22 ++- examples/lib/stories/parallax/advanced.dart | 2 +- examples/lib/stories/parallax/no_fcs.dart | 2 +- packages/flame/CHANGELOG.md | 2 + packages/flame/lib/src/game/game.dart | 18 +- .../lib/src/game/game_widget/game_widget.dart | 73 +++++--- .../flame/lib/src/game/mixins/keyboard.dart | 82 ++++++++- .../components/collision_detection_test.dart | 4 +- .../game_widget_keyboard_test.dart | 153 +++++++++++++++++ .../flame/test/image_composition_test.dart | 2 +- .../2_sprite_animations_gestures/README.md | 5 +- 17 files changed, 580 insertions(+), 231 deletions(-) rename doc/{input.md => gesture-input.md} (63%) create mode 100644 doc/keyboard-input.md create mode 100644 doc/other-inputs.md create mode 100644 packages/flame/test/game/game_widget/game_widget_keyboard_test.dart diff --git a/doc/input.md b/doc/gesture-input.md similarity index 63% rename from doc/input.md rename to doc/gesture-input.md index 9d1bb8710..aba41bddb 100644 --- a/doc/input.md +++ b/doc/gesture-input.md @@ -1,6 +1,13 @@ -# Input +# Gesture Input -## Gestures +This includes documentation for gesture inputs, which is, mouse and touch pointers. + +For other input documents, see also: + +- [Keyboard Input](keyboard-input.md): for keystrokes +- [Other Inputs](other-inputs.md): For joysticks, game pads, etc. + +## Intro Inside `package:flame/gestures.dart` you can find a whole set of `mixin`s which can be included on your game class instance to be able to receive touch input events. Below you can see the full list @@ -302,150 +309,4 @@ for the event to be counted on your component. An example of you to use it can be seen [here](https://github.com/flame-engine/flame/tree/main/examples/lib/stories/). -## Keyboard -Flame provides a simple way to access Flutter's features regarding accessing Keyboard input events. - -To use it, just add the `KeyboardEvents` mixin to your game class. -When doing this you will need to implement the `onKeyEvent` method, this method is called every time -a keyboard event happens, and it receives an instance of the Flutter class `RawKeyEvent`. -This event can be used to get information about what occurred, such as if it was a key down or key -up event, and which key was pressed etc. - -Minimal example: - -```dart -import 'package:flame/game.dart'; -import 'package:flame/input.dart'; -import 'package:flutter/services.dart'; - -class MyGame extends Game with KeyboardEvents { - // update and render omitted - - @override - void onKeyEvent(e) { - final bool isKeyDown = e is RawKeyDownEvent; - print(" Key: ${e.data.keyLabel} - isKeyDown: $isKeyDown"); - } -} -``` - -You can also check a more complete example -[here](https://github.com/flame-engine/flame/tree/main/examples/lib/stories/input/keyboard.dart). - -## Joystick - -Flame provides a component capable of creating a virtual joystick for taking input for your game. -To use this feature you need to create a `JoystickComponent`, configure it the way you want, and -add it to your game. - -To receive the inputs from the joystick component, pass your `JoystickComponent` to the component -that you want it to control, or simply act upon input from it in the update-loop of your game. - -Check this example to get a better understanding: - -```dart -class MyGame extends BaseGame with HasDraggableComponents { - - MyGame() { - joystick.addObserver(player); - add(player); - add(joystick); - } - - @override - Future onLoad() async { - super.onLoad(); - final image = await images.load('joystick.png'); - final sheet = SpriteSheet.fromColumnsAndRows( - image: image, - columns: 6, - rows: 1, - ); - final joystick = JoystickComponent( - knob: SpriteComponent( - sprite: sheet.getSpriteById(1), - size: Vector2.all(100), - ), - background: SpriteComponent( - sprite: sheet.getSpriteById(0), - size: Vector2.all(150), - ), - margin: const EdgeInsets.only(left: 40, bottom: 40), - ); - - final player = Player(joystick); - add(player); - add(joystick); - } -} - -class JoystickPlayer extends SpriteComponent with HasGameRef { - /// Pixels/s - double maxSpeed = 300.0; - - final JoystickComponent joystick; - - JoystickPlayer(this.joystick) - : super( - size: Vector2.all(100.0), - ) { - anchor = Anchor.center; - } - - @override - Future onLoad() async { - super.onLoad(); - sprite = await gameRef.loadSprite('layers/player.png'); - position = gameRef.size / 2; - } - - @override - void update(double dt) { - super.update(dt); - if (joystick.direction != JoystickDirection.idle) { - position.add(joystick.velocity * maxSpeed * dt); - angle = joystick.delta.screenAngle(); - } - } -} -``` - -So in this example we create the classes `MyGame` and `Player`. `MyGame` creates a joystick which is -passed to the `Player` when it is created. In the `Player` class we act upon the current state of -the joystick. - -The joystick has a few fields that change depending on what state it is in. -These are the fields that should be used to know the state of the joystick: - - `intensity`: The percentage [0.0, 1.0] that the knob is dragged from the epicenter to the edge of - the joystick (or `knobRadius` if that is set). - - `delta`: The absolute amount (defined as a `Vector2`) that the knob is dragged from its epicenter. - - `velocity`: The percentage, presented as a `Vector2`, and direction that the knob is currently - pulled from its base position to a edge of the joystick. - -If you want to create buttons to go with your joystick, check out -[`MarginButtonComponent`](#HudButtonComponent). - -A full examples of how to use it can be found -[here](https://github.com/flame-engine/flame/tree/main/examples/lib/stories/input/joystick.dart). -And it can be seen running [here](https://examples.flame-engine.org/#/Controls_Joystick). - -## HudButtonComponent -A `HudButtonComponent` is a button that can be defined with margins to the edge of the `Viewport` -instead of with a position. It takes two `PositionComponent`s. `button` and `buttonDown`, the first -is used for when the button is idle and the second is shown when the button is being pressed. The -second one is optional if you don't want to change the look of the button when it is pressed, or if -you handle this through the `button` component. - -As the name suggests this button is a hud by default, which means that it will be static on your -screen even if the camera for the game moves around. You can also use this component as a non-hud by -setting `hudButtonComponent.isHud = false;`. - -If you want to act upon the button being pressed (which I guess that you do) you can either pass in -a callback function as the `onPressed` argument, or you extend the component and override -`onTapDown`, `onTapUp` and/or `onTapCancel` and implement your logic in there. - -## Gamepad - -Flame has a separate plugin for gamepad support, you can checkout the plugin -[here](https://github.com/flame-engine/flame_gamepad) for more information. diff --git a/doc/keyboard-input.md b/doc/keyboard-input.md new file mode 100644 index 000000000..0f15ff8ab --- /dev/null +++ b/doc/keyboard-input.md @@ -0,0 +1,94 @@ +# Keyboard Input + +This includes documentation for keyboard inputs. + +For other input documents, see also: + +- [Gesture Input](gesture-input.md): for mouse and touch pointer gestures +- [Other Inputs](other-inputs.md): For joysticks, game pads, etc. + +## Intro + +The keyboard API on flame relies on the +[Flutter's Focus widget](https://api.flutter.dev/flutter/widgets/Focus-class.html). + +To customize focus behavior, see [Controlling focus](#controlling-focus). + +There are two ways a game can be sensitive to key strokes; at the game level and at a component level. +For each we have a mixin that can me added to `Game`s and `BaseComponent`s, respectively. + +### Receive keyboard events in a game level + +To make a `Game` sub class sensitive to key stroke, mix it with `KeyboardEvents`. + +After that, it will be possible to override an `onKeyEvent` method. + +This method receives two parameters, first the [`RawKeyEvent`](https://api.flutter.dev/flutter/services/RawKeyEvent-class.html) +that triggered the callback in the first place. The second is a set of the currently pressed [`LogicalKeyboardKey`](https://api.flutter.dev/flutter/widgets/KeyEventResult-class.html). + +The return value should be a [`KeyEventResult`](https://api.flutter.dev/flutter/widgets/KeyEventResult-class.html). + +`KeyEventResult.handled` will tell the framework that the key stroke was resolved inside of Flame and skip any other keyboard handler widgets apart of `GameWidget`. + +`KeyEventResult.ignored` will tell the framework to keep testing this event in any other keyboard handler widget apart of `GameWidget`. If the event is not resolved by any handler, the framework will trigger `SystemSoundType.alert`. + +`KeyEventResult.skipRemainingHandlers` is very similar to `.ignored`, apart from the fact that will skip any other handler widget and will straight up play the alert sound. + +Minimal example: + +```dart +class MyGame extends Game with KeyboardEvents { + // ... + @override + KeyEventResult onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + final isKeyDown = event is RawKeyDownEvent; + + final isSpace = event == LogicalKeyboardKey.space; + + if (isSpace && isKeyDown) { + if (keysPressed.contains(LogicalKeyboardKey.altLeft) || + keysPressed.contains(LogicalKeyboardKey.altRight)) { + this.shootHarder(); + } else { + this.shoot(); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } +} +``` + +### Receive keyboard events in a component level + +To receive keyboard events directly in components, there is the mixin `KeyboardHandler`. + +Similarly to `Tappable` and `Draggable`, `KeyboardHandler` can be mixed into any `BaseComponent` +subclass. + +KeyboardHandlers must only be added to games that are mixed with `HasKeyboardHandlerComponents`. + +> ⚠️ Attention: If `HasKeyboardHandlerComponents` is used, you must remove `KeyboardEvents` +> from the game mixin list to avoid conflicts. + +After applying `HasKeyboardHandlerComponents`, it will be possible to override an `onKeyEvent` method. + +This method receives two parameters. First the [`RawKeyEvent`](https://api.flutter.dev/flutter/services/RawKeyEvent-class.html) +that triggered the callback in the first place. The second is a set of the currently pressed [`LogicalKeyboardKey`](https://api.flutter.dev/flutter/widgets/KeyEventResult-class.html)s. + +The returned value should be `true` to allow the continuous propagation of the key event among other components. +To not allow any other component to receive the event, return `false`. + +### Controlling focus + +On the widget level, it is possible to use the [`FocusNode`](https://api.flutter.dev/flutter/widgets/FocusNode-class.html) API to control whether the game is focused or not. + +`GameWidget` has an optional `focusNode` parameter that allow its focus to be controlled externally. + +By default `GameWidget` has its `autofocus` set to true, which means it will get focused once it is mounted. To override that behavior, set `autofocus` to false. + +For a more complete example see +[here](https://github.com/flame-engine/flame/tree/main/examples/lib/stories/input/keyboard.dart). diff --git a/doc/other-inputs.md b/doc/other-inputs.md new file mode 100644 index 000000000..9ae1c2048 --- /dev/null +++ b/doc/other-inputs.md @@ -0,0 +1,123 @@ +# Other inputs + +This includes documentation for input methods besides keyboard and mouse. + +For other input documents, see also: + +- [Gesture Input](gesture-input.md): for mouse and touch pointer gestures +- [Keyboard Input](keyboard-input.md): for keystrokes + +## Joystick + +Flame provides a component capable of creating a virtual joystick for taking input for your game. +To use this feature, you need to create a `JoystickComponent`, configure it the way you want, and +add it to your game. + +Check this example to get a better understanding: + +```dart +class MyGame extends BaseGame with HasDraggableComponents { + + MyGame() { + joystick.addObserver(player); + add(player); + add(joystick); + } + + @override + Future onLoad() async { + super.onLoad(); + final image = await images.load('joystick.png'); + final sheet = SpriteSheet.fromColumnsAndRows( + image: image, + columns: 6, + rows: 1, + ); + final joystick = JoystickComponent( + knob: SpriteComponent( + sprite: sheet.getSpriteById(1), + size: Vector2.all(100), + ), + background: SpriteComponent( + sprite: sheet.getSpriteById(0), + size: Vector2.all(150), + ), + margin: const EdgeInsets.only(left: 40, bottom: 40), + ); + + final player = Player(joystick); + add(player); + add(joystick); + } +} + +class JoystickPlayer extends SpriteComponent with HasGameRef { + /// Pixels/s + double maxSpeed = 300.0; + + final JoystickComponent joystick; + + JoystickPlayer(this.joystick) + : super( + size: Vector2.all(100.0), + ) { + anchor = Anchor.center; + } + + @override + Future onLoad() async { + super.onLoad(); + sprite = await gameRef.loadSprite('layers/player.png'); + position = gameRef.size / 2; + } + + @override + void update(double dt) { + super.update(dt); + if (joystick.direction != JoystickDirection.idle) { + position.add(joystick.velocity * maxSpeed * dt); + angle = joystick.delta.screenAngle(); + } + } +} +``` + +So in this example, we create the classes `MyGame` and `Player`. `MyGame` creates a joystick which is +passed to the `Player` when it is created. In the `Player` class we act upon the current state of +the joystick. + +The joystick has a few fields that change depending on what state it is in. +These are the fields that should be used to know the state of the joystick: + - `intensity`: The percentage [0.0, 1.0] that the knob is dragged from the epicenter to the edge of + the joystick (or `knobRadius` if that is set). + - `delta`: The absolute amount (defined as a `Vector2`) that the knob is dragged from its epicenter. + - `velocity`: The percentage, presented as a `Vector2`, and direction that the knob is currently + pulled from its base position to a edge of the joystick. + +If you want to create buttons to go with your joystick, check out +[`MarginButtonComponent`](#HudButtonComponent). + +A full examples of how to use it can be found +[here](https://github.com/flame-engine/flame/tree/main/examples/lib/stories/input/joystick.dart). +And it can be seen running [here](https://examples.flame-engine.org/#/Controls_Joystick). + +## HudButtonComponent + +A `HudButtonComponent` is a button that can be defined with margins to the edge of the `Viewport` +instead of with a position. It takes two `PositionComponent`s. `button` and `buttonDown`, the first +is used for when the button is idle and the second is shown when the button is being pressed. The +second one is optional if you don't want to change the look of the button when it is pressed, or if +you handle this through the `button` component. + +As the name suggests this button is a hud by default, which means that it will be static on your +screen even if the camera for the game moves around. You can also use this component as a non-hud by +setting `hudButtonComponent.isHud = false;`. + +If you want to act upon the button being pressed (which would be the common thing to do) you can either pass in +a callback function as the `onPressed` argument, or you extend the component and override +`onTapDown`, `onTapUp` and/or `onTapCancel` and implement your logic in there. + +## Gamepad + +Flame has a separate plugin to support external game controllers (gamepads), checkout +[here](https://github.com/flame-engine/flame_gamepad) for more information. diff --git a/doc/summary.md b/doc/summary.md index ee2eb028b..6256bbb12 100644 --- a/doc/summary.md +++ b/doc/summary.md @@ -4,12 +4,16 @@ - [File structure](structure.md) - [Game loop](game.md) - [Components](components.md) - - [Input](input.md) - [Platforms](platforms.md) - [Collision detection](collision_detection.md) - [Effects](effects.md) - [Camera & Viewport](camera_and_viewport.md) +- Inputs + - [Gesture Input](gesture-input.md) + - [Keyboard Input](keyboard-input.md) + - [Other Inputs](other-inputs.md) + - Audio - [General Audio](audio.md) - [Looping Background Music](bgm.md) diff --git a/examples/lib/stories/camera_and_viewport/coordinate_systems.dart b/examples/lib/stories/camera_and_viewport/coordinate_systems.dart index 3b38cc118..b017456f2 100644 --- a/examples/lib/stories/camera_and_viewport/coordinate_systems.dart +++ b/examples/lib/stories/camera_and_viewport/coordinate_systems.dart @@ -211,22 +211,28 @@ class CoordinateSystemsGame extends BaseGame /// Camera controls. @override - void onKeyEvent(RawKeyEvent e) { - final isKeyDown = e is RawKeyDownEvent; - if (e.data.keyLabel == 'a') { + KeyEventResult onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + final isKeyDown = event is RawKeyDownEvent; + + if (event.logicalKey == LogicalKeyboardKey.keyA) { cameraVelocity.x = isKeyDown ? -1 : 0; - } else if (e.data.keyLabel == 'd') { + } else if (event.logicalKey == LogicalKeyboardKey.keyD) { cameraVelocity.x = isKeyDown ? 1 : 0; - } else if (e.data.keyLabel == 'w') { + } else if (event.logicalKey == LogicalKeyboardKey.keyW) { cameraVelocity.y = isKeyDown ? -1 : 0; - } else if (e.data.keyLabel == 's') { + } else if (event.logicalKey == LogicalKeyboardKey.keyS) { cameraVelocity.y = isKeyDown ? 1 : 0; } else if (isKeyDown) { - if (e.data.keyLabel == 'q') { + if (event.logicalKey == LogicalKeyboardKey.keyQ) { camera.zoom *= 2; - } else if (e.data.keyLabel == 'e') { + } else if (event.logicalKey == LogicalKeyboardKey.keyE) { camera.zoom /= 2; } } + + return KeyEventResult.handled; } } diff --git a/examples/lib/stories/camera_and_viewport/follow_object.dart b/examples/lib/stories/camera_and_viewport/follow_object.dart index 20a368b36..2c600724b 100644 --- a/examples/lib/stories/camera_and_viewport/follow_object.dart +++ b/examples/lib/stories/camera_and_viewport/follow_object.dart @@ -4,6 +4,7 @@ import 'package:flame/components.dart'; import 'package:flame/game.dart'; import 'package:flame/geometry.dart'; import 'package:flame/input.dart'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -12,7 +13,11 @@ import '../../commons/square_component.dart'; final R = Random(); class MovableSquare extends SquareComponent - with Hitbox, Collidable, HasGameRef { + with + Hitbox, + Collidable, + HasGameRef, + KeyboardHandler { static const double speed = 300; static final TextPaint textRenderer = TextPaint( config: const TextPaintConfig( @@ -55,6 +60,27 @@ class MovableSquare extends SquareComponent timer.start(); } } + + @override + bool onKeyEvent(RawKeyEvent event, Set keysPressed) { + final isKeyDown = event is RawKeyDownEvent; + + if (event.logicalKey == LogicalKeyboardKey.keyA) { + velocity.x = isKeyDown ? -1 : 0; + return false; + } else if (event.logicalKey == LogicalKeyboardKey.keyD) { + velocity.x = isKeyDown ? 1 : 0; + return false; + } else if (event.logicalKey == LogicalKeyboardKey.keyW) { + velocity.y = isKeyDown ? -1 : 0; + return false; + } else if (event.logicalKey == LogicalKeyboardKey.keyS) { + velocity.y = isKeyDown ? 1 : 0; + return false; + } + + return super.onKeyEvent(event, keysPressed); + } } class Map extends Component { @@ -112,7 +138,7 @@ class Rock extends SquareComponent with Hitbox, Collidable, Tappable { } class CameraAndViewportGame extends BaseGame - with KeyboardEvents, HasCollidables, HasTappableComponents { + with HasCollidables, HasTappableComponents, HasKeyboardHandlerComponents { late MovableSquare square; final Vector2 viewportResolution; @@ -134,18 +160,4 @@ class CameraAndViewportGame extends BaseGame add(Rock(Vector2(Map.genCoord(), Map.genCoord()))); } } - - @override - void onKeyEvent(RawKeyEvent e) { - final isKeyDown = e is RawKeyDownEvent; - if (e.data.keyLabel == 'a') { - square.velocity.x = isKeyDown ? -1 : 0; - } else if (e.data.keyLabel == 'd') { - square.velocity.x = isKeyDown ? 1 : 0; - } else if (e.data.keyLabel == 'w') { - square.velocity.y = isKeyDown ? -1 : 0; - } else if (e.data.keyLabel == 's') { - square.velocity.y = isKeyDown ? 1 : 0; - } - } } diff --git a/examples/lib/stories/input/keyboard.dart b/examples/lib/stories/input/keyboard.dart index 39eafd32a..b2598fbf1 100644 --- a/examples/lib/stories/input/keyboard.dart +++ b/examples/lib/stories/input/keyboard.dart @@ -1,9 +1,11 @@ import 'dart:ui'; import 'package:flame/game.dart'; + import 'package:flame/input.dart'; import 'package:flame/palette.dart'; -import 'package:flutter/services.dart' show RawKeyDownEvent, RawKeyEvent; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; class KeyboardGame extends Game with KeyboardEvents { static final Paint white = BasicPalette.white.paint(); @@ -24,16 +26,22 @@ class KeyboardGame extends Game with KeyboardEvents { } @override - void onKeyEvent(RawKeyEvent e) { - final isKeyDown = e is RawKeyDownEvent; - if (e.data.keyLabel == 'a') { + KeyEventResult onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + final isKeyDown = event is RawKeyDownEvent; + + if (event.logicalKey == LogicalKeyboardKey.keyA) { velocity.x = isKeyDown ? -1 : 0; - } else if (e.data.keyLabel == 'd') { + } else if (event.logicalKey == LogicalKeyboardKey.keyD) { velocity.x = isKeyDown ? 1 : 0; - } else if (e.data.keyLabel == 'w') { + } else if (event.logicalKey == LogicalKeyboardKey.keyW) { velocity.y = isKeyDown ? -1 : 0; - } else if (e.data.keyLabel == 's') { + } else if (event.logicalKey == LogicalKeyboardKey.keyS) { velocity.y = isKeyDown ? 1 : 0; } + + return super.onKeyEvent(event, keysPressed); } } diff --git a/examples/lib/stories/parallax/advanced.dart b/examples/lib/stories/parallax/advanced.dart index d03be2773..897d0fafc 100644 --- a/examples/lib/stories/parallax/advanced.dart +++ b/examples/lib/stories/parallax/advanced.dart @@ -1,6 +1,6 @@ import 'package:flame/components.dart'; -import 'package:flame/parallax.dart'; import 'package:flame/game.dart'; +import 'package:flame/parallax.dart'; class AdvancedParallaxGame extends BaseGame { final _layersMeta = { diff --git a/examples/lib/stories/parallax/no_fcs.dart b/examples/lib/stories/parallax/no_fcs.dart index 28f7d56c8..5f39322eb 100644 --- a/examples/lib/stories/parallax/no_fcs.dart +++ b/examples/lib/stories/parallax/no_fcs.dart @@ -1,7 +1,7 @@ import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; import 'package:flame/game.dart'; import 'package:flame/parallax.dart'; -import 'package:flame/extensions.dart'; import 'package:flutter/material.dart'; /// This examples serves to test the Parallax feature outside of the diff --git a/packages/flame/CHANGELOG.md b/packages/flame/CHANGELOG.md index 50c82cc8a..1fc2ee549 100644 --- a/packages/flame/CHANGELOG.md +++ b/packages/flame/CHANGELOG.md @@ -41,6 +41,8 @@ - Update `Camera` docs to showcase usage with `Game` class - Fixed a bug with `worldBounds` being set to `null` in `Camera` - `MockCanvas` is now strongly typed and matches numeric coordinates up to a tolerance + - Reviewed the keyboard API with new mixins (`KeyboardHandler` and `HasKeyboardHandlerComponents`) + - Added `FocusNode` on the game widget and improved keyboard handling in the game. ## [1.0.0-releasecandidate.13] - Fix camera not ending up in the correct position on long jumps diff --git a/packages/flame/lib/src/game/game.dart b/packages/flame/lib/src/game/game.dart index a12c63772..c3127ae28 100644 --- a/packages/flame/lib/src/game/game.dart +++ b/packages/flame/lib/src/game/game.dart @@ -3,7 +3,6 @@ import 'dart:ui'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import '../assets/assets_cache.dart'; @@ -13,7 +12,6 @@ import '../extensions/vector2.dart'; import '../sprite.dart'; import '../sprite_animation.dart'; import 'game_render_box.dart'; -import 'mixins/keyboard.dart'; import 'projector.dart'; /// Represents a generic game. @@ -87,10 +85,6 @@ abstract class Game extends Projector { /// Use for calculating the FPS. void onTimingsCallback(List timings) {} - void _handleKeyEvent(RawKeyEvent e) { - (this as KeyboardEvents).onKeyEvent(e); - } - /// Marks game as not attached tto any widget tree. /// /// Should be called manually. @@ -107,11 +101,7 @@ abstract class Game extends Projector { // Called when the Game widget is attached @mustCallSuper - void onAttach() { - if (this is KeyboardEvents) { - RawKeyboard.instance.addListener(_handleKeyEvent); - } - } + void onAttach() {} /// Marks game as not attached tto any widget tree. /// @@ -125,12 +115,6 @@ abstract class Game extends Projector { // Called when the Game widget is detached @mustCallSuper void onDetach() { - // Keeping this here, because if we leave this on HasWidgetsOverlay - // and somebody overrides this and forgets to call the stream close - // we can face some leaks. - if (this is KeyboardEvents) { - RawKeyboard.instance.removeListener(_handleKeyEvent); - } images.clearCache(); } 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 83308e656..ae27b55f0 100644 --- a/packages/flame/lib/src/game/game_widget/game_widget.dart +++ b/packages/flame/lib/src/game/game_widget/game_widget.dart @@ -1,7 +1,9 @@ import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import '../../../extensions.dart'; +import '../../../input.dart'; import '../../extensions/size.dart'; import '../game.dart'; import '../game_render_box.dart'; @@ -58,6 +60,14 @@ class GameWidget extends StatefulWidget { /// - [Game.overlays] final List? initialActiveOverlays; + /// The [FocusNode] to control the games focus to receive event inputs. + /// If omitted, defaults to an internally controlled focus node. + final FocusNode? focusNode; + + /// Whether the [focusNode] requests focus once the game is mounted. + /// Defaults to true. + final bool autofocus; + /// Renders a [game] in a flutter widget tree. /// /// Ex: @@ -104,6 +114,8 @@ class GameWidget extends StatefulWidget { this.backgroundBuilder, this.overlayBuilderMap, this.initialActiveOverlays, + this.focusNode, + this.autofocus = true, }) : super(key: key); /// Renders a [game] in a flutter widget tree alongside widgets overlays. @@ -117,6 +129,7 @@ class _GameWidgetState extends State> { Set initialActiveOverlays = {}; Future? _gameLoaderFuture; + Future get _gameLoaderFutureCache => _gameLoaderFuture ?? (_gameLoaderFuture = widget.game.onLoad()); @@ -187,6 +200,14 @@ class _GameWidgetState extends State> { }); } + KeyEventResult _handleKeyEvent(FocusNode focusNode, RawKeyEvent event) { + final game = widget.game; + if (game is KeyboardEvents) { + return game.onKeyEvent(event, RawKeyboard.instance.keysPressed); + } + return KeyEventResult.handled; + } + @override Widget build(BuildContext context) { Widget internalGameWidget = _GameRenderObjectWidget(widget.game); @@ -228,30 +249,36 @@ class _GameWidgetState extends State> { // We can use Directionality.maybeOf when that method lands on stable final textDir = widget.textDirection ?? TextDirection.ltr; - return Directionality( - textDirection: textDir, - child: Container( - color: widget.game.backgroundColor(), - child: LayoutBuilder( - builder: (_, BoxConstraints constraints) { - widget.game.onResize(constraints.biggest.toVector2()); - return FutureBuilder( - future: _gameLoaderFutureCache, - builder: (_, snapshot) { - if (snapshot.hasError) { - if (widget.errorBuilder == null) { - throw snapshot.error!; - } else { - return widget.errorBuilder!(context, snapshot.error!); + return Focus( + focusNode: widget.focusNode, + autofocus: widget.autofocus, + onKey: _handleKeyEvent, + child: Directionality( + textDirection: textDir, + child: Container( + color: widget.game.backgroundColor(), + child: LayoutBuilder( + builder: (_, BoxConstraints constraints) { + widget.game.onResize(constraints.biggest.toVector2()); + return FutureBuilder( + future: _gameLoaderFutureCache, + builder: (_, snapshot) { + if (snapshot.hasError) { + final errorBuilder = widget.errorBuilder; + if (errorBuilder == null) { + throw snapshot.error!; + } else { + return errorBuilder(context, snapshot.error!); + } } - } - if (snapshot.connectionState == ConnectionState.done) { - return Stack(children: stackedWidgets); - } - return widget.loadingBuilder?.call(context) ?? Container(); - }, - ); - }, + if (snapshot.connectionState == ConnectionState.done) { + return Stack(children: stackedWidgets); + } + return widget.loadingBuilder?.call(context) ?? Container(); + }, + ); + }, + ), ), ), ); diff --git a/packages/flame/lib/src/game/mixins/keyboard.dart b/packages/flame/lib/src/game/mixins/keyboard.dart index d49525c30..19c827c16 100644 --- a/packages/flame/lib/src/game/mixins/keyboard.dart +++ b/packages/flame/lib/src/game/mixins/keyboard.dart @@ -1,7 +1,81 @@ +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../../components.dart'; +import '../../../game.dart'; -import '../game.dart'; - -mixin KeyboardEvents on Game { - void onKeyEvent(RawKeyEvent event); +/// A [BaseComponent] mixin to add keyboard handling capability to components. +/// Must be used in components that can only be added to games that are mixed +/// with [HasKeyboardHandlerComponents]. +mixin KeyboardHandler on BaseComponent { + bool onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + return true; + } +} + +/// A [BaseGame] mixin that implements [KeyboardEvents] with keyboard event +/// propagation to components that are mixed with [KeyboardHandler]. +/// +/// Attention: should not be used alongside [KeyboardEvents] in a game subclass. +/// Using this mixin remove the necessity of [KeyboardEvents]. +mixin HasKeyboardHandlerComponents on BaseGame implements KeyboardEvents { + bool _handleKeyboardEvent( + bool Function(KeyboardHandler child) keyboardEventHandler, + ) { + var shouldContinue = true; + for (final c in components.toList().reversed) { + if (c is BaseComponent) { + shouldContinue = c.propagateToChildren( + keyboardEventHandler, + ); + } + if (c is KeyboardHandler && shouldContinue) { + shouldContinue = keyboardEventHandler(c); + } + if (!shouldContinue) { + break; + } + } + + return shouldContinue; + } + + @override + @mustCallSuper + KeyEventResult onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + final blockedPropagation = !_handleKeyboardEvent( + (KeyboardHandler child) => child.onKeyEvent(event, keysPressed), + ); + + // If any component received the event, return handled, + // otherwise, ignore it. + if (blockedPropagation) { + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } +} + +/// A [Game] mixin to make a game subclass sensitive to keyboard events. +/// +/// Override [onKeyEvent] to customize the keyboard handling behavior. +mixin KeyboardEvents on Game { + KeyEventResult onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + assert( + this is HasKeyboardHandlerComponents, + 'A keyboard event was registered by KeyboardEvents for a game also ' + 'mixed with HasKeyboardHandlerComponents. Do not mix with both, ' + 'HasKeyboardHandlerComponents removes the necessity of KeyboardEvents', + ); + + return KeyEventResult.handled; + } } diff --git a/packages/flame/test/components/collision_detection_test.dart b/packages/flame/test/components/collision_detection_test.dart index 282f11724..5d55fa351 100644 --- a/packages/flame/test/components/collision_detection_test.dart +++ b/packages/flame/test/components/collision_detection_test.dart @@ -1,9 +1,9 @@ -import 'package:flame/geometry.dart'; import 'package:flame/extensions.dart'; +import 'package:flame/geometry.dart'; import 'package:flame/geometry.dart' as geometry; import 'package:flame/src/geometry/circle.dart'; -import 'package:flame/src/geometry/line_segment.dart'; import 'package:flame/src/geometry/line.dart'; +import 'package:flame/src/geometry/line_segment.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/flame/test/game/game_widget/game_widget_keyboard_test.dart b/packages/flame/test/game/game_widget/game_widget_keyboard_test.dart new file mode 100644 index 000000000..635cbdcc6 --- /dev/null +++ b/packages/flame/test/game/game_widget/game_widget_keyboard_test.dart @@ -0,0 +1,153 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame/input.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Vector2 size = Vector2(1.0, 1.0); + +class _KeyboardEventsGame extends Game with KeyboardEvents { + final List keysPressed = []; + + _KeyboardEventsGame(); + + @override + void render(Canvas canvas) {} + + @override + void update(double dt) {} + + @override + KeyEventResult onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + this.keysPressed.add(event.character ?? 'none'); + + return KeyEventResult.handled; + } +} + +class _KeyboardHandlerComponent extends BaseComponent with KeyboardHandler { + final List keysPressed = []; + + @override + bool onKeyEvent(RawKeyEvent event, Set keysPressed) { + this.keysPressed.add(event.character ?? 'none'); + return false; + } +} + +class _HasKeyboardHandlerComponentsGame extends BaseGame + with HasKeyboardHandlerComponents { + _HasKeyboardHandlerComponentsGame(); + + late _KeyboardHandlerComponent keyboardHandler; + + @override + Future onLoad() async { + keyboardHandler = _KeyboardHandlerComponent(); + add(keyboardHandler); + } +} + +class _GamePage extends StatelessWidget { + final Widget child; + + const _GamePage({Key? key, required this.child}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: child, + ), + Positioned( + top: 0, + right: 0, + child: ElevatedButton( + child: const Text('Back'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ], + ), + ), + ); + } +} + +void main() async { + testWidgets('Adds focus', (tester) async { + final focusNode = FocusNode(); + + final game = _KeyboardEventsGame(); + + await tester.pumpWidget( + _GamePage( + child: GameWidget( + game: game, + focusNode: focusNode, + ), + ), + ); + + expect(focusNode.hasFocus, true); + }); + + testWidgets('KeyboardEvents receives keys', (tester) async { + final focusNode = FocusNode(); + + final game = _KeyboardEventsGame(); + + await tester.pumpWidget( + _GamePage( + child: GameWidget( + game: game, + focusNode: focusNode, + ), + ), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC); + + expect(game.keysPressed, ['a', 'b', 'c']); + }); + + testWidgets('HasKeyboardHandlerComponents receives keys', (tester) async { + final focusNode = FocusNode(); + + final game = _HasKeyboardHandlerComponentsGame(); + + await tester.pumpWidget( + _GamePage( + child: GameWidget( + game: game, + focusNode: focusNode, + ), + ), + ); + + game.onResize(size); + game.update(0.1); + + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyI); + + expect(game.keyboardHandler.keysPressed, ['z', 'f', 'i']); + }); +} diff --git a/packages/flame/test/image_composition_test.dart b/packages/flame/test/image_composition_test.dart index 0a72d245c..979844722 100644 --- a/packages/flame/test/image_composition_test.dart +++ b/packages/flame/test/image_composition_test.dart @@ -1,9 +1,9 @@ import 'dart:ui'; +import 'package:flame/extensions.dart'; import 'package:flame/image_composition.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; -import 'package:flame/extensions.dart'; class MockImage extends Mock implements Image {} diff --git a/tutorials/2_sprite_animations_gestures/README.md b/tutorials/2_sprite_animations_gestures/README.md index 312492e75..8b8c18684 100644 --- a/tutorials/2_sprite_animations_gestures/README.md +++ b/tutorials/2_sprite_animations_gestures/README.md @@ -162,7 +162,7 @@ Finally, we just render it on the game `render` function: ```dart @override void render(Canvas canvas) { - // Running robot render omited + // Running robot render omitted final button = isPressed ? pressedButton : unpressedButton; button.render(canvas, position: buttonPosition, size: buttonSize); @@ -173,7 +173,8 @@ You now should see the button on the screen, but right now, it is pretty much us So, to change that, we will now add some interactivity to our game and make the button tappable/clickable. -Flame provides several input handlers, which you can check with more in depth on [our docs](https://github.com/flame-engine/flame/blob/main/doc/input.md). For this tutorial, we will be using the `TapDetector` which enables us to detect taps on the screen, as well as mouse click when running on web or desktop. +Flame provides several input handlers, about which you can check with more in depth on [our docs](https://github.com/flame-engine/flame/blob/main/doc/gesture-input.md). +For this tutorial, we will be using the `TapDetector` which enables us to detect taps on the screen, as well as mouse click when running on web or desktop. All Flame input detectors are mixins which can be added to your game, enabling you to override listener methods related to that detector. For the `TapDetector`, we will need to override three methods: