diff --git a/packages/flame_behaviors/lib/src/behaviors/behaviors.dart b/packages/flame_behaviors/lib/src/behaviors/behaviors.dart index 5b4eaea8a38..0376e8474cf 100644 --- a/packages/flame_behaviors/lib/src/behaviors/behaviors.dart +++ b/packages/flame_behaviors/lib/src/behaviors/behaviors.dart @@ -1,3 +1,4 @@ export 'behavior.dart'; export 'events/events.dart'; export 'propagating_collision_behavior.dart'; +export 'screen_collision_behavior.dart'; diff --git a/packages/flame_behaviors/lib/src/behaviors/screen_collision_behavior.dart b/packages/flame_behaviors/lib/src/behaviors/screen_collision_behavior.dart new file mode 100644 index 00000000000..39c82159cdf --- /dev/null +++ b/packages/flame_behaviors/lib/src/behaviors/screen_collision_behavior.dart @@ -0,0 +1,35 @@ +import 'package:flame/components.dart'; +import 'package:flame_behaviors/flame_behaviors.dart'; + +/// {@template screen_collision_behavior} +/// A [CollisionBehavior] that fires only when the [Parent] entity collides +/// with a [ScreenHitbox]. +/// +/// Pins the `Collider` type parameter of [CollisionBehavior] to +/// [ScreenHitbox] so subclasses only have to specify their parent entity +/// type. Override the standard [onCollision], [onCollisionStart], and +/// [onCollisionEnd] callbacks (now strongly typed to receive a +/// [ScreenHitbox]) to react to screen-edge interactions — for example to +/// clamp the entity's position, bounce off the edge, or wrap to the +/// opposite side using the [ScreenHitbox]'s `position` and `scaledSize`. +/// +/// ```dart +/// class WrapAroundScreen extends ScreenCollisionBehavior { +/// @override +/// void onCollisionEnd(ScreenHitbox screen) { +/// if (parent.position.x > screen.position.x + screen.scaledSize.x) { +/// parent.position.x = screen.position.x; +/// } +/// } +/// } +/// ``` +/// +/// Adding the behavior still requires the entity to host a +/// [PropagatingCollisionBehavior] and the game to register a +/// [ScreenHitbox] for the screen edges. +/// {@endtemplate} +abstract class ScreenCollisionBehavior + extends CollisionBehavior { + /// {@macro screen_collision_behavior} + ScreenCollisionBehavior({super.children, super.priority, super.key}); +} diff --git a/packages/flame_behaviors/test/src/behaviors/screen_collision_behavior_test.dart b/packages/flame_behaviors/test/src/behaviors/screen_collision_behavior_test.dart new file mode 100644 index 00000000000..b8992818fb9 --- /dev/null +++ b/packages/flame_behaviors/test/src/behaviors/screen_collision_behavior_test.dart @@ -0,0 +1,147 @@ +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_behaviors/flame_behaviors.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Entity extends PositionedEntity { + _Entity({super.behaviors, super.position}) + : super(size: Vector2.all(16), anchor: Anchor.center); +} + +class _TrackingScreenCollisionBehavior + extends ScreenCollisionBehavior<_Entity> { + bool startCalled = false; + bool collisionCalled = false; + bool endCalled = false; + ScreenHitbox? lastOther; + + @override + void onCollisionStart(Set intersectionPoints, ScreenHitbox other) { + super.onCollisionStart(intersectionPoints, other); + startCalled = true; + lastOther = other; + } + + @override + void onCollision(Set intersectionPoints, ScreenHitbox other) { + super.onCollision(intersectionPoints, other); + collisionCalled = true; + } + + @override + void onCollisionEnd(ScreenHitbox other) { + super.onCollisionEnd(other); + endCalled = true; + } +} + +class _TestGame extends FlameGame with HasCollisionDetection { + _TestGame() : super(children: [ScreenHitbox()]); +} + +void main() { + final flameTester = FlameTester(_TestGame.new); + + group('$ScreenCollisionBehavior', () { + flameTester.testGameWidget( + 'fires onCollisionStart when entity touches the screen edge, ' + 'with the ScreenHitbox passed through', + setUp: (game, tester) async { + await game.ready(); + final behavior = _TrackingScreenCollisionBehavior(); + final entity = _Entity( + behaviors: [ + PropagatingCollisionBehavior(RectangleHitbox()), + behavior, + ], + position: Vector2(0, game.size.y / 2), + ); + await game.ensureAdd(entity); + }, + verify: (game, tester) async { + final entity = game.firstChild<_Entity>()!; + final behavior = entity.firstChild<_TrackingScreenCollisionBehavior>()!; + + game.update(0); + + expect(behavior.startCalled, isTrue); + expect(behavior.collisionCalled, isTrue); + expect(behavior.lastOther, isA()); + }, + ); + + flameTester.testGameWidget( + 'fires onCollisionEnd when entity leaves the screen edge', + setUp: (game, tester) async { + await game.ready(); + final behavior = _TrackingScreenCollisionBehavior(); + final entity = _Entity( + behaviors: [ + PropagatingCollisionBehavior(RectangleHitbox()), + behavior, + ], + position: Vector2(0, game.size.y / 2), + ); + await game.ensureAdd(entity); + }, + verify: (game, tester) async { + final entity = game.firstChild<_Entity>()!; + final behavior = entity.firstChild<_TrackingScreenCollisionBehavior>()!; + + game.update(0); + expect(behavior.startCalled, isTrue); + expect(behavior.endCalled, isFalse); + + entity.position = game.size / 2; + game.update(0); + + expect(behavior.endCalled, isTrue); + }, + ); + + flameTester.testGameWidget( + 'does not fire when colliding with a non-screen hitbox', + setUp: (game, tester) async { + await game.ready(); + final behavior = _TrackingScreenCollisionBehavior(); + final entity = _Entity( + behaviors: [ + PropagatingCollisionBehavior(RectangleHitbox()), + behavior, + ], + position: game.size / 2, + ); + final other = _Entity( + behaviors: [PropagatingCollisionBehavior(RectangleHitbox())], + position: game.size / 2, + ); + await game.ensureAdd(entity); + await game.ensureAdd(other); + }, + verify: (game, tester) async { + final entity = game.firstChildWhere<_Entity>( + (e) => e.firstChild<_TrackingScreenCollisionBehavior>() != null, + )!; + final behavior = entity.firstChild<_TrackingScreenCollisionBehavior>()!; + + game.update(0); + + expect(behavior.startCalled, isFalse); + expect(behavior.collisionCalled, isFalse); + }, + ); + }); +} + +extension on FlameGame { + T? firstChildWhere(bool Function(T) test) { + for (final c in children.whereType()) { + if (test(c)) { + return c; + } + } + return null; + } +} diff --git a/packages/flame_steering_behaviors/example/lib/src/behaviors/behaviors.dart b/packages/flame_steering_behaviors/example/lib/src/behaviors/behaviors.dart index 7daeb35f5d9..134b3043ce2 100644 --- a/packages/flame_steering_behaviors/example/lib/src/behaviors/behaviors.dart +++ b/packages/flame_steering_behaviors/example/lib/src/behaviors/behaviors.dart @@ -1 +1 @@ -export 'screen_collision_behavior.dart'; +export 'screen_wrapping_behavior.dart'; diff --git a/packages/flame_steering_behaviors/example/lib/src/behaviors/screen_collision_behavior.dart b/packages/flame_steering_behaviors/example/lib/src/behaviors/screen_wrapping_behavior.dart similarity index 89% rename from packages/flame_steering_behaviors/example/lib/src/behaviors/screen_collision_behavior.dart rename to packages/flame_steering_behaviors/example/lib/src/behaviors/screen_wrapping_behavior.dart index 887cadf168d..355eaee5059 100644 --- a/packages/flame_steering_behaviors/example/lib/src/behaviors/screen_collision_behavior.dart +++ b/packages/flame_steering_behaviors/example/lib/src/behaviors/screen_wrapping_behavior.dart @@ -3,8 +3,7 @@ import 'package:flame_behaviors/flame_behaviors.dart'; /// Simplified "screen wrapping" behavior, while not perfect it does showcase /// the possibility of acting on collision with non-entities. -class ScreenCollisionBehavior - extends CollisionBehavior { +class ScreenWrappingBehavior extends ScreenCollisionBehavior { @override void onCollisionEnd(ScreenHitbox other) { if (parent.position.x < other.position.x) { diff --git a/packages/flame_steering_behaviors/example/lib/src/entities/dot/dot.dart b/packages/flame_steering_behaviors/example/lib/src/entities/dot/dot.dart index a0196e3cd6d..9e4aa11643c 100644 --- a/packages/flame_steering_behaviors/example/lib/src/entities/dot/dot.dart +++ b/packages/flame_steering_behaviors/example/lib/src/entities/dot/dot.dart @@ -21,7 +21,7 @@ class Dot extends PositionedEntity with Steerable { ], behaviors: [ PropagatingCollisionBehavior(CircleHitbox()), - ScreenCollisionBehavior(), + ScreenWrappingBehavior(), WanderBehavior( circleDistance: 3 * relativeValue, maximumAngle: 45 * degrees2Radians, diff --git a/packages/flame_steering_behaviors/example/test/src/behaviors/screen_collision_behavior_test.dart b/packages/flame_steering_behaviors/example/test/src/behaviors/screen_wrapping_behavior_test.dart similarity index 71% rename from packages/flame_steering_behaviors/example/test/src/behaviors/screen_collision_behavior_test.dart rename to packages/flame_steering_behaviors/example/test/src/behaviors/screen_wrapping_behavior_test.dart index 58976b51979..5f913fa911b 100644 --- a/packages/flame_steering_behaviors/example/test/src/behaviors/screen_collision_behavior_test.dart +++ b/packages/flame_steering_behaviors/example/test/src/behaviors/screen_wrapping_behavior_test.dart @@ -17,7 +17,7 @@ class _TestEntity extends PositionedEntity { void main() { final flameTester = FlameTester(TestGame.new); - group('ScreenCollisionBehavior', () { + group('ScreenWrappingBehavior', () { late ScreenHitbox screenHitbox; setUp(() { @@ -29,13 +29,13 @@ void main() { flameTester.testGameWidget( 'does not move the parent entity', setUp: (game, tester) async { - final screenCollisionBehavior = ScreenCollisionBehavior(); + final screenWrappingBehavior = ScreenWrappingBehavior(); final entity = _TestEntity(); - await entity.add(screenCollisionBehavior); + await entity.add(screenWrappingBehavior); await game.ensureAdd(entity); - screenCollisionBehavior.onCollisionEnd(screenHitbox); + screenWrappingBehavior.onCollisionEnd(screenHitbox); expect(entity.position, closeToVector(Vector2(0, 0))); }, ); @@ -43,13 +43,13 @@ void main() { flameTester.testGameWidget( 'moves parent entity from top to bottom', setUp: (game, tester) async { - final screenCollisionBehavior = ScreenCollisionBehavior(); + final screenWrappingBehavior = ScreenWrappingBehavior(); final entity = _TestEntity(position: Vector2(-25, 0)); - await entity.add(screenCollisionBehavior); + await entity.add(screenWrappingBehavior); await game.ensureAdd(entity); - screenCollisionBehavior.onCollisionEnd(screenHitbox); + screenWrappingBehavior.onCollisionEnd(screenHitbox); expect(entity.position, closeToVector(Vector2(200, 0))); }, ); @@ -57,13 +57,13 @@ void main() { flameTester.testGameWidget( 'moves parent entity from bottom to top', setUp: (game, tester) async { - final screenCollisionBehavior = ScreenCollisionBehavior(); + final screenWrappingBehavior = ScreenWrappingBehavior(); final entity = _TestEntity(position: Vector2(225, 0)); - await entity.add(screenCollisionBehavior); + await entity.add(screenWrappingBehavior); await game.ensureAdd(entity); - screenCollisionBehavior.onCollisionEnd(screenHitbox); + screenWrappingBehavior.onCollisionEnd(screenHitbox); expect(entity.position, closeToVector(Vector2(0, 0))); }, ); @@ -71,13 +71,13 @@ void main() { flameTester.testGameWidget( 'moves parent entity from left to right', setUp: (game, tester) async { - final screenCollisionBehavior = ScreenCollisionBehavior(); + final screenWrappingBehavior = ScreenWrappingBehavior(); final entity = _TestEntity(position: Vector2(0, -25)); - await entity.add(screenCollisionBehavior); + await entity.add(screenWrappingBehavior); await game.ensureAdd(entity); - screenCollisionBehavior.onCollisionEnd(screenHitbox); + screenWrappingBehavior.onCollisionEnd(screenHitbox); expect(entity.position, closeToVector(Vector2(0, 200))); }, ); @@ -85,13 +85,13 @@ void main() { flameTester.testGameWidget( 'moves parent entity from right to left', setUp: (game, tester) async { - final screenCollisionBehavior = ScreenCollisionBehavior(); + final screenWrappingBehavior = ScreenWrappingBehavior(); final entity = _TestEntity(position: Vector2(0, 225)); - await entity.add(screenCollisionBehavior); + await entity.add(screenWrappingBehavior); await game.ensureAdd(entity); - screenCollisionBehavior.onCollisionEnd(screenHitbox); + screenWrappingBehavior.onCollisionEnd(screenHitbox); expect(entity.position, closeToVector(Vector2(0, 0))); }, );