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 <erickzanardoo@gmail.com>

* rename test

* Apply suggestions from code review

Co-authored-by: Luan Nico <luanpotter27@gmail.com>

* fix test

* Update tutorials/2_sprite_animations_gestures/README.md

Co-authored-by: Luan Nico <luanpotter27@gmail.com>

* docs

* Apply suggestions from code review

Co-authored-by: Jochum van der Ploeg <jochum@vdploeg.net>

* yo

Co-authored-by: Erick <erickzanardoo@gmail.com>
Co-authored-by: Luan Nico <luanpotter27@gmail.com>
Co-authored-by: Jochum van der Ploeg <jochum@vdploeg.net>
This commit is contained in:
Renan
2021-08-31 23:38:21 +01:00
committed by GitHub
parent 20def310e8
commit c833319c49
17 changed files with 580 additions and 231 deletions

View File

@ -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 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 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 An example of you to use it can be seen
[here](https://github.com/flame-engine/flame/tree/main/examples/lib/stories/). [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<void> 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<void> 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.

94
doc/keyboard-input.md Normal file
View File

@ -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<LogicalKeyboardKey> 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).

123
doc/other-inputs.md Normal file
View File

@ -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<void> 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<void> 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.

View File

@ -4,12 +4,16 @@
- [File structure](structure.md) - [File structure](structure.md)
- [Game loop](game.md) - [Game loop](game.md)
- [Components](components.md) - [Components](components.md)
- [Input](input.md)
- [Platforms](platforms.md) - [Platforms](platforms.md)
- [Collision detection](collision_detection.md) - [Collision detection](collision_detection.md)
- [Effects](effects.md) - [Effects](effects.md)
- [Camera & Viewport](camera_and_viewport.md) - [Camera & Viewport](camera_and_viewport.md)
- Inputs
- [Gesture Input](gesture-input.md)
- [Keyboard Input](keyboard-input.md)
- [Other Inputs](other-inputs.md)
- Audio - Audio
- [General Audio](audio.md) - [General Audio](audio.md)
- [Looping Background Music](bgm.md) - [Looping Background Music](bgm.md)

View File

@ -211,22 +211,28 @@ class CoordinateSystemsGame extends BaseGame
/// Camera controls. /// Camera controls.
@override @override
void onKeyEvent(RawKeyEvent e) { KeyEventResult onKeyEvent(
final isKeyDown = e is RawKeyDownEvent; RawKeyEvent event,
if (e.data.keyLabel == 'a') { Set<LogicalKeyboardKey> keysPressed,
) {
final isKeyDown = event is RawKeyDownEvent;
if (event.logicalKey == LogicalKeyboardKey.keyA) {
cameraVelocity.x = isKeyDown ? -1 : 0; cameraVelocity.x = isKeyDown ? -1 : 0;
} else if (e.data.keyLabel == 'd') { } else if (event.logicalKey == LogicalKeyboardKey.keyD) {
cameraVelocity.x = isKeyDown ? 1 : 0; cameraVelocity.x = isKeyDown ? 1 : 0;
} else if (e.data.keyLabel == 'w') { } else if (event.logicalKey == LogicalKeyboardKey.keyW) {
cameraVelocity.y = isKeyDown ? -1 : 0; cameraVelocity.y = isKeyDown ? -1 : 0;
} else if (e.data.keyLabel == 's') { } else if (event.logicalKey == LogicalKeyboardKey.keyS) {
cameraVelocity.y = isKeyDown ? 1 : 0; cameraVelocity.y = isKeyDown ? 1 : 0;
} else if (isKeyDown) { } else if (isKeyDown) {
if (e.data.keyLabel == 'q') { if (event.logicalKey == LogicalKeyboardKey.keyQ) {
camera.zoom *= 2; camera.zoom *= 2;
} else if (e.data.keyLabel == 'e') { } else if (event.logicalKey == LogicalKeyboardKey.keyE) {
camera.zoom /= 2; camera.zoom /= 2;
} }
} }
return KeyEventResult.handled;
} }
} }

View File

@ -4,6 +4,7 @@ import 'package:flame/components.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:flame/geometry.dart'; import 'package:flame/geometry.dart';
import 'package:flame/input.dart'; import 'package:flame/input.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -12,7 +13,11 @@ import '../../commons/square_component.dart';
final R = Random(); final R = Random();
class MovableSquare extends SquareComponent class MovableSquare extends SquareComponent
with Hitbox, Collidable, HasGameRef<CameraAndViewportGame> { with
Hitbox,
Collidable,
HasGameRef<CameraAndViewportGame>,
KeyboardHandler {
static const double speed = 300; static const double speed = 300;
static final TextPaint textRenderer = TextPaint( static final TextPaint textRenderer = TextPaint(
config: const TextPaintConfig( config: const TextPaintConfig(
@ -55,6 +60,27 @@ class MovableSquare extends SquareComponent
timer.start(); timer.start();
} }
} }
@override
bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> 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 { class Map extends Component {
@ -112,7 +138,7 @@ class Rock extends SquareComponent with Hitbox, Collidable, Tappable {
} }
class CameraAndViewportGame extends BaseGame class CameraAndViewportGame extends BaseGame
with KeyboardEvents, HasCollidables, HasTappableComponents { with HasCollidables, HasTappableComponents, HasKeyboardHandlerComponents {
late MovableSquare square; late MovableSquare square;
final Vector2 viewportResolution; final Vector2 viewportResolution;
@ -134,18 +160,4 @@ class CameraAndViewportGame extends BaseGame
add(Rock(Vector2(Map.genCoord(), Map.genCoord()))); 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;
}
}
} }

View File

@ -1,9 +1,11 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:flame/input.dart'; import 'package:flame/input.dart';
import 'package:flame/palette.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 { class KeyboardGame extends Game with KeyboardEvents {
static final Paint white = BasicPalette.white.paint(); static final Paint white = BasicPalette.white.paint();
@ -24,16 +26,22 @@ class KeyboardGame extends Game with KeyboardEvents {
} }
@override @override
void onKeyEvent(RawKeyEvent e) { KeyEventResult onKeyEvent(
final isKeyDown = e is RawKeyDownEvent; RawKeyEvent event,
if (e.data.keyLabel == 'a') { Set<LogicalKeyboardKey> keysPressed,
) {
final isKeyDown = event is RawKeyDownEvent;
if (event.logicalKey == LogicalKeyboardKey.keyA) {
velocity.x = isKeyDown ? -1 : 0; velocity.x = isKeyDown ? -1 : 0;
} else if (e.data.keyLabel == 'd') { } else if (event.logicalKey == LogicalKeyboardKey.keyD) {
velocity.x = isKeyDown ? 1 : 0; velocity.x = isKeyDown ? 1 : 0;
} else if (e.data.keyLabel == 'w') { } else if (event.logicalKey == LogicalKeyboardKey.keyW) {
velocity.y = isKeyDown ? -1 : 0; velocity.y = isKeyDown ? -1 : 0;
} else if (e.data.keyLabel == 's') { } else if (event.logicalKey == LogicalKeyboardKey.keyS) {
velocity.y = isKeyDown ? 1 : 0; velocity.y = isKeyDown ? 1 : 0;
} }
return super.onKeyEvent(event, keysPressed);
} }
} }

View File

@ -1,6 +1,6 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/parallax.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:flame/parallax.dart';
class AdvancedParallaxGame extends BaseGame { class AdvancedParallaxGame extends BaseGame {
final _layersMeta = { final _layersMeta = {

View File

@ -1,7 +1,7 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:flame/parallax.dart'; import 'package:flame/parallax.dart';
import 'package:flame/extensions.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// This examples serves to test the Parallax feature outside of the /// This examples serves to test the Parallax feature outside of the

View File

@ -41,6 +41,8 @@
- Update `Camera` docs to showcase usage with `Game` class - Update `Camera` docs to showcase usage with `Game` class
- Fixed a bug with `worldBounds` being set to `null` in `Camera` - Fixed a bug with `worldBounds` being set to `null` in `Camera`
- `MockCanvas` is now strongly typed and matches numeric coordinates up to a tolerance - `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] ## [1.0.0-releasecandidate.13]
- Fix camera not ending up in the correct position on long jumps - Fix camera not ending up in the correct position on long jumps

View File

@ -3,7 +3,6 @@ import 'dart:ui';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../assets/assets_cache.dart'; import '../assets/assets_cache.dart';
@ -13,7 +12,6 @@ import '../extensions/vector2.dart';
import '../sprite.dart'; import '../sprite.dart';
import '../sprite_animation.dart'; import '../sprite_animation.dart';
import 'game_render_box.dart'; import 'game_render_box.dart';
import 'mixins/keyboard.dart';
import 'projector.dart'; import 'projector.dart';
/// Represents a generic game. /// Represents a generic game.
@ -87,10 +85,6 @@ abstract class Game extends Projector {
/// Use for calculating the FPS. /// Use for calculating the FPS.
void onTimingsCallback(List<FrameTiming> timings) {} void onTimingsCallback(List<FrameTiming> timings) {}
void _handleKeyEvent(RawKeyEvent e) {
(this as KeyboardEvents).onKeyEvent(e);
}
/// Marks game as not attached tto any widget tree. /// Marks game as not attached tto any widget tree.
/// ///
/// Should be called manually. /// Should be called manually.
@ -107,11 +101,7 @@ abstract class Game extends Projector {
// Called when the Game widget is attached // Called when the Game widget is attached
@mustCallSuper @mustCallSuper
void onAttach() { void onAttach() {}
if (this is KeyboardEvents) {
RawKeyboard.instance.addListener(_handleKeyEvent);
}
}
/// Marks game as not attached tto any widget tree. /// 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 // Called when the Game widget is detached
@mustCallSuper @mustCallSuper
void onDetach() { 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(); images.clearCache();
} }

View File

@ -1,7 +1,9 @@
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../../../extensions.dart'; import '../../../extensions.dart';
import '../../../input.dart';
import '../../extensions/size.dart'; import '../../extensions/size.dart';
import '../game.dart'; import '../game.dart';
import '../game_render_box.dart'; import '../game_render_box.dart';
@ -58,6 +60,14 @@ class GameWidget<T extends Game> extends StatefulWidget {
/// - [Game.overlays] /// - [Game.overlays]
final List<String>? initialActiveOverlays; final List<String>? 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. /// Renders a [game] in a flutter widget tree.
/// ///
/// Ex: /// Ex:
@ -104,6 +114,8 @@ class GameWidget<T extends Game> extends StatefulWidget {
this.backgroundBuilder, this.backgroundBuilder,
this.overlayBuilderMap, this.overlayBuilderMap,
this.initialActiveOverlays, this.initialActiveOverlays,
this.focusNode,
this.autofocus = true,
}) : super(key: key); }) : super(key: key);
/// Renders a [game] in a flutter widget tree alongside widgets overlays. /// Renders a [game] in a flutter widget tree alongside widgets overlays.
@ -117,6 +129,7 @@ class _GameWidgetState<T extends Game> extends State<GameWidget<T>> {
Set<String> initialActiveOverlays = {}; Set<String> initialActiveOverlays = {};
Future<void>? _gameLoaderFuture; Future<void>? _gameLoaderFuture;
Future<void> get _gameLoaderFutureCache => Future<void> get _gameLoaderFutureCache =>
_gameLoaderFuture ?? (_gameLoaderFuture = widget.game.onLoad()); _gameLoaderFuture ?? (_gameLoaderFuture = widget.game.onLoad());
@ -187,6 +200,14 @@ class _GameWidgetState<T extends Game> extends State<GameWidget<T>> {
}); });
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget internalGameWidget = _GameRenderObjectWidget(widget.game); Widget internalGameWidget = _GameRenderObjectWidget(widget.game);
@ -228,7 +249,11 @@ class _GameWidgetState<T extends Game> extends State<GameWidget<T>> {
// We can use Directionality.maybeOf when that method lands on stable // We can use Directionality.maybeOf when that method lands on stable
final textDir = widget.textDirection ?? TextDirection.ltr; final textDir = widget.textDirection ?? TextDirection.ltr;
return Directionality( return Focus(
focusNode: widget.focusNode,
autofocus: widget.autofocus,
onKey: _handleKeyEvent,
child: Directionality(
textDirection: textDir, textDirection: textDir,
child: Container( child: Container(
color: widget.game.backgroundColor(), color: widget.game.backgroundColor(),
@ -239,10 +264,11 @@ class _GameWidgetState<T extends Game> extends State<GameWidget<T>> {
future: _gameLoaderFutureCache, future: _gameLoaderFutureCache,
builder: (_, snapshot) { builder: (_, snapshot) {
if (snapshot.hasError) { if (snapshot.hasError) {
if (widget.errorBuilder == null) { final errorBuilder = widget.errorBuilder;
if (errorBuilder == null) {
throw snapshot.error!; throw snapshot.error!;
} else { } else {
return widget.errorBuilder!(context, snapshot.error!); return errorBuilder(context, snapshot.error!);
} }
} }
if (snapshot.connectionState == ConnectionState.done) { if (snapshot.connectionState == ConnectionState.done) {
@ -254,6 +280,7 @@ class _GameWidgetState<T extends Game> extends State<GameWidget<T>> {
}, },
), ),
), ),
),
); );
} }

View File

@ -1,7 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../../components.dart';
import '../../../game.dart';
import '../game.dart'; /// 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
mixin KeyboardEvents on Game { /// with [HasKeyboardHandlerComponents].
void onKeyEvent(RawKeyEvent event); mixin KeyboardHandler on BaseComponent {
bool onKeyEvent(
RawKeyEvent event,
Set<LogicalKeyboardKey> 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<KeyboardHandler>(
keyboardEventHandler,
);
}
if (c is KeyboardHandler && shouldContinue) {
shouldContinue = keyboardEventHandler(c);
}
if (!shouldContinue) {
break;
}
}
return shouldContinue;
}
@override
@mustCallSuper
KeyEventResult onKeyEvent(
RawKeyEvent event,
Set<LogicalKeyboardKey> 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<LogicalKeyboardKey> 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;
}
} }

View File

@ -1,9 +1,9 @@
import 'package:flame/geometry.dart';
import 'package:flame/extensions.dart'; import 'package:flame/extensions.dart';
import 'package:flame/geometry.dart';
import 'package:flame/geometry.dart' as geometry; import 'package:flame/geometry.dart' as geometry;
import 'package:flame/src/geometry/circle.dart'; 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.dart';
import 'package:flame/src/geometry/line_segment.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
void main() { void main() {

View File

@ -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<String> keysPressed = [];
_KeyboardEventsGame();
@override
void render(Canvas canvas) {}
@override
void update(double dt) {}
@override
KeyEventResult onKeyEvent(
RawKeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
this.keysPressed.add(event.character ?? 'none');
return KeyEventResult.handled;
}
}
class _KeyboardHandlerComponent extends BaseComponent with KeyboardHandler {
final List<String> keysPressed = [];
@override
bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
this.keysPressed.add(event.character ?? 'none');
return false;
}
}
class _HasKeyboardHandlerComponentsGame extends BaseGame
with HasKeyboardHandlerComponents {
_HasKeyboardHandlerComponentsGame();
late _KeyboardHandlerComponent keyboardHandler;
@override
Future<void> 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']);
});
}

View File

@ -1,9 +1,9 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flame/extensions.dart';
import 'package:flame/image_composition.dart'; import 'package:flame/image_composition.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'package:flame/extensions.dart';
class MockImage extends Mock implements Image {} class MockImage extends Mock implements Image {}

View File

@ -162,7 +162,7 @@ Finally, we just render it on the game `render` function:
```dart ```dart
@override @override
void render(Canvas canvas) { void render(Canvas canvas) {
// Running robot render omited // Running robot render omitted
final button = isPressed ? pressedButton : unpressedButton; final button = isPressed ? pressedButton : unpressedButton;
button.render(canvas, position: buttonPosition, size: buttonSize); 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. 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: 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: