diff --git a/CHANGELOG.md b/CHANGELOG.md index a98f2f034..c8a9776ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Move files to comply with the dart package layout convention - Fix gesture detection bug of children of `PositionComponent` - The `game` argument on `GameWidget` is now required + - Add hitbox mixin for PositionComponent to make more accurate gestures ## 1.0.0-rc5 - Option for overlays to be already visible on the GameWidget diff --git a/doc/examples/gestures/lib/main_tapables_hitbox.dart b/doc/examples/gestures/lib/main_tapables_hitbox.dart new file mode 100644 index 000000000..0dc853347 --- /dev/null +++ b/doc/examples/gestures/lib/main_tapables_hitbox.dart @@ -0,0 +1,58 @@ +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:flame/game.dart'; + +void main() { + runApp( + Container( + padding: const EdgeInsets.all(50), + color: const Color(0xFFA9A9A9), + child: GameWidget( + game: MyGame(), + ), + ), + ); +} + +class TapablePolygon extends PositionComponent with Tapable, Hitbox { + TapablePolygon({Vector2 position}) { + size = Vector2.all(100); + // The hitbox is defined as percentages of the full size of the component + shape = [ + Vector2(-1.0, 0.0), + Vector2(-0.8, 0.6), + Vector2(0.0, 1.0), + Vector2(0.6, 0.9), + Vector2(1.0, 0.0), + Vector2(0.6, -0.8), + Vector2(0, -1.0), + Vector2(-0.8, -0.8), + ]; + this.position = position ?? Vector2.all(150); + } + + @override + bool onTapUp(TapUpDetails details) { + return true; + } + + @override + bool onTapDown(TapDownDetails details) { + angle += 1.0; + size.add(Vector2.all(10)); + return true; + } + + @override + bool onTapCancel() { + return true; + } +} + +class MyGame extends BaseGame with HasTapableComponents { + MyGame() { + debugMode = true; + add(TapablePolygon()..anchor = Anchor.center); + add(TapablePolygon()..y = 350); + } +} diff --git a/doc/input.md b/doc/input.md index 40c114559..06caab683 100644 --- a/doc/input.md +++ b/doc/input.md @@ -207,6 +207,16 @@ class MyGame extends BaseGame with HasDraggableComponents { Warning: `HasDraggableComponents` uses an advanced gesture detector under the hood and as explained further up on this page, shouldn't be used alongside basic detectors. +## 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 +want to register input that is in the corner of the image where the rock is not displayed. Then you +can use the `Hitbox` mixin to define a more accurate polygon for which the input should be within +for the event to be counted on your component. + +An example of you to use it can be seen +[here](https://github.com/flame-engine/flame/blob/master/doc/examples/gestures/lib/main_tapables_hitbox.dart). + ## Keyboard Flame provides a simple way to access Flutter's features regarding accessing Keyboard input events. diff --git a/lib/components.dart b/lib/components.dart index 2f7046d6c..b6a4a48b6 100644 --- a/lib/components.dart +++ b/lib/components.dart @@ -16,6 +16,7 @@ export 'joystick.dart'; export 'src/components/mixins/draggable.dart'; export 'src/components/mixins/has_game_ref.dart'; +export 'src/components/mixins/hitbox.dart'; export 'src/components/mixins/single_child_particle.dart'; export 'src/components/mixins/tapable.dart'; diff --git a/lib/src/collision_detection.dart b/lib/src/collision_detection.dart new file mode 100644 index 000000000..92d43c40f --- /dev/null +++ b/lib/src/collision_detection.dart @@ -0,0 +1,18 @@ +import '../extensions.dart'; + +/// Checks whether the [polygon] represented by the list of [Vector2] contains +/// the [point]. +bool containsPoint(Vector2 point, List polygon) { + for (int i = 0; i < polygon.length; i++) { + final previousNode = polygon[i]; + final node = polygon[(i + 1) % polygon.length]; + final isOutside = (node.x - previousNode.x) * (point.y - previousNode.y) - + (point.x - previousNode.x) * (node.y - previousNode.y) > + 0; + if (isOutside) { + // Point is outside of convex polygon + return false; + } + } + return true; +} diff --git a/lib/src/components/base_component.dart b/lib/src/components/base_component.dart index 3db21eaef..51313579f 100644 --- a/lib/src/components/base_component.dart +++ b/lib/src/components/base_component.dart @@ -109,7 +109,7 @@ abstract class BaseComponent extends Component { /// Called to check whether the point is to be counted as within the component /// It needs to be overridden to have any effect, like it is in the /// [PositionComponent] - bool checkOverlap(Vector2 point) => false; + bool containsPoint(Vector2 point) => false; /// Add an effect to the component void addEffect(ComponentEffect effect) { diff --git a/lib/src/components/mixins/draggable.dart b/lib/src/components/mixins/draggable.dart index dda70947e..880237ca4 100644 --- a/lib/src/components/mixins/draggable.dart +++ b/lib/src/components/mixins/draggable.dart @@ -12,7 +12,7 @@ mixin Draggable on BaseComponent { } bool handleReceiveDrag(DragEvent event) { - if (checkOverlap(event.initialPosition.toVector2())) { + if (containsPoint(event.initialPosition.toVector2())) { return onReceiveDrag(event); } return true; diff --git a/lib/src/components/mixins/hitbox.dart b/lib/src/components/mixins/hitbox.dart new file mode 100644 index 000000000..7258171a7 --- /dev/null +++ b/lib/src/components/mixins/hitbox.dart @@ -0,0 +1,84 @@ +import 'dart:ui'; + +import '../../../components.dart'; +import '../../collision_detection.dart' as collision_detection; + +mixin Hitbox on PositionComponent { + List _shape; + + /// The list of vertices used for collision detection and to define whether + /// a point is inside of the component or not, so that the tap detection etc + /// can be more accurately performed. + /// The hitbox is defined from the center of the component and with + /// percentages of the size of the component. + /// Example: [[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0], [0.0, -1.0]] + /// This will form a square with a 45 degree angle (pi/4 rad) within the + /// bounding size box. + set shape(List vertices) => _shape = vertices; + List get shape => _shape ?? []; + + /// Whether the hitbox shape has defined vertices and is not an empty list + bool hasShape() => _shape?.isNotEmpty ?? false; + + Iterable _scaledShape; + Vector2 _lastScaledSize; + + /// Gives back the shape vectors multiplied by the size of the component + Iterable get scaledShape { + if (_lastScaledSize != size || _scaledShape == null) { + _lastScaledSize = size; + _scaledShape = _shape?.map( + (p) => p.clone()..multiply(size / 2), + ); + } + return _scaledShape; + } + + void renderContour(Canvas canvas) { + final hitboxPath = Path() + ..addPolygon( + scaledShape.map((point) => (point + size / 2).toOffset()).toList(), + true, + ); + canvas.drawPath(hitboxPath, debugPaint); + } + + // These variables are used to see whether the bounding vertices cache is + // valid or not + Vector2 _lastCachePosition; + Vector2 _lastCacheSize; + double _lastCacheAngle; + bool _hadShape = false; + List _cachedHitbox; + + bool _isHitboxCacheValid() { + return _lastCacheAngle == angle && + _lastCacheSize == size && + _lastCachePosition == position && + _hadShape == hasShape(); + } + + /// Gives back the bounding vertices represented as a list of points which + /// are the "corners" of the hitbox rotated with [angle]. + List get hitbox { + // Use cached bounding vertices if state of the component hasn't changed + if (!_isHitboxCacheValid()) { + _cachedHitbox = scaledShape + .map((point) => rotatePoint(center + point)) + .toList(growable: false) ?? + []; + _lastCachePosition = position.clone(); + _lastCacheSize = size.clone(); + _lastCacheAngle = angle; + _hadShape = hasShape(); + } + return _cachedHitbox; + } + + /// Checks whether the hitbox represented by the list of [Vector2] contains + /// the [point]. + @override + bool containsPoint(Vector2 point) { + return collision_detection.containsPoint(point, hitbox); + } +} diff --git a/lib/src/components/mixins/tapable.dart b/lib/src/components/mixins/tapable.dart index 833694f72..c9567600c 100644 --- a/lib/src/components/mixins/tapable.dart +++ b/lib/src/components/mixins/tapable.dart @@ -24,7 +24,7 @@ mixin Tapable on BaseComponent { bool _checkPointerId(int pointerId) => _currentPointerId == pointerId; bool handleTapDown(int pointerId, TapDownDetails details) { - if (checkOverlap(details.localPosition.toVector2())) { + if (containsPoint(details.localPosition.toVector2())) { _currentPointerId = pointerId; return onTapDown(details); } @@ -33,7 +33,7 @@ mixin Tapable on BaseComponent { bool handleTapUp(int pointerId, TapUpDetails details) { if (_checkPointerId(pointerId) && - checkOverlap(details.localPosition.toVector2())) { + containsPoint(details.localPosition.toVector2())) { _currentPointerId = null; return onTapUp(details); } diff --git a/lib/src/components/position_component.dart b/lib/src/components/position_component.dart index 7f84fc89d..63ac4846e 100644 --- a/lib/src/components/position_component.dart +++ b/lib/src/components/position_component.dart @@ -1,12 +1,12 @@ import 'dart:ui' hide Offset; -import 'dart:math' as math; +import '../collision_detection.dart' as collision_detection; import '../anchor.dart'; import '../extensions/offset.dart'; import '../extensions/vector2.dart'; -import '../../game.dart'; import 'base_component.dart'; import 'component.dart'; +import 'mixins/hitbox.dart'; /// A [Component] implementation that represents a component that has a /// specific, possibly dynamic position on the screen. @@ -71,6 +71,11 @@ abstract class PositionComponent extends BaseComponent { this.position = position + (anchor.toVector2..multiply(size)); } + /// Get the position of the center of the component + Vector2 get center { + return anchor == Anchor.center ? position : topLeftPosition + (size / 2); + } + /// Angle (with respect to the x-axis) this component should be rendered with. /// It is rotated around its anchor. double angle = 0.0; @@ -98,45 +103,22 @@ abstract class PositionComponent extends BaseComponent { topLeftPosition = rect.topLeft.toVector2(); } - @override - bool checkOverlap(Vector2 absolutePoint) { - final point = absolutePoint - absoluteCanvasPosition; - final corners = _rotatedCorners(); - for (int i = 0; i < corners.length; i++) { - final previousCorner = corners[i]; - final corner = corners[(i + 1) % corners.length]; - final isOutside = - (corner.x - previousCorner.x) * (point.y - previousCorner.y) - - (point.x - previousCorner.x) * (corner.y - previousCorner.y) > - 0; - if (isOutside) { - // Point is outside of convex polygon (only used for rectangles so far) - return false; - } - } - return true; + /// Rotate [point] around component's angle and position (anchor) + Vector2 rotatePoint(Vector2 point) { + return point.clone()..rotate(angle, center: position); } - List _rotatedCorners() { - // Rotates the corner around [position] - Vector2 rotateCorner(Vector2 corner) { - return Vector2( - math.cos(angle) * (corner.x - position.x) - - math.sin(angle) * (corner.y - position.y) + - position.x, - math.sin(angle) * (corner.x - position.x) + - math.cos(angle) * (corner.y - position.y) + - position.y, - ); - } - - // Counter-clockwise direction - return [ - rotateCorner(topLeftPosition), // Top-left - rotateCorner(topLeftPosition + Vector2(0.0, size.y)), // Bottom-left - rotateCorner(topLeftPosition + size), // Bottom-right - rotateCorner(topLeftPosition + Vector2(size.x, 0.0)), // Top-right + @override + bool containsPoint(Vector2 point) { + final corners = [ + rotatePoint(absoluteTopLeftPosition), // Top-left + rotatePoint( + absoluteTopLeftPosition + Vector2(0.0, size.y)), // Bottom-left + rotatePoint(absoluteTopLeftPosition + size), // Bottom-right + rotatePoint(absoluteTopLeftPosition + Vector2(size.x, 0.0)), // Top-right ]; + + return collision_detection.containsPoint(point, corners); } double angleTo(PositionComponent c) => position.angleTo(c.position); @@ -145,6 +127,9 @@ abstract class PositionComponent extends BaseComponent { @override void renderDebugMode(Canvas canvas) { + if (this is Hitbox) { + (this as Hitbox).renderContour(canvas); + } canvas.drawRect(size.toRect(), debugPaint); debugTextConfig.render( canvas, diff --git a/lib/src/extensions/vector2.dart b/lib/src/extensions/vector2.dart index 2e5f1da13..7c759a6c7 100644 --- a/lib/src/extensions/vector2.dart +++ b/lib/src/extensions/vector2.dart @@ -28,11 +28,19 @@ extension Vector2Extension on Vector2 { } /// Rotates the [Vector2] with [angle] in radians - void rotate(double angle) { - setValues( - x * cos(angle) - y * sin(angle), - x * sin(angle) + y * cos(angle), - ); + /// rotates around [center] if it is defined + void rotate(double angle, {Vector2 center}) { + if (center == null) { + setValues( + x * cos(angle) - y * sin(angle), + x * sin(angle) + y * cos(angle), + ); + } else { + setValues( + cos(angle) * (x - center.x) - sin(angle) * (y - center.y) + center.x, + sin(angle) * (x - center.x) + cos(angle) * (y - center.y) + center.y, + ); + } } /// Changes the [length] of the vector to the length provided, without changing direction. @@ -45,6 +53,9 @@ extension Vector2Extension on Vector2 { } } + /// Modulo/Remainder + Vector2 operator %(Vector2 mod) => Vector2(x % mod.x, y % mod.y); + /// Create a Vector2 with ints as input static Vector2 fromInts(int x, int y) => Vector2(x.toDouble(), y.toDouble()); } diff --git a/test/base_game_test.dart b/test/base_game_test.dart index e5260af60..ccf936f10 100644 --- a/test/base_game_test.dart +++ b/test/base_game_test.dart @@ -43,7 +43,7 @@ class MyComponent extends PositionComponent with Tapable, HasGameRef { } @override - bool checkOverlap(Vector2 v) => true; + bool containsPoint(Vector2 v) => true; @override void onRemove() { diff --git a/test/components/position_component_test.dart b/test/components/position_component_test.dart index 34ff5ce84..37f1de03b 100644 --- a/test/components/position_component_test.dart +++ b/test/components/position_component_test.dart @@ -5,6 +5,8 @@ import 'package:test/test.dart'; class MyComponent extends PositionComponent {} +class MyHitboxComponent extends PositionComponent with Hitbox {} + void main() { group('PositionComponent overlap test', () { test('overlap', () { @@ -15,7 +17,7 @@ void main() { component.anchor = Anchor.center; final point = Vector2(2.0, 2.0); - expect(component.checkOverlap(point), true); + expect(component.containsPoint(point), true); }); test('overlap on edge', () { @@ -26,7 +28,7 @@ void main() { component.anchor = Anchor.center; final point = Vector2(1.0, 1.0); - expect(component.checkOverlap(point), true); + expect(component.containsPoint(point), true); }); test('not overlapping with x', () { @@ -37,7 +39,7 @@ void main() { component.anchor = Anchor.center; final point = Vector2(4.0, 1.0); - expect(component.checkOverlap(point), false); + expect(component.containsPoint(point), false); }); test('not overlapping with y', () { @@ -48,7 +50,7 @@ void main() { component.anchor = Anchor.center; final point = Vector2(1.0, 4.0); - expect(component.checkOverlap(point), false); + expect(component.containsPoint(point), false); }); test('overlapping with angle', () { @@ -59,7 +61,7 @@ void main() { component.anchor = Anchor.center; final point = Vector2(3.1, 2.0); - expect(component.checkOverlap(point), true); + expect(component.containsPoint(point), true); }); test('not overlapping with angle', () { @@ -70,7 +72,7 @@ void main() { component.anchor = Anchor.center; final point = Vector2(1.0, 0.1); - expect(component.checkOverlap(point), false); + expect(component.containsPoint(point), false); }); test('overlapping with angle and topLeft anchor', () { @@ -81,7 +83,41 @@ void main() { component.anchor = Anchor.topLeft; final point = Vector2(1.0, 3.1); - expect(component.checkOverlap(point), true); + expect(component.containsPoint(point), true); + }); + + test('component with hitbox contains point', () { + final size = Vector2(2.0, 2.0); + final Hitbox component = MyHitboxComponent(); + component.position = Vector2(1.0, 1.0); + component.anchor = Anchor.topLeft; + component.size = size; + component.shape = [ + Vector2(1, 0), + Vector2(0, -1), + Vector2(-1, 0), + Vector2(0, 1), + ]; + + final point = component.position + component.size / 4; + expect(component.containsPoint(point), true); + }); + + test('component with hitbox does not contains point', () { + final size = Vector2(2.0, 2.0); + final Hitbox component = MyHitboxComponent(); + component.position = Vector2(1.0, 1.0); + component.anchor = Anchor.topLeft; + component.size = size; + component.shape = [ + Vector2(1, 0), + Vector2(0, -1), + Vector2(-1, 0), + Vector2(0, 1), + ]; + + final point = Vector2(1.1, 1.1); + expect(component.containsPoint(point), false); }); }); }