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:
Lukas Klingsbo
2021-05-18 16:21:49 +02:00
committed by GitHub
parent 52a0239ab5
commit e4281d6471
11 changed files with 253 additions and 35 deletions

View File

@ -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. 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 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 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 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` the predefined [ScreenCollidable](#ScreenCollidable) class and since that one also is a `Collidable`

View File

@ -6,6 +6,19 @@ import 'circles.dart';
import 'multiple_shapes.dart'; import 'multiple_shapes.dart';
import 'only_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) { void addCollisionDetectionStories(Dashbook dashbook) {
dashbook.storiesOf('Collision Detection') dashbook.storiesOf('Collision Detection')
..add( ..add(
@ -17,6 +30,7 @@ void addCollisionDetectionStories(Dashbook dashbook) {
'Multiple shapes', 'Multiple shapes',
(_) => GameWidget(game: MultipleShapes()), (_) => GameWidget(game: MultipleShapes()),
codeLink: baseLink('collision_detection/multiple_shapes.dart'), codeLink: baseLink('collision_detection/multiple_shapes.dart'),
info: basicInfo,
) )
..add( ..add(
'Shapes without components', 'Shapes without components',

View File

@ -18,9 +18,9 @@ abstract class MyCollidable extends PositionComponent
final delta = Vector2.zero(); final delta = Vector2.zero();
double angleDelta = 0; double angleDelta = 0;
bool _isDragged = false; bool _isDragged = false;
final _activePaint = Paint()..color = Colors.amber; late final Paint _activePaint;
late final Color _defaultDebugColor = debugColor; final Color _defaultColor = Colors.blue.withOpacity(0.8);
bool _isHit = false; final Set<Collidable> _activeCollisions = {};
final ScreenCollidable screenCollidable; final ScreenCollidable screenCollidable;
MyCollidable( MyCollidable(
@ -34,17 +34,17 @@ abstract class MyCollidable extends PositionComponent
anchor = Anchor.center; anchor = Anchor.center;
} }
@override
Future<void> onLoad() async {
_activePaint = Paint()..color = _defaultColor;
}
@override @override
void update(double dt) { void update(double dt) {
super.update(dt); super.update(dt);
if (_isDragged) { if (_isDragged) {
return; return;
} }
if (!_isHit) {
debugColor = _defaultDebugColor;
} else {
_isHit = false;
}
delta.setFrom(velocity * dt); delta.setFrom(velocity * dt);
position.add(delta); position.add(delta);
angleDelta = dt * rotationSpeed; angleDelta = dt * rotationSpeed;
@ -63,6 +63,7 @@ abstract class MyCollidable extends PositionComponent
@override @override
void render(Canvas canvas) { void render(Canvas canvas) {
super.render(canvas); super.render(canvas);
renderShapes(canvas, paint: _activePaint);
if (_isDragged) { if (_isDragged) {
final localCenter = (size / 2).toOffset(); final localCenter = (size / 2).toOffset();
canvas.drawCircle(localCenter, 5, _activePaint); canvas.drawCircle(localCenter, 5, _activePaint);
@ -71,25 +72,34 @@ abstract class MyCollidable extends PositionComponent
@override @override
void onCollision(Set<Vector2> intersectionPoints, Collidable other) { 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) { switch (other.runtimeType) {
case ScreenCollidable: case ScreenCollidable:
debugColor = Colors.teal; return Colors.teal;
break;
case CollidablePolygon: case CollidablePolygon:
debugColor = Colors.blue; return Colors.deepOrange;
break;
case CollidableCircle: case CollidableCircle:
debugColor = Colors.green; return Colors.green;
break;
case CollidableRectangle: case CollidableRectangle:
debugColor = Colors.cyan; return Colors.cyan;
break;
case CollidableSnowman: case CollidableSnowman:
debugColor = Colors.amber; return Colors.amber;
break;
default: default:
debugColor = Colors.pink; return Colors.pink;
} }
} }
@ -152,20 +162,18 @@ class CollidableCircle extends MyCollidable {
} }
class SnowmanPart extends HitboxCircle { class SnowmanPart extends HitboxCircle {
static const startColor = Colors.white; final startColor = Colors.blue.withOpacity(0.8);
final hitPaint = Paint() final hitPaint = Paint();
..color = startColor
..strokeWidth = 1
..style = PaintingStyle.stroke;
SnowmanPart(double definition, Vector2 relativeOffset, Color hitColor) SnowmanPart(double definition, Vector2 relativeOffset, Color hitColor)
: super(definition: definition) { : super(definition: definition) {
this.relativeOffset.setFrom(relativeOffset); this.relativeOffset.setFrom(relativeOffset);
hitPaint..color = startColor;
onCollision = (Set<Vector2> intersectionPoints, HitboxShape other) { onCollision = (Set<Vector2> intersectionPoints, HitboxShape other) {
if (other.component is ScreenCollidable) { if (other.component is ScreenCollidable) {
hitPaint..color = startColor; hitPaint..color = startColor;
} else { } else {
hitPaint..color = hitColor; hitPaint.color = hitColor.withOpacity(0.8);
} }
}; };
} }
@ -196,9 +204,6 @@ class CollidableSnowman extends MyCollidable {
class MultipleShapes extends BaseGame class MultipleShapes extends BaseGame
with HasCollidables, HasDraggableComponents { with HasCollidables, HasDraggableComponents {
@override
bool debugMode = true;
final TextPaint fpsTextPaint = TextPaint( final TextPaint fpsTextPaint = TextPaint(
config: TextPaintConfig( config: TextPaintConfig(
color: BasicPalette.white.color, color: BasicPalette.white.color,
@ -219,7 +224,7 @@ class MultipleShapes extends BaseGame
add(screenCollidable); add(screenCollidable);
add(snowman); add(snowman);
var totalAdded = 1; var totalAdded = 1;
while (totalAdded < 10) { while (totalAdded < 20) {
lastToAdd = createRandomCollidable(lastToAdd, screenCollidable); lastToAdd = createRandomCollidable(lastToAdd, screenCollidable);
final lastBottomRight = final lastBottomRight =
lastToAdd.toAbsoluteRect().bottomRight.toVector2(); lastToAdd.toAbsoluteRect().bottomRight.toVector2();

View File

@ -18,7 +18,7 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
dart_code_metrics: ^3.1.0 dart_code_metrics: ^3.2.2
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@ -4,6 +4,7 @@
- Replace deprecated analysis option lines-of-executable-code with source-lines-of-code - Replace deprecated analysis option lines-of-executable-code with source-lines-of-code
- Fix the anchor of SpriteWidget - Fix the anchor of SpriteWidget
- Add test for re-adding previously removed component - 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] ## [1.0.0-rc10]
- Updated tutorial documentation to indicate use of new version - Updated tutorial documentation to indicate use of new version

View File

@ -18,6 +18,7 @@ mixin Collidable on Hitbox {
CollidableType collidableType = CollidableType.active; CollidableType collidableType = CollidableType.active;
void onCollision(Set<Vector2> intersectionPoints, Collidable other) {} void onCollision(Set<Vector2> intersectionPoints, Collidable other) {}
void onCollisionEnd(Collidable other) {}
} }
class ScreenCollidable extends PositionComponent class ScreenCollidable extends PositionComponent

View File

@ -1,4 +1,5 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:ui';
import '../../../extensions.dart'; import '../../../extensions.dart';
import '../../geometry/shape.dart'; import '../../geometry/shape.dart';
@ -26,8 +27,8 @@ mixin Hitbox on PositionComponent {
_shapes.any((shape) => shape.containsPoint(point)); _shapes.any((shape) => shape.containsPoint(point));
} }
void renderShapes(Canvas canvas) { void renderShapes(Canvas canvas, {Paint? paint}) {
_shapes.forEach((shape) => shape.render(canvas, debugPaint)); _shapes.forEach((shape) => shape.render(canvas, paint ?? debugPaint));
} }
/// Since this is a cheaper calculation than checking towards all shapes, this /// Since this is a cheaper calculation than checking towards all shapes, this

View File

@ -1,6 +1,10 @@
import '../../extensions.dart'; import '../../extensions.dart';
import '../../geometry.dart';
import '../components/mixins/collidable.dart'; import '../components/mixins/collidable.dart';
final Set<int> _collidableHashes = {};
final Set<int> _shapeHashes = {};
int _collidableTypeCompare(Collidable a, Collidable b) { int _collidableTypeCompare(Collidable a, Collidable b) {
return a.collidableType.index - b.collidableType.index; return a.collidableType.index - b.collidableType.index;
} }
@ -25,11 +29,43 @@ void collisionDetection(List<Collidable> collidables) {
if (intersectionPoints.isNotEmpty) { if (intersectionPoints.isNotEmpty) {
collidableX.onCollision(intersectionPoints, collidableY); collidableX.onCollision(intersectionPoints, collidableY);
collidableY.onCollision(intersectionPoints, collidableX); 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 /// Check what the intersection points of two collidables are
/// returns an empty list if there are no intersections /// returns an empty list if there are no intersections
Set<Vector2> intersections( Set<Vector2> intersections(
@ -38,6 +74,13 @@ Set<Vector2> intersections(
) { ) {
if (!collidableA.possiblyOverlapping(collidableB)) { if (!collidableA.possiblyOverlapping(collidableB)) {
// These collidables can't have any intersection points // 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 {}; return {};
} }
@ -52,8 +95,15 @@ Set<Vector2> intersections(
shapeA.onCollision(currentResult, shapeB); shapeA.onCollision(currentResult, shapeB);
shapeB.onCollision(currentResult, shapeA); shapeB.onCollision(currentResult, shapeA);
currentResult.clear(); currentResult.clear();
_shapeHashes.add(_combinedHash(shapeA, shapeB));
} else {
_handleShapeCollisionEnd(shapeA, shapeB);
} }
} }
} }
return result; return result;
} }
int _combinedHash(Object o1, Object o2) {
return o1.hashCode ^ o2.hashCode;
}

View File

@ -126,6 +126,10 @@ mixin HitboxShape on Shape {
/// Assign your own [CollisionCallback] if you want a callback when this /// Assign your own [CollisionCallback] if you want a callback when this
/// shape collides with another [HitboxShape] /// shape collides with another [HitboxShape]
CollisionCallback onCollision = emptyCollisionCallback; 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( typedef CollisionCallback = void Function(
@ -133,7 +137,10 @@ typedef CollisionCallback = void Function(
HitboxShape other, HitboxShape other,
); );
typedef CollisionEndCallback = void Function(HitboxShape other);
void emptyCollisionCallback(Set<Vector2> _, HitboxShape __) {} void emptyCollisionCallback(Set<Vector2> _, HitboxShape __) {}
void emptyCollisionEndCallback(HitboxShape _) {}
/// Used for caching calculated shapes, the cache is determined to be valid by /// 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 /// comparing a list of values that can be of any type and is compared to the

View File

@ -14,7 +14,7 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
test: ^1.16.0 test: ^1.16.0
dart_code_metrics: ^3.1.0 dart_code_metrics: ^3.2.2
dartdoc: ^0.42.0 dartdoc: ^0.42.0
environment: environment:

View 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);
});
},
);
}