Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Added HardwareKeyboardDetector #2257

Merged
merged 27 commits into from
Jan 27, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0b401cc
RawKeyboardDetector
st-pasha Jan 7, 2023
6fb5fc1
remove an argument
st-pasha Jan 7, 2023
f6351dd
Merge branch 'main' into ps.raw-keyboard-events
st-pasha Jan 11, 2023
ff19335
rename onKeyEvent->onRawKeyEvent
st-pasha Jan 12, 2023
4d0cd11
start working on example
st-pasha Jan 12, 2023
fb88e15
Merge branch 'main' into ps.raw-keyboard-events
st-pasha Jan 12, 2023
02b33d0
Added example
st-pasha Jan 14, 2023
e3016e6
Merge branch 'main' into ps.raw-keyboard-events
st-pasha Jan 14, 2023
8ba5028
add a dictionary word
st-pasha Jan 14, 2023
38dad23
rename RawKeyboardDetector->HardwareKeyboardDetector, and added tests
st-pasha Jan 15, 2023
3065ac8
format
st-pasha Jan 15, 2023
c51f5d3
Merge branch 'main' into ps.raw-keyboard-events
st-pasha Jan 15, 2023
a886391
added pauseKeyEvents property
st-pasha Jan 15, 2023
eb844c2
update doccomments
st-pasha Jan 15, 2023
aaec880
Merge branch 'main' into ps.raw-keyboard-events
st-pasha Jan 26, 2023
e156502
analyze warnings
st-pasha Jan 26, 2023
e59cf4e
more robust pauseKeyEvents
st-pasha Jan 26, 2023
b7c8d27
add external links
st-pasha Jan 26, 2023
767fab5
convert HardwareKeyboardDetector into a component
st-pasha Jan 26, 2023
87a096e
analysis
st-pasha Jan 26, 2023
44bc77b
normalize events upon mounting/dismounting
st-pasha Jan 26, 2023
0ae6828
comment
st-pasha Jan 26, 2023
a64075d
minor
st-pasha Jan 26, 2023
81bcf7b
Merge branch 'main' into ps.raw-keyboard-events
st-pasha Jan 26, 2023
a3d485d
fix tests
st-pasha Jan 26, 2023
6628295
Merge branch 'main' into ps.raw-keyboard-events
st-pasha Jan 27, 2023
a87a013
Merge branch 'main' into ps.raw-keyboard-events
spydon Jan 27, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/.cspell/gamedev_dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ arities
arity
autofocus
backpressure
backquote
backtick
backticks
bitfield
Expand Down
7 changes: 7 additions & 0 deletions examples/lib/stories/input/input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:examples/stories/input/mouse_movement_example.dart';
import 'package:examples/stories/input/multitap_advanced_example.dart';
import 'package:examples/stories/input/multitap_example.dart';
import 'package:examples/stories/input/overlapping_tappables_example.dart';
import 'package:examples/stories/input/raw_keyboard_example.dart';
import 'package:examples/stories/input/scroll_example.dart';
import 'package:examples/stories/input/tappables_example.dart';
import 'package:flame/game.dart';
Expand Down Expand Up @@ -55,6 +56,12 @@ void addInputStories(Dashbook dashbook) {
codeLink: baseLink('input/keyboard_listener_component_example.dart'),
info: KeyboardListenerComponentExample.description,
)
..add(
'Raw Keyboard',
(_) => GameWidget(game: RawKeyboardExample()),
codeLink: baseLink('input/raw_keyboard_example.dart'),
info: RawKeyboardExample.description,
)
..add(
'Mouse Movement',
(_) => GameWidget(game: MouseMovementExample()),
Expand Down
247 changes: 247 additions & 0 deletions examples/lib/stories/input/raw_keyboard_example.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flame/text.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';

class RawKeyboardExample extends FlameGame with RawKeyboardDetector {
static String description = '''
This example uses the RawKeyboardDetector mixin in order to keep track of
which keys on a keyboard are currently pressed.
''';

/// The list of [KeyboardKey] components currently shown on the screen. This
/// list is re-generated on every RawKeyEvent. These components are also
/// attached as children.
final List<KeyboardKey> keyComponents = [];

@override
void onLoad() {
add(
TextComponent(
text: 'Press any key(s)',
textRenderer: TextPaint(
style: const TextStyle(
fontSize: 12,
color: Color(0x77ffffff),
),
),
)..position = Vector2(80, 60),
);
}

@override
void onRawKeyEvent(RawKeyEvent event) {
clearKeys();
var x0 = 80.0;
const y0 = 100.0;
for (final key in physicalKeysPressed) {
final keyComponent = KeyboardKey(
text: keyNames[key] ?? '[${key.usbHidUsage} ${key.debugName}]',
position: Vector2(x0, y0),
);
keyComponents.add(keyComponent);
x0 += keyComponent.width + 10;
}
addAll(keyComponents);
}

void clearKeys() {
for (final key in keyComponents) {
key.visible = false;
}
removeAll(keyComponents);
keyComponents.clear();
}

/// The names of keyboard keys (at least the most important ones). We can't
/// rely on `key.debugName` because this property is not available in release
/// builds.
static Map<PhysicalKeyboardKey, String> keyNames = {
PhysicalKeyboardKey.hyper: 'Hyper',
PhysicalKeyboardKey.superKey: 'Super',
PhysicalKeyboardKey.fn: 'Fn',
PhysicalKeyboardKey.fnLock: 'FnLock',
PhysicalKeyboardKey.gameButton1: 'Game 1',
PhysicalKeyboardKey.gameButton2: 'Game 2 ',
PhysicalKeyboardKey.gameButton3: 'Game 3',
PhysicalKeyboardKey.gameButton4: 'Game 4',
PhysicalKeyboardKey.gameButton5: 'Game 5',
PhysicalKeyboardKey.gameButton6: 'Game 6',
PhysicalKeyboardKey.gameButton7: 'Game 7',
PhysicalKeyboardKey.gameButton8: 'Game 8',
PhysicalKeyboardKey.gameButtonA: 'Game A',
PhysicalKeyboardKey.gameButtonB: 'Game B',
PhysicalKeyboardKey.gameButtonC: 'Game C',
PhysicalKeyboardKey.gameButtonLeft1: 'Game L1',
PhysicalKeyboardKey.gameButtonLeft2: 'Game L2',
PhysicalKeyboardKey.gameButtonMode: 'Game Mode',
PhysicalKeyboardKey.gameButtonRight1: 'Game R1',
PhysicalKeyboardKey.gameButtonRight2: 'Game R2',
PhysicalKeyboardKey.gameButtonSelect: 'Game Select',
PhysicalKeyboardKey.gameButtonStart: 'Game Start',
PhysicalKeyboardKey.gameButtonThumbLeft: 'Game LThumb',
PhysicalKeyboardKey.gameButtonThumbRight: 'Game RThumb',
PhysicalKeyboardKey.gameButtonX: 'Game X',
PhysicalKeyboardKey.gameButtonY: 'Game Y',
PhysicalKeyboardKey.gameButtonZ: 'Game Z',
PhysicalKeyboardKey.keyA: 'A',
PhysicalKeyboardKey.keyB: 'B',
PhysicalKeyboardKey.keyC: 'C',
PhysicalKeyboardKey.keyD: 'D',
PhysicalKeyboardKey.keyE: 'E',
PhysicalKeyboardKey.keyF: 'F',
PhysicalKeyboardKey.keyG: 'G',
PhysicalKeyboardKey.keyH: 'H',
PhysicalKeyboardKey.keyI: 'I',
PhysicalKeyboardKey.keyJ: 'J',
PhysicalKeyboardKey.keyK: 'K',
PhysicalKeyboardKey.keyL: 'L',
PhysicalKeyboardKey.keyM: 'M',
PhysicalKeyboardKey.keyN: 'N',
PhysicalKeyboardKey.keyO: 'O',
PhysicalKeyboardKey.keyP: 'P',
PhysicalKeyboardKey.keyQ: 'Q',
PhysicalKeyboardKey.keyR: 'R',
PhysicalKeyboardKey.keyS: 'S',
PhysicalKeyboardKey.keyT: 'T',
PhysicalKeyboardKey.keyU: 'U',
PhysicalKeyboardKey.keyV: 'V',
PhysicalKeyboardKey.keyW: 'W',
PhysicalKeyboardKey.keyX: 'X',
PhysicalKeyboardKey.keyY: 'Y',
PhysicalKeyboardKey.keyZ: 'Z',
PhysicalKeyboardKey.digit1: '1',
PhysicalKeyboardKey.digit2: '2',
PhysicalKeyboardKey.digit3: '3',
PhysicalKeyboardKey.digit4: '4',
PhysicalKeyboardKey.digit5: '5',
PhysicalKeyboardKey.digit6: '6',
PhysicalKeyboardKey.digit7: '7',
PhysicalKeyboardKey.digit8: '8',
PhysicalKeyboardKey.digit9: '9',
PhysicalKeyboardKey.digit0: '0',
PhysicalKeyboardKey.enter: 'Enter',
PhysicalKeyboardKey.escape: 'Esc',
PhysicalKeyboardKey.backspace: 'Backspace',
PhysicalKeyboardKey.tab: 'Tab',
PhysicalKeyboardKey.space: 'Space',
PhysicalKeyboardKey.minus: '-',
PhysicalKeyboardKey.equal: '=',
PhysicalKeyboardKey.bracketLeft: '[',
PhysicalKeyboardKey.bracketRight: ']',
PhysicalKeyboardKey.backslash: r'\',
PhysicalKeyboardKey.semicolon: ';',
PhysicalKeyboardKey.quote: "'",
PhysicalKeyboardKey.backquote: '`',
PhysicalKeyboardKey.comma: ',',
PhysicalKeyboardKey.period: '.',
PhysicalKeyboardKey.slash: '/',
PhysicalKeyboardKey.capsLock: 'CapsLock',
PhysicalKeyboardKey.f1: 'F1',
PhysicalKeyboardKey.f2: 'F2',
PhysicalKeyboardKey.f3: 'F3',
PhysicalKeyboardKey.f4: 'F4',
PhysicalKeyboardKey.f5: 'F5',
PhysicalKeyboardKey.f6: 'F6',
PhysicalKeyboardKey.f7: 'F7',
PhysicalKeyboardKey.f8: 'F8',
PhysicalKeyboardKey.f9: 'F9',
PhysicalKeyboardKey.f10: 'F10',
PhysicalKeyboardKey.f11: 'F11',
PhysicalKeyboardKey.f12: 'F12',
PhysicalKeyboardKey.f13: 'F13',
PhysicalKeyboardKey.f14: 'F14',
PhysicalKeyboardKey.f15: 'F15',
PhysicalKeyboardKey.f16: 'F16',
PhysicalKeyboardKey.printScreen: 'PrintScreen',
PhysicalKeyboardKey.scrollLock: 'ScrollLock',
PhysicalKeyboardKey.pause: 'Pause',
PhysicalKeyboardKey.insert: 'Insert',
PhysicalKeyboardKey.home: 'Home',
PhysicalKeyboardKey.pageUp: 'PageUp',
PhysicalKeyboardKey.delete: 'Delete',
PhysicalKeyboardKey.end: 'End',
PhysicalKeyboardKey.pageDown: 'PageDown',
PhysicalKeyboardKey.arrowRight: 'ArrowRight',
PhysicalKeyboardKey.arrowLeft: 'ArrowLeft',
PhysicalKeyboardKey.arrowDown: 'ArrowDown',
PhysicalKeyboardKey.arrowUp: 'ArrowUp',
PhysicalKeyboardKey.numLock: 'NumLock',
PhysicalKeyboardKey.numpadDivide: 'Num /',
PhysicalKeyboardKey.numpadMultiply: 'Num *',
PhysicalKeyboardKey.numpadSubtract: 'Num -',
PhysicalKeyboardKey.numpadAdd: 'Num +',
PhysicalKeyboardKey.numpadEnter: 'Num Enter',
PhysicalKeyboardKey.numpad1: 'Num 1',
PhysicalKeyboardKey.numpad2: 'Num 2',
PhysicalKeyboardKey.numpad3: 'Num 3',
PhysicalKeyboardKey.numpad4: 'Num 4',
PhysicalKeyboardKey.numpad5: 'Num 5',
PhysicalKeyboardKey.numpad6: 'Num 6',
PhysicalKeyboardKey.numpad7: 'Num 7',
PhysicalKeyboardKey.numpad8: 'Num 8',
PhysicalKeyboardKey.numpad9: 'Num 9',
PhysicalKeyboardKey.numpad0: 'Num 0',
PhysicalKeyboardKey.numpadDecimal: 'Num .',
PhysicalKeyboardKey.contextMenu: 'ContextMenu',
PhysicalKeyboardKey.controlLeft: 'LControl',
PhysicalKeyboardKey.shiftLeft: 'LShift',
PhysicalKeyboardKey.altLeft: 'LAlt',
PhysicalKeyboardKey.metaLeft: 'LMeta',
PhysicalKeyboardKey.controlRight: 'RControl',
PhysicalKeyboardKey.shiftRight: 'RShift',
PhysicalKeyboardKey.altRight: 'RAlt',
PhysicalKeyboardKey.metaRight: 'RMeta',
};
}

class KeyboardKey extends PositionComponent {
KeyboardKey({required this.text, super.position}) {
textElement = textRenderer.formatter.format(text);
width = textElement.metrics.width + padding.x;
height = textElement.metrics.height + padding.y;
textElement.translate(
padding.x / 2,
padding.y / 2 + textElement.metrics.ascent,
);
rect = RRect.fromLTRBR(0, 0, width, height, const Radius.circular(8));
}

final String text;
late final TextElement textElement;
late final RRect rect;

/// The RawKeyEvents may occur very fast, and out of sync with the game loop.
/// On each such event we remove old KeyboardKey components, and add new ones.
/// However, since multiple RawKeyEvents may occur within a single game tick,
/// we end up adding/removing components many times within that tick, and for
/// a brief moment there could be a situation that the old components still
/// haven't been removed while the new ones were already added. In order to
/// prevent this from happening, we mark all components that are about to be
/// removed as "not visible", which prevents them from being rendered while
/// they are waiting to be removed.
bool visible = true;

static Vector2 padding = Vector2(24, 12);
static Paint borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..color = const Color(0xffb5ffd0);
static TextPaint textRenderer = TextPaint(
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xffb5ffd0),
),
);

@override
void render(Canvas canvas) {
if (visible) {
canvas.drawRRect(rect, borderPaint);
textElement.render(canvas);
}
}
}
3 changes: 3 additions & 0 deletions packages/flame/lib/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ export 'src/events/game_mixins/multi_touch_drag_detector.dart'
show MultiTouchDragDetector;
export 'src/events/game_mixins/multi_touch_tap_detector.dart'
show MultiTouchTapDetector;
export 'src/events/game_mixins/raw_keyboard_detector.dart'
show RawKeyboardDetector;
export 'src/events/interfaces/multi_drag_listener.dart' show MultiDragListener;
export 'src/events/interfaces/multi_tap_listener.dart' show MultiTapListener;

export 'src/game/mixins/has_draggables.dart' show HasDraggables;
export 'src/game/mixins/has_hoverables.dart' show HasHoverables;
export 'src/game/mixins/has_tappables.dart' show HasTappables;
Expand Down
104 changes: 104 additions & 0 deletions packages/flame/lib/src/events/game_mixins/raw_keyboard_detector.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import 'package:flame/src/game/game.dart';
import 'package:flutter/services.dart';

/// This mixin allows listening to raw keyboard events, bypassing the `Focus`
/// widget in Flutter.
///
/// The mixin provides two event handlers that can be overridden in your game:
/// - [onRawKeyEvent] fires whenever the user presses or releases any key on a
/// keyboard. Use this to handle "one-off" key events, such as the user
/// pressing <Space> to jump, or <Esc> to open a menu.
/// - [onRawKeysPressed] fires on every game tick as long as the user presses at
/// least some keys on the keyboard. Use this to reliably handle repeating
/// key events, such as user pressing arrow keys to move their character, or
/// holding down <Ctrl> to shoot continuously.
mixin RawKeyboardDetector on Game {
/// The list of keys that are currently being pressed on the keyboard (or a
/// keyboard-like device). The keys are listed in the order in which they
/// were pressed, except for the modifier keys which may be listed
/// out-of-order on some systems.
List<PhysicalKeyboardKey> physicalKeysPressed = [];

/// The set of logical keys that are currently being pressed on the keyboard.
/// This set corresponds to the [physicalKeysPressed] list, and can be used
/// to search for keys in a keyboard-layout-independent way.
Set<LogicalKeyboardKey> logicalKeysPressed = {};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given this issue flutter/flutter#99330 I would not duplicate this info that is already present and accessible on RawKeyboard.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field is copied from the RawKeyboard on every key event:

logicalKeysPressed = RawKeyboard.instance.keysPressed;

Thus, it is always kept in sync with the information available in RawKeyboard. The reason I decided to have this copy is because in RawKeyboard this field is defined as follows:

Set<LogicalKeyboardKey> get keysPressed => _keysPressed.values.toSet();

That is, they return a copy of the internal state on every access. This is good for safety, but not so much for efficiency: if your code needs to check whether a number of certain keys are pressed, there will be a new set created on every check. Even such simple methods as isShiftPressed/isControlPressed/isAltPressed each create two new copies of this set.

So I thought that a good solution to this is to simply keep a local copy of the set of logical keys, and make sure it is always kept up-to-date with RawKeyboard.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for physicalKeysPressed -- I believe it's important to know not only which keys are currently being pressed, but also in what order they were pressed. This information is currently missing in RawKeyboard, they return physical keys as an orderless set, and keep it internally as a dictionary.

The reason why the order may be important is a scenario like this: suppose the user presses [Left Arrow], holds it, and then presses [Right Arrow]. Now two arrows are pressed simultaneously, and the game needs to decide how to respond to this. One possible choice is to make the player character stand still; another choice is to make the player character go right, since the right arrow was pressed last. By keeping the physical keys in a list instead of a set, we give the game an opportunity to choose which behavior to implement (my personal preference is for the latter).


/// Is the <Ctrl> key currently being pressed (either left or right)?
bool get isControlPressed =>
logicalKeysPressed.contains(LogicalKeyboardKey.controlLeft) ||
logicalKeysPressed.contains(LogicalKeyboardKey.controlRight);

/// Is the <Shift> key currently being pressed (either left or right)?
bool get isShiftPressed =>
logicalKeysPressed.contains(LogicalKeyboardKey.shiftLeft) ||
logicalKeysPressed.contains(LogicalKeyboardKey.shiftRight);

/// Is the <Alt> key currently being pressed (either left or right)?
bool get isAltPressed =>
logicalKeysPressed.contains(LogicalKeyboardKey.altLeft) ||
logicalKeysPressed.contains(LogicalKeyboardKey.altRight);

/// Override this event handler if you want to get notified whenever any key
/// on a keyboard is pressed or released. The [event] will be either a
/// [RawKeyDownEvent] or a [RawKeyUpEvent], respectively.
///
/// This event may also fire when the user presses a key and then holds it
/// down. In such a case `event.repeat` property will be set to `true`.
/// However, this should not be used for character navigation, since this
/// behavior may not be reliable, and the frequency of such events is system-
/// dependent. Use [onRawKeysPressed] event handler instead.
void onRawKeyEvent(RawKeyEvent event) {}

/// Override this event handler if you want to get notified whenever any keys
/// are being pressed. This event handler is fired at the start of every game
/// tick.
///
/// The list of keys currently being pressed can be accessed via the
/// [physicalKeysPressed] or [logicalKeysPressed] properties.
void onRawKeysPressed() {}

/// Internal handler of raw key events.
void _onRawKeyEvent(RawKeyEvent event) {
logicalKeysPressed = RawKeyboard.instance.keysPressed;
if (event is RawKeyDownEvent) {
if (!physicalKeysPressed.contains(event.physicalKey)) {
physicalKeysPressed.add(event.physicalKey);
}
} else if (event is RawKeyUpEvent) {
physicalKeysPressed.remove(event.physicalKey);
}
// The list of physical keys may need to be reconciled with the RawKeyboard
if (physicalKeysPressed.length != logicalKeysPressed.length) {
final rawPhysicalKeysPressed = RawKeyboard.instance.physicalKeysPressed;
physicalKeysPressed
..removeWhere((key) => !rawPhysicalKeysPressed.contains(key))
..addAll(
rawPhysicalKeysPressed
.where((key) => !physicalKeysPressed.contains(key))
.toList(),
);
}
onRawKeyEvent(event);
}

@override
void onMount() {
super.onMount();
RawKeyboard.instance.addListener(_onRawKeyEvent);
}

@override
void onRemove() {
super.onRemove();
RawKeyboard.instance.removeListener(_onRawKeyEvent);
}

@override
void update(double dt) {
if (physicalKeysPressed.isNotEmpty) {
onRawKeysPressed();
}
super.update(dt);
}
}