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

Add hitbox to PositionComponent #618

Merged
merged 28 commits into from
Jan 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7694edb
Move out collision detection methods
spydon Jan 6, 2021
8e76ed3
Add possibility to define a hull for PositionComponents
spydon Jan 9, 2021
99ec70f
Add example of how to use hull with tapable
spydon Jan 9, 2021
a6ee7ca
Update contains point comment
spydon Jan 9, 2021
040502b
Fix contains point
spydon Jan 9, 2021
8511059
Hull should be based on center position
spydon Jan 9, 2021
a761c7f
Remove collision detection parts
spydon Jan 9, 2021
7bf6c1c
Added tests
spydon Jan 9, 2021
415414e
Use percentage of size instead of absolute size
spydon Jan 9, 2021
62c33a3
Separate hull from PositionComponent
spydon Jan 10, 2021
288bb86
Clarify hull example
spydon Jan 10, 2021
cbe31a5
Fix formatting
spydon Jan 10, 2021
a81fec4
Override correct method
spydon Jan 10, 2021
9ed9049
Use mixin for hitbox
spydon Jan 11, 2021
5911119
Update changelog
spydon Jan 11, 2021
10d342e
Rename HasHitbox to Hitbox
spydon Jan 11, 2021
4148f08
Clarified names
spydon Jan 11, 2021
b7a1217
Center to edge is considered as 1.0
spydon Jan 11, 2021
63964da
Fix test
spydon Jan 11, 2021
81f3402
Add spaces within braces
spydon Jan 12, 2021
05c9d53
Removed extra spaces in the braces
spydon Jan 12, 2021
fce479d
Add hitbox docs
spydon Jan 13, 2021
a35cd18
Fix link
spydon Jan 13, 2021
826ab88
Moved point rotation to Vector2 extension
spydon Jan 17, 2021
1a28824
Render hitbox within extension
spydon Jan 17, 2021
d193c93
Fix rebase
spydon Jan 20, 2021
c4749b7
Fix rebase
spydon Jan 20, 2021
666d4c7
Fix formatting
spydon Jan 20, 2021
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions doc/examples/gestures/lib/main_tapables_hitbox.dart
Original file line number Diff line number Diff line change
@@ -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}) {
spydon marked this conversation as resolved.
Show resolved Hide resolved
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);
}
}
10 changes: 10 additions & 0 deletions doc/input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions lib/components.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
18 changes: 18 additions & 0 deletions lib/src/collision_detection.dart
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion lib/src/components/base_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion lib/src/components/mixins/draggable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
84 changes: 84 additions & 0 deletions lib/src/components/mixins/hitbox.dart
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 2 additions & 2 deletions lib/src/components/mixins/tapable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand Down
61 changes: 23 additions & 38 deletions lib/src/components/position_component.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down
21 changes: 16 additions & 5 deletions lib/src/extensions/vector2.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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());
}
2 changes: 1 addition & 1 deletion test/base_game_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading