mirror of
https://github.com/flame-engine/flame.git
synced 2025-11-14 03:40:21 +08:00
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:
@@ -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
|
||||
|
||||
58
doc/examples/gestures/lib/main_tapables_hitbox.dart
Normal file
58
doc/examples/gestures/lib/main_tapables_hitbox.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
10
doc/input.md
10
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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
18
lib/src/collision_detection.dart
Normal file
18
lib/src/collision_detection.dart
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
84
lib/src/components/mixins/hitbox.dart
Normal file
84
lib/src/components/mixins/hitbox.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user