From e4281d64714eeb59e703d41ae0e51d478eb4cfdf Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 18 May 2021 16:21:49 +0200 Subject: [PATCH] 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 * Update examples/lib/stories/collision_detection/multiple_shapes.dart Co-authored-by: Erick * 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 Co-authored-by: Erick Co-authored-by: Luan Nico --- doc/collision_detection.md | 12 +- .../collision_detection.dart | 14 ++ .../collision_detection/multiple_shapes.dart | 65 +++++---- examples/pubspec.yaml | 2 +- packages/flame/CHANGELOG.md | 1 + .../lib/src/components/mixins/collidable.dart | 1 + .../lib/src/components/mixins/hitbox.dart | 5 +- .../lib/src/geometry/collision_detection.dart | 50 +++++++ packages/flame/lib/src/geometry/shape.dart | 7 + packages/flame/pubspec.yaml | 2 +- .../components/collision_callback_test.dart | 129 ++++++++++++++++++ 11 files changed, 253 insertions(+), 35 deletions(-) create mode 100644 packages/flame/test/components/collision_callback_test.dart diff --git a/doc/collision_detection.md b/doc/collision_detection.md index 86cb603f5..a2cc154e2 100644 --- a/doc/collision_detection.md +++ b/doc/collision_detection.md @@ -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` diff --git a/examples/lib/stories/collision_detection/collision_detection.dart b/examples/lib/stories/collision_detection/collision_detection.dart index d8f45d3fe..1e247deed 100644 --- a/examples/lib/stories/collision_detection/collision_detection.dart +++ b/examples/lib/stories/collision_detection/collision_detection.dart @@ -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', diff --git a/examples/lib/stories/collision_detection/multiple_shapes.dart b/examples/lib/stories/collision_detection/multiple_shapes.dart index 1c49c2f44..36e257647 100644 --- a/examples/lib/stories/collision_detection/multiple_shapes.dart +++ b/examples/lib/stories/collision_detection/multiple_shapes.dart @@ -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 _activeCollisions = {}; final ScreenCollidable screenCollidable; MyCollidable( @@ -34,17 +34,17 @@ abstract class MyCollidable extends PositionComponent anchor = Anchor.center; } + @override + Future 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 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 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(); diff --git a/examples/pubspec.yaml b/examples/pubspec.yaml index 5be108a9b..15369e2eb 100644 --- a/examples/pubspec.yaml +++ b/examples/pubspec.yaml @@ -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 diff --git a/packages/flame/CHANGELOG.md b/packages/flame/CHANGELOG.md index 03eee6b63..075cd0eb4 100644 --- a/packages/flame/CHANGELOG.md +++ b/packages/flame/CHANGELOG.md @@ -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 diff --git a/packages/flame/lib/src/components/mixins/collidable.dart b/packages/flame/lib/src/components/mixins/collidable.dart index 9f6b2f454..cc27857d4 100644 --- a/packages/flame/lib/src/components/mixins/collidable.dart +++ b/packages/flame/lib/src/components/mixins/collidable.dart @@ -18,6 +18,7 @@ mixin Collidable on Hitbox { CollidableType collidableType = CollidableType.active; void onCollision(Set intersectionPoints, Collidable other) {} + void onCollisionEnd(Collidable other) {} } class ScreenCollidable extends PositionComponent diff --git a/packages/flame/lib/src/components/mixins/hitbox.dart b/packages/flame/lib/src/components/mixins/hitbox.dart index c5ef3dc9b..deebc7325 100644 --- a/packages/flame/lib/src/components/mixins/hitbox.dart +++ b/packages/flame/lib/src/components/mixins/hitbox.dart @@ -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 diff --git a/packages/flame/lib/src/geometry/collision_detection.dart b/packages/flame/lib/src/geometry/collision_detection.dart index b55497d01..2628d81c4 100644 --- a/packages/flame/lib/src/geometry/collision_detection.dart +++ b/packages/flame/lib/src/geometry/collision_detection.dart @@ -1,6 +1,10 @@ import '../../extensions.dart'; +import '../../geometry.dart'; import '../components/mixins/collidable.dart'; +final Set _collidableHashes = {}; +final Set _shapeHashes = {}; + int _collidableTypeCompare(Collidable a, Collidable b) { return a.collidableType.index - b.collidableType.index; } @@ -25,11 +29,43 @@ void collisionDetection(List 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 intersections( @@ -38,6 +74,13 @@ Set 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 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; +} diff --git a/packages/flame/lib/src/geometry/shape.dart b/packages/flame/lib/src/geometry/shape.dart index ba60f1239..827d193e1 100644 --- a/packages/flame/lib/src/geometry/shape.dart +++ b/packages/flame/lib/src/geometry/shape.dart @@ -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 _, 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 diff --git a/packages/flame/pubspec.yaml b/packages/flame/pubspec.yaml index 00d26b415..cf84243ee 100644 --- a/packages/flame/pubspec.yaml +++ b/packages/flame/pubspec.yaml @@ -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: diff --git a/packages/flame/test/components/collision_callback_test.dart b/packages/flame/test/components/collision_callback_test.dart new file mode 100644 index 000000000..fb1fe6ce9 --- /dev/null +++ b/packages/flame/test/components/collision_callback_test.dart @@ -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 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 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 intersectionPoints, Collidable other) { + collisions.add(other); + } + + @override + void onCollisionEnd(Collidable other) { + endCounter++; + collisions.remove(other); + } +} + +void main() { + TestGame gameWithCollidables(List 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); + }); + }, + ); +}