Add hitbox to PositionComponent (#618)

* Move out collision detection methods

* Add possibility to define a hull for PositionComponents

* Add example of how to use hull with tapable

* Update contains point comment

* Fix contains point

* Hull should be based on center position

* Remove collision detection parts

* Added tests

* Use percentage of size instead of absolute size

* Separate hull from PositionComponent

* Clarify hull example

* Fix formatting

* Override correct method

* Use mixin for hitbox

* Update changelog

* Rename HasHitbox to Hitbox

* Clarified names

* Center to edge is considered as 1.0

* Fix test

* Add spaces within braces

* Removed extra spaces in the braces

* Add hitbox docs

* Fix link

* Moved point rotation to Vector2 extension

* Render hitbox within extension

* Fix rebase

* Fix rebase

* Fix formatting
This commit is contained in:
Lukas Klingsbo
2021-01-20 23:39:01 +01:00
committed by GitHub
parent b92a22fe27
commit 0593e35766
13 changed files with 259 additions and 55 deletions

View File

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

View File

@@ -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);
}
}

View File

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

View File

@@ -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';

View File

@@ -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<Vector2> 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;
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -0,0 +1,84 @@
import 'dart:ui';
import '../../../components.dart';
import '../../collision_detection.dart' as collision_detection;
mixin Hitbox on PositionComponent {
List<Vector2> _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<Vector2> vertices) => _shape = vertices;
List<Vector2> get shape => _shape ?? [];
/// Whether the hitbox shape has defined vertices and is not an empty list
bool hasShape() => _shape?.isNotEmpty ?? false;
Iterable<Vector2> _scaledShape;
Vector2 _lastScaledSize;
/// Gives back the shape vectors multiplied by the size of the component
Iterable<Vector2> 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<Vector2> _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<Vector2> 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);
}
}

View File

@@ -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);
}

View File

@@ -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<Vector2> _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,

View File

@@ -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());
}

View File

@@ -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() {

View File

@@ -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);
});
});
}