diff --git a/doc/input.md b/doc/input.md index 2b3ac1ebd..9589e0914 100644 --- a/doc/input.md +++ b/doc/input.md @@ -138,10 +138,10 @@ class MyGame extends Game with TapDetector { You can also check more complete examples [here](https://github.com/flame-engine/flame/tree/main/examples/lib/stories/controls/). -## Tapable and Draggable components +## Tapable, Draggable and Hoverable components -Any component derived from `BaseComponent` (most components) can add the `Tapable` and/or the -`Draggable` mixins to handle taps and drags on the component. +Any component derived from `BaseComponent` (most components) can add the `Tapable`, the +`Draggable`, and/or the `Hoverable` mixins to handle taps, drags and hovers on the component. All overridden methods return a boolean to control if the event should be passed down further along to components underneath it. So say that you only want your top visible component to receive a tap @@ -166,8 +166,7 @@ void onTapUp(TapUpInfo event) {} Minimal component example: ```dart -import 'package:flame/components/component.dart'; -import 'package:flame/components/mixins/tapable.dart'; +import 'package:flame/components.dart'; class TapableComponent extends PositionComponent with Tapable { @@ -229,8 +228,7 @@ Minimal component example (this example ignores pointerId so it wont work well i multi-drag): ```dart -import 'package:flame/components/component.dart'; -import 'package:flame/components/mixins/draggable.dart'; +import 'package:flame/components.dart'; class DraggableComponent extends PositionComponent with Draggable { @@ -275,6 +273,25 @@ class MyGame extends BaseGame with HasDraggableComponents { **Note**: `HasDraggableComponents` uses an advanced gesture detector under the hood and as explained further up on this page, shouldn't be used alongside basic detectors. +### Hoverable components + +Just like the others, this mixin allows for easy wiring of your component to listen to hover states +and events. + +By adding the `HasHoverableComponents` mixin to your base game, and by using the mixin `Hoverable` on +your components, they get an `isHovered` field and a couple of methods (`onHoverStart`, `onHoverEnd`) that +you can override if you want to listen to the events. + +```dart + bool isHovered = false; + void onHoverEnter(PointerHoverInfo event) {} + void onHoverLeave(PointerHoverInfo event) {} +``` + +The provided event info is from the mouse move that triggered the action (entering or leaving). +While the mouse movement is kept inside or outside, no events are fired and those mouse move events are +not propagated. Only when the state is changed the handlers are triggered. + ## Hitbox The `Hitbox` mixin is used to make detection of gestures on top of your `PositionComponent`s more accurate. Say that you have a fairly round rock as a `SpriteComponent` for example, then you don't diff --git a/examples/lib/stories/controls/controls.dart b/examples/lib/stories/controls/controls.dart index eb05206cc..6eb3b88b4 100644 --- a/examples/lib/stories/controls/controls.dart +++ b/examples/lib/stories/controls/controls.dart @@ -4,6 +4,7 @@ import 'package:flame/game.dart'; import '../../commons/commons.dart'; import 'advanced_joystick.dart'; import 'draggables.dart'; +import 'hoverables.dart'; import 'joystick.dart'; import 'keyboard.dart'; import 'mouse_movement.dart'; @@ -55,16 +56,18 @@ void addControlsStories(Dashbook dashbook) { (context) { return GameWidget( game: DraggablesGame( - zoom: context.listProperty( - 'zoom', - 1, - [0.5, 1, 1.5], - ), + zoom: context.listProperty('zoom', 1, [0.5, 1, 1.5]), ), ); }, codeLink: baseLink('controls/draggables.dart'), ) + ..add( + 'Hoverables', + (_) => GameWidget(game: HoverablesGame()), + codeLink: baseLink('controls/hoverables.dart'), + info: 'Add more squares by clicking. Hover squares to change colors.', + ) ..add( 'Joystick', (_) => GameWidget(game: JoystickGame()), diff --git a/examples/lib/stories/controls/hoverables.dart b/examples/lib/stories/controls/hoverables.dart new file mode 100644 index 000000000..174732a96 --- /dev/null +++ b/examples/lib/stories/controls/hoverables.dart @@ -0,0 +1,34 @@ +import 'package:flame/components.dart'; +import 'package:flame/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flame/game.dart'; +import 'package:flame/extensions.dart'; + +class HoverableSquare extends PositionComponent with Hoverable { + 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; + } + + @override + void render(Canvas canvas) { + super.render(canvas); + canvas.drawRect(size.toRect(), isHovered ? _grey : _white); + } +} + +class HoverablesGame extends BaseGame with HasHoverableComponents, TapDetector { + @override + Future onLoad() async { + add(HoverableSquare(Vector2(200, 500))); + add(HoverableSquare(Vector2(700, 300))); + } + + @override + void onTapDown(TapDownInfo event) { + add(HoverableSquare(event.eventPosition.game)); + } +} diff --git a/packages/flame/CHANGELOG.md b/packages/flame/CHANGELOG.md index 409960d7b..7a0caad14 100644 --- a/packages/flame/CHANGELOG.md +++ b/packages/flame/CHANGELOG.md @@ -11,6 +11,7 @@ - No need to send size in ParallaxComponent.fromParallax since Parallax already contains it - Fix Text Rendering not working properly - Add more useful methods to the IsometricTileMap component + - Add Hoverables ## [1.0.0-rc10] - Updated tutorial documentation to indicate use of new version diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index 361456028..383b93f73 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -8,6 +8,7 @@ export 'src/components/mixins/draggable.dart'; export 'src/components/mixins/has_collidables.dart'; export 'src/components/mixins/has_game_ref.dart'; export 'src/components/mixins/hitbox.dart'; +export 'src/components/mixins/hoverable.dart'; export 'src/components/mixins/single_child_particle.dart'; export 'src/components/mixins/tapable.dart'; export 'src/components/nine_tile_box_component.dart'; diff --git a/packages/flame/lib/src/components/mixins/hoverable.dart b/packages/flame/lib/src/components/mixins/hoverable.dart new file mode 100644 index 000000000..21862f3a8 --- /dev/null +++ b/packages/flame/lib/src/components/mixins/hoverable.dart @@ -0,0 +1,48 @@ +import 'package:meta/meta.dart'; + +import '../../../game.dart'; +import '../../game/base_game.dart'; +import '../../gestures/events.dart'; +import '../base_component.dart'; + +mixin Hoverable on BaseComponent { + bool _isHovered = false; + bool get isHovered => _isHovered; + void onHoverEnter(PointerHoverInfo event) {} + void onHoverLeave(PointerHoverInfo event) {} + + @nonVirtual + void doHandleMouseMovement(PointerHoverInfo event, Vector2 p) { + if (containsPoint(p)) { + if (!_isHovered) { + _isHovered = true; + onHoverEnter(event); + } + } else { + if (_isHovered) { + _isHovered = false; + onHoverLeave(event); + } + } + } +} + +mixin HasHoverableComponents on BaseGame { + @mustCallSuper + void onMouseMove(PointerHoverInfo event) { + final p = event.eventPosition.game; + bool _mouseMoveHandler(Hoverable c) { + c.doHandleMouseMovement(event, p); + return true; // always continue + } + + for (final c in components.toList().reversed) { + if (c is BaseComponent) { + c.propagateToChildren(_mouseMoveHandler); + } + if (c is Hoverable) { + _mouseMoveHandler(c); + } + } + } +} diff --git a/packages/flame/lib/src/game/base_game.dart b/packages/flame/lib/src/game/base_game.dart index fe553db58..f670d3262 100644 --- a/packages/flame/lib/src/game/base_game.dart +++ b/packages/flame/lib/src/game/base_game.dart @@ -11,6 +11,7 @@ import '../components/mixins/collidable.dart'; import '../components/mixins/draggable.dart'; import '../components/mixins/has_collidables.dart'; import '../components/mixins/has_game_ref.dart'; +import '../components/mixins/hoverable.dart'; import '../components/mixins/tapable.dart'; import '../components/position_component.dart'; import '../fps_counter.dart'; @@ -106,6 +107,12 @@ class BaseGame extends Game with FPSCounter { 'Draggable Components can only be added to a BaseGame with HasDraggableComponents', ); } + if (c is Hoverable) { + assert( + this is HasHoverableComponents, + 'Hoverable Components can only be added to a BaseGame with HasHoverableComponents', + ); + } if (debugMode && c is PositionComponent) { c.debugMode = true; diff --git a/packages/flame/lib/src/game/game_widget/gestures.dart b/packages/flame/lib/src/game/game_widget/gestures.dart index 5954f12c2..9ce3e196f 100644 --- a/packages/flame/lib/src/game/game_widget/gestures.dart +++ b/packages/flame/lib/src/game/game_widget/gestures.dart @@ -1,6 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; +import '../../../components.dart'; import '../../../extensions.dart'; import '../../components/mixins/draggable.dart'; import '../../components/mixins/tapable.dart'; @@ -27,7 +28,9 @@ bool hasAdvancedGesturesDetectors(Game game) => game is HasDraggableComponents; bool hasMouseDetectors(Game game) => - game is MouseMovementDetector || game is ScrollDetector; + game is MouseMovementDetector || + game is ScrollDetector || + game is HasHoverableComponents; Widget applyBasicGesturesDetectors(Game game, Widget child) { return GestureDetector( @@ -267,12 +270,13 @@ Widget applyAdvancedGesturesDetectors(Game game, Widget child) { } Widget applyMouseDetectors(Game game, Widget child) { + final mouseMoveFn = game is MouseMovementDetector + ? game.onMouseMove + : (game is HasHoverableComponents ? game.onMouseMove : null); return Listener( child: MouseRegion( child: child, - onHover: game is MouseMovementDetector - ? (e) => game.onMouseMove(PointerHoverInfo.fromDetails(game, e)) - : null, + onHover: (e) => mouseMoveFn?.call(PointerHoverInfo.fromDetails(game, e)), ), onPointerSignal: (event) => game is ScrollDetector && event is PointerScrollEvent diff --git a/packages/flame/test/anchor_test.dart b/packages/flame/test/anchor_test.dart index 9a2ff0ff4..291a3b164 100644 --- a/packages/flame/test/anchor_test.dart +++ b/packages/flame/test/anchor_test.dart @@ -24,7 +24,7 @@ void main() { test('fail to parse invalid anchor', () { expect( () => Anchor.valueOf('foobar'), - throwsA(const TypeMatcher()), + throwsA(isA()), ); }); diff --git a/packages/flame/test/components/draggable_test.dart b/packages/flame/test/components/draggable_test.dart index c42731f15..7bf7c089e 100644 --- a/packages/flame/test/components/draggable_test.dart +++ b/packages/flame/test/components/draggable_test.dart @@ -28,15 +28,11 @@ void main() { final game2 = _GameWithoutDraggables(); game2.onResize(Vector2.all(100)); - var hasError = false; - try { - await game2.add(DraggableComponent()); - } catch (e) { - hasError = true; - } - - expect(hasError, true); + expect( + () => game2.add(DraggableComponent()), + throwsA(isA()), + ); }); test('can be dragged', () async { diff --git a/packages/flame/test/components/hoverable_test.dart b/packages/flame/test/components/hoverable_test.dart new file mode 100644 index 000000000..5c5a67c0b --- /dev/null +++ b/packages/flame/test/components/hoverable_test.dart @@ -0,0 +1,162 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame/src/components/mixins/hoverable.dart'; +import 'package:flame/src/gestures/events.dart'; +import 'package:flutter/gestures.dart' show PointerHoverEvent; +import 'package:test/test.dart'; + +class _GameWithHoverables extends BaseGame with HasHoverableComponents {} + +class _GameWithoutHoverables extends BaseGame {} + +class HoverableComponent extends PositionComponent with Hoverable { + int enterCount = 0; + int leaveCount = 0; + + @override + void onHoverEnter(PointerHoverInfo event) { + enterCount++; + } + + @override + void onHoverLeave(PointerHoverInfo event) { + leaveCount++; + } +} + +void main() { + group('hoverable test', () { + test('make sure they cannot be added to invalid games', () async { + final game1 = _GameWithHoverables(); + game1.onResize(Vector2.all(100)); + // should be ok + await game1.add(HoverableComponent()); + + final game2 = _GameWithoutHoverables(); + game2.onResize(Vector2.all(100)); + + expect( + () => game2.add(HoverableComponent()), + throwsA(isA()), + ); + }); + test('single component', () async { + final game = _GameWithHoverables(); + game.onResize(Vector2.all(100)); + + final c = HoverableComponent() + ..position = Vector2(10, 20) + ..size = Vector2(3, 3); + await game.add(c); + game.update(0); + + expect(c.isHovered, false); + expect(c.enterCount, 0); + expect(c.leaveCount, 0); + + _triggerMouseMove(game, 0, 0); + expect(c.isHovered, false); + expect(c.enterCount, 0); + expect(c.leaveCount, 0); + + _triggerMouseMove(game, 11, 0); + expect(c.isHovered, false); + expect(c.enterCount, 0); + expect(c.leaveCount, 0); + + _triggerMouseMove(game, 11, 21); // enter! + expect(c.isHovered, true); + expect(c.enterCount, 1); + expect(c.leaveCount, 0); + + _triggerMouseMove(game, 12, 22); // still inside + expect(c.isHovered, true); + expect(c.enterCount, 1); + expect(c.leaveCount, 0); + + _triggerMouseMove(game, 11, 25); // leave + expect(c.isHovered, false); + expect(c.enterCount, 1); + expect(c.leaveCount, 1); + + _triggerMouseMove(game, 11, 21); // enter again + expect(c.isHovered, true); + expect(c.enterCount, 2); + expect(c.leaveCount, 1); + }); + test('camera is respected', () async { + final game = _GameWithHoverables(); + game.onResize(Vector2.all(100)); + + final c = HoverableComponent() + ..position = Vector2(10, 20) + ..size = Vector2(3, 3); + await game.add(c); + game.update(0); + + // component is now at the corner of the screen + game.camera.snapTo(Vector2(10, 20)); + + _triggerMouseMove(game, 11, 21); + expect(c.isHovered, false); + _triggerMouseMove(game, 11, 1); + expect(c.isHovered, false); + _triggerMouseMove(game, 1, 1); + expect(c.isHovered, true); + _triggerMouseMove(game, 5, 1); + expect(c.isHovered, false); + }); + test('multiple components', () async { + final game = _GameWithHoverables(); + game.onResize(Vector2.all(100)); + + final a = HoverableComponent() + ..position = Vector2(10, 0) + ..size = Vector2(2, 20); + final b = HoverableComponent() + ..position = Vector2(10, 10) + ..size = Vector2(2, 2); + final c = HoverableComponent() + ..position = Vector2(0, 7) + ..size = Vector2(20, 2); + await game.add(a); + await game.add(b); + await game.add(c); + game.update(0); + + _triggerMouseMove(game, 0, 0); + expect(a.isHovered, false); + expect(b.isHovered, false); + expect(c.isHovered, false); + + _triggerMouseMove(game, 10, 10); + expect(a.isHovered, true); + expect(b.isHovered, true); + expect(c.isHovered, false); + + _triggerMouseMove(game, 11, 8); + expect(a.isHovered, true); + expect(b.isHovered, false); + expect(c.isHovered, true); + + _triggerMouseMove(game, 11, 6); + expect(a.isHovered, true); + expect(b.isHovered, false); + expect(c.isHovered, false); + }); + }); +} + +// TODO(luan) we can probably provide some helpers to facilitate testing events +void _triggerMouseMove(HasHoverableComponents game, double dx, double dy) { + game.onMouseMove( + PointerHoverInfo.fromDetails( + game, + PointerHoverEvent( + position: Offset(dx, dy), + ), + ), + ); +} diff --git a/packages/flame/test/components/tapables_test.dart b/packages/flame/test/components/tapables_test.dart index 22e2a1eb1..df7c9e851 100644 --- a/packages/flame/test/components/tapables_test.dart +++ b/packages/flame/test/components/tapables_test.dart @@ -19,15 +19,10 @@ void main() { final game2 = _GameWithoutTapables(); game2.onResize(Vector2.all(100)); - var hasError = false; - - try { - await game2.add(TapableComponent()); - } catch (e) { - hasError = true; - } - - expect(hasError, true); + expect( + () => game2.add(TapableComponent()), + throwsA(isA()), + ); }); }); }