mirror of
https://github.com/flame-engine/flame.git
synced 2025-11-02 11:43:19 +08:00
onCollisionEnd callbacks for Collidable and HitboxShape (#792)
* Add onCollisionEnd for HitboxShape and Collidable * Add tests for collision callbacks * Detect multiple collsions with same collidable in test * Remove unused import * Break out duplicated code * Fix formatting * Use correct hash set * Update examples/lib/stories/collision_detection/multiple_shapes.dart Co-authored-by: Erick <erickzanardoo@gmail.com> * Update examples/lib/stories/collision_detection/multiple_shapes.dart Co-authored-by: Erick <erickzanardoo@gmail.com> * Use hashValues instead of _combineHashCodes * hashValues is order dependent so we need to sort the objects first * Add section about onCollisionEnd * Fix missed hashValues * Use xor instead of hashValues * Update examples/lib/stories/collision_detection/collision_detection.dart Co-authored-by: Luan Nico <luanpotter27@gmail.com> Co-authored-by: Erick <erickzanardoo@gmail.com> Co-authored-by: Luan Nico <luanpotter27@gmail.com>
This commit is contained in:
@ -86,6 +86,15 @@ class MyCollidable extends PositionComponent with Hitbox, Collidable {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onCollisionEnd(Collidable other) {
|
||||
if (other is CollidableScreen) {
|
||||
...
|
||||
} else if (other is YourOtherCollidable) {
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@ -93,7 +102,8 @@ In this example it can be seen how the Dart `is` keyword is used to check which
|
||||
that your component collided with. The set of points is where the edges of the hitboxes collided.
|
||||
Note that the `onCollision` method will be called on both collidable components if they
|
||||
have both implemented the `onCollision` method, and also on both shapes if they have that method
|
||||
implemented.
|
||||
implemented. The same goes for the `onCollisionEnd` method, which is called when two components or
|
||||
shapes that were previously colliding no longer colliding with each other.
|
||||
|
||||
If you want to check collisions with the screen edges, as we do in the example above, you can use
|
||||
the predefined [ScreenCollidable](#ScreenCollidable) class and since that one also is a `Collidable`
|
||||
|
||||
@ -6,6 +6,19 @@ import 'circles.dart';
|
||||
import 'multiple_shapes.dart';
|
||||
import 'only_shapes.dart';
|
||||
|
||||
const basicInfo = '''
|
||||
An example with many hitboxes that move around on the screen and during
|
||||
collisions they change color depending on what it is that they have collided
|
||||
with.
|
||||
|
||||
The snowman, the component built with three circles on top of each other, works
|
||||
a little bit differently than the other components to show that you can have
|
||||
multiple hitboxes within one component.
|
||||
|
||||
On this example, you can "throw" the components by dragging them quickly in any
|
||||
direction.
|
||||
''';
|
||||
|
||||
void addCollisionDetectionStories(Dashbook dashbook) {
|
||||
dashbook.storiesOf('Collision Detection')
|
||||
..add(
|
||||
@ -17,6 +30,7 @@ void addCollisionDetectionStories(Dashbook dashbook) {
|
||||
'Multiple shapes',
|
||||
(_) => GameWidget(game: MultipleShapes()),
|
||||
codeLink: baseLink('collision_detection/multiple_shapes.dart'),
|
||||
info: basicInfo,
|
||||
)
|
||||
..add(
|
||||
'Shapes without components',
|
||||
|
||||
@ -18,9 +18,9 @@ abstract class MyCollidable extends PositionComponent
|
||||
final delta = Vector2.zero();
|
||||
double angleDelta = 0;
|
||||
bool _isDragged = false;
|
||||
final _activePaint = Paint()..color = Colors.amber;
|
||||
late final Color _defaultDebugColor = debugColor;
|
||||
bool _isHit = false;
|
||||
late final Paint _activePaint;
|
||||
final Color _defaultColor = Colors.blue.withOpacity(0.8);
|
||||
final Set<Collidable> _activeCollisions = {};
|
||||
final ScreenCollidable screenCollidable;
|
||||
|
||||
MyCollidable(
|
||||
@ -34,17 +34,17 @@ abstract class MyCollidable extends PositionComponent
|
||||
anchor = Anchor.center;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onLoad() async {
|
||||
_activePaint = Paint()..color = _defaultColor;
|
||||
}
|
||||
|
||||
@override
|
||||
void update(double dt) {
|
||||
super.update(dt);
|
||||
if (_isDragged) {
|
||||
return;
|
||||
}
|
||||
if (!_isHit) {
|
||||
debugColor = _defaultDebugColor;
|
||||
} else {
|
||||
_isHit = false;
|
||||
}
|
||||
delta.setFrom(velocity * dt);
|
||||
position.add(delta);
|
||||
angleDelta = dt * rotationSpeed;
|
||||
@ -63,6 +63,7 @@ abstract class MyCollidable extends PositionComponent
|
||||
@override
|
||||
void render(Canvas canvas) {
|
||||
super.render(canvas);
|
||||
renderShapes(canvas, paint: _activePaint);
|
||||
if (_isDragged) {
|
||||
final localCenter = (size / 2).toOffset();
|
||||
canvas.drawCircle(localCenter, 5, _activePaint);
|
||||
@ -71,25 +72,34 @@ abstract class MyCollidable extends PositionComponent
|
||||
|
||||
@override
|
||||
void onCollision(Set<Vector2> intersectionPoints, Collidable other) {
|
||||
_isHit = true;
|
||||
final isNew = _activeCollisions.add(other);
|
||||
if (isNew) {
|
||||
_activePaint.color = collisionColor(other).withOpacity(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onCollisionEnd(Collidable other) {
|
||||
_activeCollisions.remove(other);
|
||||
if (_activeCollisions.isEmpty) {
|
||||
_activePaint.color = _defaultColor;
|
||||
}
|
||||
}
|
||||
|
||||
Color collisionColor(Collidable other) {
|
||||
switch (other.runtimeType) {
|
||||
case ScreenCollidable:
|
||||
debugColor = Colors.teal;
|
||||
break;
|
||||
return Colors.teal;
|
||||
case CollidablePolygon:
|
||||
debugColor = Colors.blue;
|
||||
break;
|
||||
return Colors.deepOrange;
|
||||
case CollidableCircle:
|
||||
debugColor = Colors.green;
|
||||
break;
|
||||
return Colors.green;
|
||||
case CollidableRectangle:
|
||||
debugColor = Colors.cyan;
|
||||
break;
|
||||
return Colors.cyan;
|
||||
case CollidableSnowman:
|
||||
debugColor = Colors.amber;
|
||||
break;
|
||||
return Colors.amber;
|
||||
default:
|
||||
debugColor = Colors.pink;
|
||||
return Colors.pink;
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,20 +162,18 @@ class CollidableCircle extends MyCollidable {
|
||||
}
|
||||
|
||||
class SnowmanPart extends HitboxCircle {
|
||||
static const startColor = Colors.white;
|
||||
final hitPaint = Paint()
|
||||
..color = startColor
|
||||
..strokeWidth = 1
|
||||
..style = PaintingStyle.stroke;
|
||||
final startColor = Colors.blue.withOpacity(0.8);
|
||||
final hitPaint = Paint();
|
||||
|
||||
SnowmanPart(double definition, Vector2 relativeOffset, Color hitColor)
|
||||
: super(definition: definition) {
|
||||
this.relativeOffset.setFrom(relativeOffset);
|
||||
hitPaint..color = startColor;
|
||||
onCollision = (Set<Vector2> intersectionPoints, HitboxShape other) {
|
||||
if (other.component is ScreenCollidable) {
|
||||
hitPaint..color = startColor;
|
||||
} else {
|
||||
hitPaint..color = hitColor;
|
||||
hitPaint.color = hitColor.withOpacity(0.8);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -196,9 +204,6 @@ class CollidableSnowman extends MyCollidable {
|
||||
|
||||
class MultipleShapes extends BaseGame
|
||||
with HasCollidables, HasDraggableComponents {
|
||||
@override
|
||||
bool debugMode = true;
|
||||
|
||||
final TextPaint fpsTextPaint = TextPaint(
|
||||
config: TextPaintConfig(
|
||||
color: BasicPalette.white.color,
|
||||
@ -219,7 +224,7 @@ class MultipleShapes extends BaseGame
|
||||
add(screenCollidable);
|
||||
add(snowman);
|
||||
var totalAdded = 1;
|
||||
while (totalAdded < 10) {
|
||||
while (totalAdded < 20) {
|
||||
lastToAdd = createRandomCollidable(lastToAdd, screenCollidable);
|
||||
final lastBottomRight =
|
||||
lastToAdd.toAbsoluteRect().bottomRight.toVector2();
|
||||
|
||||
@ -18,7 +18,7 @@ dependencies:
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
dart_code_metrics: ^3.1.0
|
||||
dart_code_metrics: ^3.2.2
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
- Replace deprecated analysis option lines-of-executable-code with source-lines-of-code
|
||||
- Fix the anchor of SpriteWidget
|
||||
- Add test for re-adding previously removed component
|
||||
- Add onCollisionEnd to make it possible for the user to easily detect when a collision ends
|
||||
|
||||
## [1.0.0-rc10]
|
||||
- Updated tutorial documentation to indicate use of new version
|
||||
|
||||
@ -18,6 +18,7 @@ mixin Collidable on Hitbox {
|
||||
CollidableType collidableType = CollidableType.active;
|
||||
|
||||
void onCollision(Set<Vector2> intersectionPoints, Collidable other) {}
|
||||
void onCollisionEnd(Collidable other) {}
|
||||
}
|
||||
|
||||
class ScreenCollidable extends PositionComponent
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:ui';
|
||||
|
||||
import '../../../extensions.dart';
|
||||
import '../../geometry/shape.dart';
|
||||
@ -26,8 +27,8 @@ mixin Hitbox on PositionComponent {
|
||||
_shapes.any((shape) => shape.containsPoint(point));
|
||||
}
|
||||
|
||||
void renderShapes(Canvas canvas) {
|
||||
_shapes.forEach((shape) => shape.render(canvas, debugPaint));
|
||||
void renderShapes(Canvas canvas, {Paint? paint}) {
|
||||
_shapes.forEach((shape) => shape.render(canvas, paint ?? debugPaint));
|
||||
}
|
||||
|
||||
/// Since this is a cheaper calculation than checking towards all shapes, this
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import '../../extensions.dart';
|
||||
import '../../geometry.dart';
|
||||
import '../components/mixins/collidable.dart';
|
||||
|
||||
final Set<int> _collidableHashes = {};
|
||||
final Set<int> _shapeHashes = {};
|
||||
|
||||
int _collidableTypeCompare(Collidable a, Collidable b) {
|
||||
return a.collidableType.index - b.collidableType.index;
|
||||
}
|
||||
@ -25,11 +29,43 @@ void collisionDetection(List<Collidable> collidables) {
|
||||
if (intersectionPoints.isNotEmpty) {
|
||||
collidableX.onCollision(intersectionPoints, collidableY);
|
||||
collidableY.onCollision(intersectionPoints, collidableX);
|
||||
final collisionHash = _combinedHash(collidableX, collidableY);
|
||||
_collidableHashes.add(collisionHash);
|
||||
} else {
|
||||
_handleCollisionEnd(collidableX, collidableY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool hasActiveCollision(Collidable collidableA, Collidable collidableB) {
|
||||
return _collidableHashes.contains(
|
||||
_combinedHash(collidableA, collidableB),
|
||||
);
|
||||
}
|
||||
|
||||
bool hasActiveShapeCollision(HitboxShape shapeA, HitboxShape shapeB) {
|
||||
return _shapeHashes.contains(
|
||||
_combinedHash(shapeA, shapeB),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleCollisionEnd(Collidable collidableA, Collidable collidableB) {
|
||||
if (hasActiveCollision(collidableA, collidableB)) {
|
||||
collidableA.onCollisionEnd(collidableB);
|
||||
collidableB.onCollisionEnd(collidableA);
|
||||
_collidableHashes.remove(_combinedHash(collidableA, collidableB));
|
||||
}
|
||||
}
|
||||
|
||||
void _handleShapeCollisionEnd(HitboxShape shapeA, HitboxShape shapeB) {
|
||||
if (hasActiveShapeCollision(shapeA, shapeB)) {
|
||||
shapeA.onCollisionEnd(shapeB);
|
||||
shapeB.onCollisionEnd(shapeA);
|
||||
_shapeHashes.remove(_combinedHash(shapeA, shapeB));
|
||||
}
|
||||
}
|
||||
|
||||
/// Check what the intersection points of two collidables are
|
||||
/// returns an empty list if there are no intersections
|
||||
Set<Vector2> intersections(
|
||||
@ -38,6 +74,13 @@ Set<Vector2> intersections(
|
||||
) {
|
||||
if (!collidableA.possiblyOverlapping(collidableB)) {
|
||||
// These collidables can't have any intersection points
|
||||
if (hasActiveCollision(collidableA, collidableB)) {
|
||||
for (final shapeA in collidableA.shapes) {
|
||||
for (final shapeB in collidableB.shapes) {
|
||||
_handleShapeCollisionEnd(shapeA, shapeB);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@ -52,8 +95,15 @@ Set<Vector2> intersections(
|
||||
shapeA.onCollision(currentResult, shapeB);
|
||||
shapeB.onCollision(currentResult, shapeA);
|
||||
currentResult.clear();
|
||||
_shapeHashes.add(_combinedHash(shapeA, shapeB));
|
||||
} else {
|
||||
_handleShapeCollisionEnd(shapeA, shapeB);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int _combinedHash(Object o1, Object o2) {
|
||||
return o1.hashCode ^ o2.hashCode;
|
||||
}
|
||||
|
||||
@ -126,6 +126,10 @@ mixin HitboxShape on Shape {
|
||||
/// Assign your own [CollisionCallback] if you want a callback when this
|
||||
/// shape collides with another [HitboxShape]
|
||||
CollisionCallback onCollision = emptyCollisionCallback;
|
||||
|
||||
/// Assign your own [CollisionEndCallback] if you want a callback when this
|
||||
/// shape stops colliding with another [HitboxShape]
|
||||
CollisionEndCallback onCollisionEnd = emptyCollisionEndCallback;
|
||||
}
|
||||
|
||||
typedef CollisionCallback = void Function(
|
||||
@ -133,7 +137,10 @@ typedef CollisionCallback = void Function(
|
||||
HitboxShape other,
|
||||
);
|
||||
|
||||
typedef CollisionEndCallback = void Function(HitboxShape other);
|
||||
|
||||
void emptyCollisionCallback(Set<Vector2> _, HitboxShape __) {}
|
||||
void emptyCollisionEndCallback(HitboxShape _) {}
|
||||
|
||||
/// Used for caching calculated shapes, the cache is determined to be valid by
|
||||
/// comparing a list of values that can be of any type and is compared to the
|
||||
|
||||
@ -14,7 +14,7 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
test: ^1.16.0
|
||||
dart_code_metrics: ^3.1.0
|
||||
dart_code_metrics: ^3.2.2
|
||||
dartdoc: ^0.42.0
|
||||
|
||||
environment:
|
||||
|
||||
129
packages/flame/test/components/collision_callback_test.dart
Normal file
129
packages/flame/test/components/collision_callback_test.dart
Normal file
@ -0,0 +1,129 @@
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame/extensions.dart';
|
||||
import 'package:flame/game.dart';
|
||||
import 'package:flame/geometry.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:vector_math/vector_math_64.dart';
|
||||
|
||||
class TestGame extends BaseGame with HasCollidables {
|
||||
TestGame() {
|
||||
onResize(Vector2.all(200));
|
||||
}
|
||||
}
|
||||
|
||||
class TestHitbox extends HitboxRectangle {
|
||||
final Set<HitboxShape> collisions = {};
|
||||
int endCounter = 0;
|
||||
|
||||
TestHitbox() {
|
||||
onCollision = (_, shape) => collisions.add(shape);
|
||||
onCollisionEnd = (shape) {
|
||||
endCounter++;
|
||||
collisions.remove(shape);
|
||||
};
|
||||
}
|
||||
|
||||
bool hasCollisionWith(HitboxShape otherShape) {
|
||||
return collisions.contains(otherShape);
|
||||
}
|
||||
}
|
||||
|
||||
class TestBlock extends PositionComponent with Hitbox, Collidable {
|
||||
final Set<Collidable> collisions = {};
|
||||
final hitbox = TestHitbox();
|
||||
int endCounter = 0;
|
||||
|
||||
TestBlock(Vector2 position, Vector2 size)
|
||||
: super(
|
||||
position: position,
|
||||
size: size,
|
||||
) {
|
||||
addShape(hitbox);
|
||||
}
|
||||
|
||||
bool hasCollisionWith(Collidable otherCollidable) {
|
||||
return collisions.contains(otherCollidable);
|
||||
}
|
||||
|
||||
@override
|
||||
void onCollision(Set<Vector2> intersectionPoints, Collidable other) {
|
||||
collisions.add(other);
|
||||
}
|
||||
|
||||
@override
|
||||
void onCollisionEnd(Collidable other) {
|
||||
endCounter++;
|
||||
collisions.remove(other);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestGame gameWithCollidables(List<Collidable> collidables) {
|
||||
final game = TestGame();
|
||||
game.addAll(collidables);
|
||||
game.update(0);
|
||||
expect(game.components.isNotEmpty, collidables.isNotEmpty);
|
||||
return game;
|
||||
}
|
||||
|
||||
group(
|
||||
'Collision callbacks are called properly',
|
||||
() {
|
||||
test('collidable callbacks are called', () {
|
||||
final blockA = TestBlock(
|
||||
Vector2.zero(),
|
||||
Vector2.all(10),
|
||||
);
|
||||
final blockB = TestBlock(
|
||||
Vector2.all(1),
|
||||
Vector2.all(10),
|
||||
);
|
||||
final game = gameWithCollidables([blockA, blockB]);
|
||||
expect(blockA.hasCollisionWith(blockB), true);
|
||||
expect(blockB.hasCollisionWith(blockA), true);
|
||||
expect(blockA.collisions.length, 1);
|
||||
expect(blockB.collisions.length, 1);
|
||||
blockB.position = Vector2.all(21);
|
||||
expect(blockA.endCounter, 0);
|
||||
expect(blockB.endCounter, 0);
|
||||
game.update(0);
|
||||
expect(blockA.hasCollisionWith(blockB), false);
|
||||
expect(blockB.hasCollisionWith(blockA), false);
|
||||
expect(blockA.collisions.length, 0);
|
||||
expect(blockB.collisions.length, 0);
|
||||
game.update(0);
|
||||
expect(blockA.endCounter, 1);
|
||||
expect(blockB.endCounter, 1);
|
||||
});
|
||||
|
||||
test('hitbox callbacks are called', () {
|
||||
final blockA = TestBlock(
|
||||
Vector2.zero(),
|
||||
Vector2.all(10),
|
||||
);
|
||||
final blockB = TestBlock(
|
||||
Vector2.all(1),
|
||||
Vector2.all(10),
|
||||
);
|
||||
final hitboxA = blockA.hitbox;
|
||||
final hitboxB = blockB.hitbox;
|
||||
final game = gameWithCollidables([blockA, blockB]);
|
||||
expect(hitboxA.hasCollisionWith(hitboxB), true);
|
||||
expect(hitboxB.hasCollisionWith(hitboxA), true);
|
||||
expect(hitboxA.collisions.length, 1);
|
||||
expect(hitboxB.collisions.length, 1);
|
||||
blockB.position = Vector2.all(21);
|
||||
expect(hitboxA.endCounter, 0);
|
||||
expect(hitboxB.endCounter, 0);
|
||||
game.update(0);
|
||||
expect(hitboxA.hasCollisionWith(hitboxB), false);
|
||||
expect(hitboxB.hasCollisionWith(hitboxA), false);
|
||||
expect(hitboxA.collisions.length, 0);
|
||||
expect(hitboxB.collisions.length, 0);
|
||||
game.update(0);
|
||||
expect(hitboxA.endCounter, 1);
|
||||
expect(hitboxB.endCounter, 1);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user