From d460b846c23fb1f67041469c99c81e4c78b89c2e Mon Sep 17 00:00:00 2001 From: Luan Nico Date: Sun, 10 Sep 2023 11:49:52 -0700 Subject: [PATCH] feat: Add HoverCallbacks (#2706) This creates HoverCallbacks (and PointerMoveCallbacks) to replicate the Hoverables behaviour in the new camera and event system. --- .github/.cspell/gamedev_dictionary.txt | 1 + doc/flame/examples/lib/main.dart | 2 + doc/flame/examples/lib/pointer_events.dart | 51 ++++++++ doc/flame/inputs/pointer_events.md | 84 +++++++++++++ doc/flame/inputs/tap_events.md | 2 +- ...mple.dart => hover_callbacks_example.dart} | 28 ++--- examples/lib/stories/input/input.dart | 10 +- packages/flame/lib/events.dart | 4 + .../component_mixins/hover_callbacks.dart | 61 ++++++++++ .../pointer_move_callbacks.dart | 29 +++++ .../pointer_move_dispatcher.dart | 71 +++++++++++ .../events/messages/pointer_move_event.dart | 44 +++++++ packages/flame/lib/src/game/game.dart | 5 + .../game_widget/gesture_detector_builder.dart | 9 +- .../hover_callbacks_test.dart | 109 +++++++++++++++++ .../pointer_move_callbacks_test.dart | 113 ++++++++++++++++++ packages/flame_test/lib/flame_test.dart | 1 + .../lib/src/mock_pointer_move_event.dart | 22 ++++ 18 files changed, 624 insertions(+), 22 deletions(-) create mode 100644 doc/flame/examples/lib/pointer_events.dart create mode 100644 doc/flame/inputs/pointer_events.md rename examples/lib/stories/input/{hoverables_example.dart => hover_callbacks_example.dart} (50%) create mode 100644 packages/flame/lib/src/events/component_mixins/hover_callbacks.dart create mode 100644 packages/flame/lib/src/events/component_mixins/pointer_move_callbacks.dart create mode 100644 packages/flame/lib/src/events/flame_game_mixins/pointer_move_dispatcher.dart create mode 100644 packages/flame/lib/src/events/messages/pointer_move_event.dart create mode 100644 packages/flame/test/events/component_mixins/hover_callbacks_test.dart create mode 100644 packages/flame/test/events/component_mixins/pointer_move_callbacks_test.dart create mode 100644 packages/flame_test/lib/src/mock_pointer_move_event.dart diff --git a/.github/.cspell/gamedev_dictionary.txt b/.github/.cspell/gamedev_dictionary.txt index 5cd7da96f9..bde6a9153b 100644 --- a/.github/.cspell/gamedev_dictionary.txt +++ b/.github/.cspell/gamedev_dictionary.txt @@ -156,3 +156,4 @@ viewports vsync widget's unawaited +proxied \ No newline at end of file diff --git a/doc/flame/examples/lib/main.dart b/doc/flame/examples/lib/main.dart index 44c6773779..0ac16d00a6 100644 --- a/doc/flame/examples/lib/main.dart +++ b/doc/flame/examples/lib/main.dart @@ -18,6 +18,7 @@ import 'package:doc_flame_examples/move_to_effect.dart'; import 'package:doc_flame_examples/opacity_by_effect.dart'; import 'package:doc_flame_examples/opacity_effect_with_target.dart'; import 'package:doc_flame_examples/opacity_to_effect.dart'; +import 'package:doc_flame_examples/pointer_events.dart'; import 'package:doc_flame_examples/ray_cast.dart'; import 'package:doc_flame_examples/ray_trace.dart'; import 'package:doc_flame_examples/remove_effect.dart'; @@ -60,6 +61,7 @@ void main() { 'opacity_by_effect': OpacityByEffectGame.new, 'opacity_effect_with_target': OpacityEffectWithTargetGame.new, 'opacity_to_effect': OpacityToEffectGame.new, + 'pointer_events': PointerEventsGame.new, 'ray_cast': RayCastExample.new, 'ray_trace': RayTraceExample.new, 'remove_effect': RemoveEffectGame.new, diff --git a/doc/flame/examples/lib/pointer_events.dart b/doc/flame/examples/lib/pointer_events.dart new file mode 100644 index 0000000000..92ebfcaafe --- /dev/null +++ b/doc/flame/examples/lib/pointer_events.dart @@ -0,0 +1,51 @@ +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/rendering.dart'; + +class PointerEventsGame extends FlameGame with TapCallbacks { + @override + Future onLoad() async { + add(HoverTarget(Vector2(100, 200))); + add(HoverTarget(Vector2(300, 300))); + add(HoverTarget(Vector2(400, 50))); + } + + @override + void onTapDown(TapDownEvent event) { + add(HoverTarget(event.localPosition)); + } +} + +class HoverTarget extends PositionComponent with HoverCallbacks { + static final Random _random = Random(); + + HoverTarget(Vector2 position) + : super( + position: position, + size: Vector2.all(50), + anchor: Anchor.center, + ); + + final _paint = Paint() + ..color = HSLColor.fromAHSL(1, _random.nextDouble() * 360, 1, 0.8) + .toColor() + .withOpacity(0.5); + + @override + void render(Canvas canvas) { + canvas.drawRect(size.toRect(), _paint); + } + + @override + void onHoverEnter() { + _paint.color = _paint.color.withOpacity(1); + } + + @override + void onHoverExit() { + _paint.color = _paint.color.withOpacity(0.5); + } +} diff --git a/doc/flame/inputs/pointer_events.md b/doc/flame/inputs/pointer_events.md new file mode 100644 index 0000000000..9cac3e8d21 --- /dev/null +++ b/doc/flame/inputs/pointer_events.md @@ -0,0 +1,84 @@ +# Pointer Events + +```{note} +This document describes the new events API. The old (legacy) approach, +which is still supported, is described in [](gesture_input.md). +``` + +**Pointer events** are Flutter's generalized "mouse-movement"-type events (for desktop or web). + +If you want to interact with mouse movement events within your component or game, you can use the +`PointerMoveCallbacks` mixin. + +For example: + +```dart +class MyComponent extends PositionComponent with PointerMoveCallbacks { + MyComponent() : super(size: Vector2(80, 60)); + + @override + void onPointerMove(PointerMoveEvent event) { + // Do something in response to the mouse move (e.g. update coordinates) + } +} +``` + +The mixin adds two overridable methods to your component: + +- `onPointerMove`: called when the mouse moves within the component +- `onPointerMoveStop`: called once if the component was being hovered and the mouse leaves + +By default, each of these methods does nothing, they need to be overridden in order to perform any +function. + +In addition, the component must implement the `containsLocalPoint()` method (already implemented in +`PositionComponent`, so most of the time you don't need to do anything here) -- this method allows +Flame to know whether the event occurred within the component or not. + +Note that only mouse events happening within your component will be proxied along. However, +`onPointerMoveStop` will be fired once on the first mouse movement that leaves your component, so +you can handle any exit conditions there. + + +## HoverCallbacks + +If you want to specifically know if your component is being hovered or not, or if you want to hook +into hover enter and exist events, you can use a more dedicated mixin called `HoverCallbacks`. + +For example: + +```dart +class MyComponent extends PositionComponent with HoverCallbacks { + + MyComponent() : super(size: Vector2(80, 60)); + + @override + void update(double dt) { + // use `isHovered` to know if the component is being hovered + } + + @override + void onHoverEnter() { + // Do something in response to the mouse entering the component + } + + @override + void onHoverExit() { + // Do something in response to the mouse leaving the component + } +} +``` + +Note that you can still listen to the "raw" onPointerMove methods for additional functionality, just +make sure to call the `super` version to enable the `HoverCallbacks` behavior. + + +### Demo + +Play with the demo below to see the pointer hover events in action. + +```{flutter-app} +:sources: ../flame/examples +:page: pointer_events +:show: widget code +``` diff --git a/doc/flame/inputs/tap_events.md b/doc/flame/inputs/tap_events.md index 7f2c331bfb..35ab4f2056 100644 --- a/doc/flame/inputs/tap_events.md +++ b/doc/flame/inputs/tap_events.md @@ -1,7 +1,7 @@ # Tap Events ```{note} -This document describes the new tap events API. The old (legacy) approach, +This document describes the new events API. The old (legacy) approach, which is still supported, is described in [](gesture_input.md). ``` diff --git a/examples/lib/stories/input/hoverables_example.dart b/examples/lib/stories/input/hover_callbacks_example.dart similarity index 50% rename from examples/lib/stories/input/hoverables_example.dart rename to examples/lib/stories/input/hover_callbacks_example.dart index 126ad01b84..89affde57d 100644 --- a/examples/lib/stories/input/hoverables_example.dart +++ b/examples/lib/stories/input/hover_callbacks_example.dart @@ -1,37 +1,37 @@ -// ignore_for_file: deprecated_member_use - import 'package:flame/components.dart'; +import 'package:flame/events.dart'; import 'package:flame/extensions.dart'; import 'package:flame/game.dart'; -import 'package:flame/input.dart'; import 'package:flutter/material.dart'; -class HoverablesExample extends FlameGame with HasHoverables, TapDetector { +class HoverCallbacksExample extends FlameGame with TapCallbacks { static const String description = ''' - This example shows how to use `Hoverable`s.\n\n + This example shows how to use `HoverCallbacks`s.\n\n Add more squares by clicking and hover them to change their color. '''; @override Future onLoad() async { - add(HoverableSquare(Vector2(200, 500))); - add(HoverableSquare(Vector2(700, 300))); + add(HoverSquare(Vector2(200, 500))); + add(HoverSquare(Vector2(700, 300))); } @override - void onTapDown(TapDownInfo info) { - add(HoverableSquare(info.eventPosition.game)); + void onTapDown(TapDownEvent event) { + add(HoverSquare(event.localPosition)); } } -class HoverableSquare extends PositionComponent with Hoverable { +class HoverSquare extends PositionComponent with HoverCallbacks { static final Paint _white = Paint()..color = const Color(0xFFFFFFFF); static final Paint _grey = Paint()..color = const Color(0xFFA5A5A5); - HoverableSquare(Vector2 position) - : super(position: position, size: Vector2.all(100)) { - anchor = Anchor.center; - } + HoverSquare(Vector2 position) + : super( + position: position, + size: Vector2.all(100), + anchor: Anchor.center, + ); @override void render(Canvas canvas) { diff --git a/examples/lib/stories/input/input.dart b/examples/lib/stories/input/input.dart index d0acc42f03..a16d95af7f 100644 --- a/examples/lib/stories/input/input.dart +++ b/examples/lib/stories/input/input.dart @@ -4,7 +4,7 @@ import 'package:examples/stories/input/double_tap_callbacks_example.dart'; import 'package:examples/stories/input/draggables_example.dart'; import 'package:examples/stories/input/gesture_hitboxes_example.dart'; import 'package:examples/stories/input/hardware_keyboard_example.dart'; -import 'package:examples/stories/input/hoverables_example.dart'; +import 'package:examples/stories/input/hover_callbacks_example.dart'; import 'package:examples/stories/input/joystick_advanced_example.dart'; import 'package:examples/stories/input/joystick_example.dart'; import 'package:examples/stories/input/keyboard_example.dart'; @@ -50,10 +50,10 @@ void addInputStories(Dashbook dashbook) { info: DoubleTapCallbacksExample.description, ) ..add( - 'Hoverables', - (_) => GameWidget(game: HoverablesExample()), - codeLink: baseLink('input/hoverables_example.dart'), - info: HoverablesExample.description, + 'HoverCallbacks', + (_) => GameWidget(game: HoverCallbacksExample()), + codeLink: baseLink('input/hover_callbacks_example.dart'), + info: HoverCallbacksExample.description, ) ..add( 'Keyboard', diff --git a/packages/flame/lib/events.dart b/packages/flame/lib/events.dart index 4c6369560d..b56bd7d089 100644 --- a/packages/flame/lib/events.dart +++ b/packages/flame/lib/events.dart @@ -1,6 +1,9 @@ export 'src/events/component_mixins/double_tap_callbacks.dart' show DoubleTapCallbacks; export 'src/events/component_mixins/drag_callbacks.dart' show DragCallbacks; +export 'src/events/component_mixins/hover_callbacks.dart' show HoverCallbacks; +export 'src/events/component_mixins/pointer_move_callbacks.dart' + show PointerMoveCallbacks; export 'src/events/component_mixins/tap_callbacks.dart' show TapCallbacks; export 'src/events/flame_game_mixins/has_draggables_bridge.dart' show HasDraggablesBridge; @@ -22,6 +25,7 @@ export 'src/events/messages/drag_cancel_event.dart' show DragCancelEvent; export 'src/events/messages/drag_end_event.dart' show DragEndEvent; export 'src/events/messages/drag_start_event.dart' show DragStartEvent; export 'src/events/messages/drag_update_event.dart' show DragUpdateEvent; +export 'src/events/messages/pointer_move_event.dart' show PointerMoveEvent; export 'src/events/messages/tap_cancel_event.dart' show TapCancelEvent; export 'src/events/messages/tap_down_event.dart' show TapDownEvent; export 'src/events/messages/tap_up_event.dart' show TapUpEvent; diff --git a/packages/flame/lib/src/events/component_mixins/hover_callbacks.dart b/packages/flame/lib/src/events/component_mixins/hover_callbacks.dart new file mode 100644 index 0000000000..5240951834 --- /dev/null +++ b/packages/flame/lib/src/events/component_mixins/hover_callbacks.dart @@ -0,0 +1,61 @@ +import 'package:flame/events.dart'; +import 'package:flame/src/components/core/component.dart'; +import 'package:meta/meta.dart'; + +/// This mixin can be added to a [Component] allowing it to receive hover +/// events. +/// +/// In addition to adding this mixin, the component must also implement the +/// [containsLocalPoint] method -- the component will only be considered +/// "hovered" if the point where the hover event occurred is inside the +/// component. +/// +/// This mixin is the replacement of the Hoverable mixin. +mixin HoverCallbacks on Component implements PointerMoveCallbacks { + bool _isHovered = false; + + /// Returns true while the component is being dragged. + bool get isHovered => _isHovered; + + void onHoverEnter() {} + + void onHoverExit() {} + + void _doHoverEnter() { + _isHovered = true; + onHoverEnter(); + } + + void _doHoverExit() { + _isHovered = false; + onHoverExit(); + } + + @override + void onPointerMove(PointerMoveEvent event) { + final position = event.localPosition; + if (containsLocalPoint(position)) { + if (!_isHovered) { + _doHoverEnter(); + } + } else { + if (_isHovered) { + _doHoverExit(); + } + } + } + + @override + void onPointerMoveStop(PointerMoveEvent event) { + if (_isHovered) { + _doHoverExit(); + } + } + + @override + @mustCallSuper + void onMount() { + super.onMount(); + PointerMoveCallbacks.onMountHandler(this); + } +} diff --git a/packages/flame/lib/src/events/component_mixins/pointer_move_callbacks.dart b/packages/flame/lib/src/events/component_mixins/pointer_move_callbacks.dart new file mode 100644 index 0000000000..03847d57a9 --- /dev/null +++ b/packages/flame/lib/src/events/component_mixins/pointer_move_callbacks.dart @@ -0,0 +1,29 @@ +import 'package:flame/events.dart'; +import 'package:flame/src/components/core/component.dart'; +import 'package:flame/src/events/flame_game_mixins/pointer_move_dispatcher.dart'; +import 'package:meta/meta.dart'; + +/// This mixin can be added to a [Component] allowing it to receive +/// pointer movement events. +mixin PointerMoveCallbacks on Component { + void onPointerMove(PointerMoveEvent event) {} + + void onPointerMoveStop(PointerMoveEvent event) {} + + @override + @mustCallSuper + void onMount() { + super.onMount(); + onMountHandler(this); + } + + static void onMountHandler(PointerMoveCallbacks instance) { + final game = instance.findGame()!; + const key = MouseMoveDispatcherKey(); + if (game.findByKey(key) == null) { + final dispatcher = PointerMoveDispatcher(); + game.registerKey(key, dispatcher); + game.add(dispatcher); + } + } +} diff --git a/packages/flame/lib/src/events/flame_game_mixins/pointer_move_dispatcher.dart b/packages/flame/lib/src/events/flame_game_mixins/pointer_move_dispatcher.dart new file mode 100644 index 0000000000..3987fa8ba2 --- /dev/null +++ b/packages/flame/lib/src/events/flame_game_mixins/pointer_move_dispatcher.dart @@ -0,0 +1,71 @@ +import 'package:flame/events.dart'; +import 'package:flame/src/components/core/component.dart'; +import 'package:flame/src/components/core/component_key.dart'; +import 'package:flame/src/events/tagged_component.dart'; +import 'package:flame/src/game/flame_game.dart'; +import 'package:flutter/gestures.dart' as flutter; +import 'package:meta/meta.dart'; + +/// **MouseMoveDispatcher** facilitates dispatching of mouse move events to the +/// [PointerMoveCallbacks] components in the component tree. It will be attached +/// to the [FlameGame] instance automatically whenever any +/// [PointerMoveCallbacks] components are mounted into the component tree. +@internal +class PointerMoveDispatcher extends Component { + /// The record of all components currently being hovered. + final Set> _records = {}; + + FlameGame get game => parent! as FlameGame; + + @mustCallSuper + void onMouseMove(PointerMoveEvent event) { + final updated = >{}; + + event.deliverAtPoint( + rootComponent: game, + deliverToAll: true, + eventHandler: (PointerMoveCallbacks component) { + final tagged = TaggedComponent(event.pointerId, component); + _records.add(tagged); + updated.add(tagged); + component.onPointerMove(event); + }, + ); + + final toRemove = >{}; + for (final record in _records) { + if (record.pointerId == event.pointerId && !updated.contains(record)) { + // one last "exit" event + record.component.onPointerMoveStop(event); + toRemove.add(record); + } + } + _records.removeAll(toRemove); + } + + void _handlePointerMove(flutter.PointerHoverEvent event) { + onMouseMove(PointerMoveEvent.fromPointerHoverEvent(game, event)); + } + + @override + void onMount() { + game.mouseDetector = _handlePointerMove; + } + + @override + void onRemove() { + game.mouseDetector = null; + game.unregisterKey(const MouseMoveDispatcherKey()); + } +} + +class MouseMoveDispatcherKey implements ComponentKey { + const MouseMoveDispatcherKey(); + + @override + int get hashCode => 'MouseMoveDispatcherKey'.hashCode; + + @override + bool operator ==(dynamic other) => + other is MouseMoveDispatcherKey && other.hashCode == hashCode; +} diff --git a/packages/flame/lib/src/events/messages/pointer_move_event.dart b/packages/flame/lib/src/events/messages/pointer_move_event.dart new file mode 100644 index 0000000000..bc9ecead30 --- /dev/null +++ b/packages/flame/lib/src/events/messages/pointer_move_event.dart @@ -0,0 +1,44 @@ +import 'package:flame/extensions.dart'; +import 'package:flame/game.dart'; +import 'package:flame/src/events/messages/position_event.dart'; +import 'package:flutter/gestures.dart' as flutter; + +class PointerMoveEvent extends PositionEvent { + PointerMoveEvent( + this.pointerId, + super.game, + flutter.PointerHoverEvent rawEvent, + ) : timestamp = rawEvent.timeStamp, + delta = rawEvent.delta.toVector2(), + super( + devicePosition: rawEvent.position.toVector2(), + ); + + final int pointerId; + final Duration timestamp; + final Vector2 delta; + + static final _nanPoint = Vector2.all(double.nan); + + @override + Vector2 get localPosition { + return renderingTrace.isEmpty ? _nanPoint : renderingTrace.last; + } + + @override + String toString() => 'PointerMoveEvent(devicePosition: $devicePosition, ' + 'canvasPosition: $canvasPosition, ' + 'delta: $delta, ' + 'pointerId: $pointerId, timestamp: $timestamp)'; + + factory PointerMoveEvent.fromPointerHoverEvent( + Game game, + flutter.PointerHoverEvent event, + ) { + return PointerMoveEvent( + event.pointer, + game, + event, + ); + } +} diff --git a/packages/flame/lib/src/game/game.dart b/packages/flame/lib/src/game/game.dart index a028943b9c..73c10a8bae 100644 --- a/packages/flame/lib/src/game/game.dart +++ b/packages/flame/lib/src/game/game.dart @@ -8,6 +8,7 @@ import 'package:flame/src/game/game_render_box.dart'; import 'package:flame/src/game/game_widget/gesture_detector_builder.dart'; import 'package:flame/src/game/overlay_manager.dart'; import 'package:flame/src/game/projector.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; @@ -35,6 +36,10 @@ abstract mixin class Game { late final GestureDetectorBuilder gestureDetectors = GestureDetectorBuilder(refreshWidget)..initializeGestures(this); + /// Set by the PointerMoveDispatcher to receive mouse events from the + /// game widget. + void Function(PointerHoverEvent event)? mouseDetector; + /// This should update the state of the game. void update(double dt); diff --git a/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart b/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart index 57265d14b7..49a6106033 100644 --- a/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart +++ b/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart @@ -174,7 +174,8 @@ bool hasMouseDetectors(Game game) { return game is MouseMovementDetector || game is ScrollDetector || // ignore: deprecated_member_use_from_same_package - game is HasHoverables; + game is HasHoverables || + game.mouseDetector != null; } Widget applyMouseDetectors(Game game, Widget child) { @@ -182,10 +183,14 @@ Widget applyMouseDetectors(Game game, Widget child) { ? game.onMouseMove // ignore: deprecated_member_use_from_same_package : (game is HasHoverables ? game.onMouseMove : null); + final mouseDetector = game.mouseDetector; return Listener( child: MouseRegion( child: child, - onHover: (e) => mouseMoveFn?.call(PointerHoverInfo.fromDetails(game, e)), + onHover: (PointerHoverEvent e) { + mouseMoveFn?.call(PointerHoverInfo.fromDetails(game, e)); + mouseDetector?.call(e); + }, ), onPointerSignal: (event) => game is ScrollDetector && event is PointerScrollEvent diff --git a/packages/flame/test/events/component_mixins/hover_callbacks_test.dart b/packages/flame/test/events/component_mixins/hover_callbacks_test.dart new file mode 100644 index 0000000000..4c13aca6d3 --- /dev/null +++ b/packages/flame/test/events/component_mixins/hover_callbacks_test.dart @@ -0,0 +1,109 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame/src/events/flame_game_mixins/pointer_move_dispatcher.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('HoverCallbacks', () { + testWithFlameGame( + 'make sure HoverCallbacks components can be added to a FlameGame', + (game) async { + await game.ensureAdd(_HoverCallbacksComponent()); + await game.ready(); + + _hasDispatcher(game); + }); + + testWithFlameGame('receive hover events', (game) async { + final component = _HoverCallbacksComponent( + position: Vector2.all(10), + size: Vector2.all(10), + ); + game.add(component); + await game.ready(); + + _hasDispatcher(game); + + _mouseEvent(game, Vector2.all(12)); + component.checkHoverEventCounts(enter: 1, exit: 0); + + _mouseEvent(game, Vector2.all(14)); + component.checkHoverEventCounts(enter: 1, exit: 0); + + _mouseEvent(game, Vector2.all(16)); + component.checkHoverEventCounts(enter: 1, exit: 0); + + _mouseEvent(game, Vector2.all(18)); + component.checkHoverEventCounts(enter: 1, exit: 0); + + _mouseEvent(game, Vector2.all(20)); + component.checkHoverEventCounts(enter: 1, exit: 1); + + _mouseEvent(game, Vector2.all(22)); + component.checkHoverEventCounts(enter: 1, exit: 1); + + _mouseEvent(game, Vector2.all(18)); + component.checkHoverEventCounts(enter: 2, exit: 1); + + _mouseEvent(game, Vector2.all(19)); + component.checkHoverEventCounts(enter: 2, exit: 1); + + _mouseEvent(game, Vector2.all(20)); + component.checkHoverEventCounts(enter: 2, exit: 2); + }); + }); +} + +void _mouseEvent(FlameGame game, Vector2 position) { + game.firstChild()!.onMouseMove( + createMouseMoveEvent( + game: game, + position: position, + ), + ); +} + +void _hasDispatcher(FlameGame game) { + expect( + game.children.whereType(), + hasLength(1), + ); +} + +mixin _HoverInspector on HoverCallbacks { + int hoverEnterEvent = 0; + int hoverExitEvent = 0; + + void checkHoverEventCounts({required int enter, required int exit}) { + expect( + hoverEnterEvent, + equals(enter), + reason: 'Mismatched hover enter event count', + ); + expect( + hoverExitEvent, + equals(exit), + reason: 'Mismatched hover exit event count', + ); + } + + @override + void onHoverEnter() { + hoverEnterEvent++; + } + + @override + void onHoverExit() { + hoverExitEvent++; + } +} + +class _HoverCallbacksComponent extends PositionComponent + with HoverCallbacks, _HoverInspector { + _HoverCallbacksComponent({ + super.position, + super.size, + }); +} diff --git a/packages/flame/test/events/component_mixins/pointer_move_callbacks_test.dart b/packages/flame/test/events/component_mixins/pointer_move_callbacks_test.dart new file mode 100644 index 0000000000..194c1490fc --- /dev/null +++ b/packages/flame/test/events/component_mixins/pointer_move_callbacks_test.dart @@ -0,0 +1,113 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame/src/events/flame_game_mixins/pointer_move_dispatcher.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PointerMoveCallbacks', () { + testWithFlameGame( + 'make sure PointerMoveCallbacks components can be added to a FlameGame', + (game) async { + await game.ensureAdd(_PointerMoveCallbacksComponent()); + await game.ready(); + + _hasDispatcher(game); + }); + + testWithFlameGame('receive pointer move events on component', (game) async { + final c1 = _PointerMoveCallbacksComponent( + position: Vector2.all(10), + size: Vector2.all(10), + ); + game.add(c1); + final c2 = _PointerMoveCallbacksComponent( + position: Vector2.all(15), + size: Vector2.all(10), + ); + game.add(c2); + + await game.ready(); + + _hasDispatcher(game); + + _mouseEvent(game, Vector2.all(12)); + expect(c1.removeSingle(), Vector2.all(2)); + expect(c2.receivedEventsAt, isEmpty); + + _mouseEvent(game, Vector2.all(1)); + expect(c1.receivedEventsAt, isEmpty); + expect(c2.receivedEventsAt, isEmpty); + + _mouseEvent(game, Vector2.all(19)); + expect(c1.removeSingle(), Vector2.all(9)); + expect(c2.removeSingle(), Vector2.all(4)); + + _mouseEvent(game, Vector2.all(21)); + expect(c1.receivedEventsAt, isEmpty); + expect(c2.removeSingle(), Vector2.all(6)); + }); + + testWithGame( + 'receive pointer move events on game', + _PointerMoveCallbacksGame.new, + (game) async { + _hasDispatcher(game); + + _mouseEvent(game, Vector2.all(12)); + expect(game.removeSingle(), Vector2.all(12)); + + _mouseEvent(game, Vector2.all(1)); + expect(game.removeSingle(), Vector2.all(1)); + + _mouseEvent(game, Vector2.all(19)); + expect(game.removeSingle(), Vector2.all(19)); + + _mouseEvent(game, Vector2.all(21)); + expect(game.removeSingle(), Vector2.all(21)); + }, + ); + }); +} + +void _mouseEvent(FlameGame game, Vector2 position) { + game.firstChild()!.onMouseMove( + createMouseMoveEvent( + game: game, + position: position, + ), + ); +} + +void _hasDispatcher(FlameGame game) { + expect( + game.children.whereType(), + hasLength(1), + ); +} + +mixin _PointerMoveInspector on PointerMoveCallbacks { + List receivedEventsAt = []; + + Vector2 removeSingle() { + expect(receivedEventsAt, hasLength(1)); + return receivedEventsAt.removeAt(0); + } + + @override + void onPointerMove(PointerMoveEvent event) { + receivedEventsAt.add(event.localPosition); + } +} + +class _PointerMoveCallbacksComponent extends PositionComponent + with PointerMoveCallbacks, _PointerMoveInspector { + _PointerMoveCallbacksComponent({ + super.position, + super.size, + }); +} + +class _PointerMoveCallbacksGame extends FlameGame + with PointerMoveCallbacks, _PointerMoveInspector {} diff --git a/packages/flame_test/lib/flame_test.dart b/packages/flame_test/lib/flame_test.dart index e18f5b3766..124c1c935e 100644 --- a/packages/flame_test/lib/flame_test.dart +++ b/packages/flame_test/lib/flame_test.dart @@ -6,6 +6,7 @@ export 'src/fails_assert.dart'; export 'src/flame_test.dart'; export 'src/mock_gesture_events.dart'; export 'src/mock_image.dart'; +export 'src/mock_pointer_move_event.dart'; export 'src/mock_tap_drag_events.dart'; export 'src/random_test.dart'; export 'src/test_flame_game.dart'; diff --git a/packages/flame_test/lib/src/mock_pointer_move_event.dart b/packages/flame_test/lib/src/mock_pointer_move_event.dart new file mode 100644 index 0000000000..4ec13ef1d3 --- /dev/null +++ b/packages/flame_test/lib/src/mock_pointer_move_event.dart @@ -0,0 +1,22 @@ +import 'package:flame/events.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/gestures.dart' as flutter; + +PointerMoveEvent createMouseMoveEvent({ + required Game game, + int? pointerId, + Vector2? position, + Vector2? delta, + Duration? timestamp, +}) { + return PointerMoveEvent( + pointerId ?? 1, + game, + flutter.PointerHoverEvent( + timeStamp: timestamp ?? Duration.zero, + position: position?.toOffset() ?? Offset.zero, + delta: delta?.toOffset() ?? Offset.zero, + ), + ); +}