From ebe8318d0443c7c2c29a40405618afedcff7efa1 Mon Sep 17 00:00:00 2001 From: Lorenzo DZ Date: Sat, 2 May 2026 12:01:59 -0300 Subject: [PATCH 1/2] feat(flame_behaviors): Add ScreenCollisionBehavior Adds an abstract ScreenCollisionBehavior that pins the Collider generic of CollisionBehavior to ScreenHitbox and exposes screen- specific callbacks (onScreenCollision[Start|End]) that drop the redundant 'other' argument. This removes the boilerplate of writing CollisionBehavior and clarifies intent at call sites that only care about screen-edge interactions. Closes #3750 --- .../lib/src/behaviors/behaviors.dart | 1 + .../behaviors/screen_collision_behavior.dart | 57 +++++++ .../screen_collision_behavior_test.dart | 149 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 packages/flame_behaviors/lib/src/behaviors/screen_collision_behavior.dart create mode 100644 packages/flame_behaviors/test/src/behaviors/screen_collision_behavior_test.dart 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..18be094bf35 --- /dev/null +++ b/packages/flame_behaviors/lib/src/behaviors/screen_collision_behavior.dart @@ -0,0 +1,57 @@ +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]. +/// +/// This is a thin specialisation of [CollisionBehavior] that pins the +/// `Collider` type parameter to [ScreenHitbox] and exposes screen-specific +/// callbacks that drop the redundant `other` argument (which is always the +/// [ScreenHitbox]). +/// +/// Subclass it to react when an entity touches the bounds of the screen, +/// e.g. to clamp its position, bounce, or remove it from the world. +/// +/// ```dart +/// class BounceOffScreen extends ScreenCollisionBehavior { +/// @override +/// void onScreenCollisionStart(Set intersectionPoints) { +/// parent.velocity.negate(); +/// } +/// } +/// ``` +/// +/// Adding the behavior still requires the entity to host a +/// [PropagatingCollisionBehavior] and the game to use a [ScreenHitbox] for +/// the screen edges. +/// {@endtemplate} +abstract class ScreenCollisionBehavior + extends CollisionBehavior { + /// {@macro screen_collision_behavior} + ScreenCollisionBehavior({super.children, super.priority, super.key}); + + /// Called every tick the entity is overlapping the screen edge. + void onScreenCollision(Set intersectionPoints) {} + + /// Called the first frame the entity starts overlapping the screen edge. + void onScreenCollisionStart(Set intersectionPoints) {} + + /// Called the first frame the entity stops overlapping the screen edge. + void onScreenCollisionEnd() {} + + @override + void onCollision(Set intersectionPoints, ScreenHitbox other) { + onScreenCollision(intersectionPoints); + } + + @override + void onCollisionStart(Set intersectionPoints, ScreenHitbox other) { + onScreenCollisionStart(intersectionPoints); + } + + @override + void onCollisionEnd(ScreenHitbox other) { + onScreenCollisionEnd(); + } +} 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..7d57f4a40de --- /dev/null +++ b/packages/flame_behaviors/test/src/behaviors/screen_collision_behavior_test.dart @@ -0,0 +1,149 @@ +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; + Set lastStartPoints = const {}; + + @override + void onScreenCollisionStart(Set intersectionPoints) { + startCalled = true; + lastStartPoints = intersectionPoints; + } + + @override + void onScreenCollision(Set intersectionPoints) { + collisionCalled = true; + } + + @override + void onScreenCollisionEnd() { + endCalled = true; + } +} + +class _TestGame extends FlameGame with HasCollisionDetection { + _TestGame() : super(children: [ScreenHitbox()]); +} + +void main() { + final flameTester = FlameTester(_TestGame.new); + + group('$ScreenCollisionBehavior', () { + flameTester.testGameWidget( + 'fires onScreenCollisionStart when entity touches the screen edge', + setUp: (game, tester) async { + await game.ready(); + final behavior = _TrackingScreenCollisionBehavior(); + // Position the entity so it overlaps the left screen edge. + 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.lastStartPoints, isNotEmpty); + }, + ); + + flameTester.testGameWidget( + 'fires onScreenCollisionEnd 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); + + // Move the entity well inside the screen. + 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, + ); + // A second non-screen entity overlapping the first one. + 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; + } +} From 1d43ac4b90dd1c76e76fddfded93e33e094f0544 Mon Sep 17 00:00:00 2001 From: Lorenzo DZ Date: Sat, 2 May 2026 12:57:24 -0300 Subject: [PATCH 2/2] feat(flame_behaviors): Simplify ScreenCollisionBehavior, rename steering example class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review of #3910: - Drop the duplicate onScreenCollision*() callback layer. The new abstract class is now a pure type-fix that pins CollisionBehavior's Collider to ScreenHitbox; subclasses override onCollision/onCollisionStart/ onCollisionEnd directly and keep access to the ScreenHitbox (which carries the screen extent — needed e.g. for screen-wrapping logic). - Rename the local class in flame_steering_behaviors_example from ScreenCollisionBehavior to ScreenWrappingBehavior (its actual behaviour) so the example app no longer collides on import with the new public ScreenCollisionBehavior. Migrate it to extend the new abstract base to demonstrate the simplification. - Drop the British 'specialisation' word from the doc to satisfy the cspell dictionary on the spell_checker workflow. --- .../behaviors/screen_collision_behavior.dart | 50 ++++++------------- .../screen_collision_behavior_test.dart | 32 ++++++------ .../example/lib/src/behaviors/behaviors.dart | 2 +- ...ior.dart => screen_wrapping_behavior.dart} | 3 +- .../example/lib/src/entities/dot/dot.dart | 2 +- ...art => screen_wrapping_behavior_test.dart} | 32 ++++++------ 6 files changed, 48 insertions(+), 73 deletions(-) rename packages/flame_steering_behaviors/example/lib/src/behaviors/{screen_collision_behavior.dart => screen_wrapping_behavior.dart} (89%) rename packages/flame_steering_behaviors/example/test/src/behaviors/{screen_collision_behavior_test.dart => screen_wrapping_behavior_test.dart} (71%) diff --git a/packages/flame_behaviors/lib/src/behaviors/screen_collision_behavior.dart b/packages/flame_behaviors/lib/src/behaviors/screen_collision_behavior.dart index 18be094bf35..39c82159cdf 100644 --- a/packages/flame_behaviors/lib/src/behaviors/screen_collision_behavior.dart +++ b/packages/flame_behaviors/lib/src/behaviors/screen_collision_behavior.dart @@ -5,53 +5,31 @@ import 'package:flame_behaviors/flame_behaviors.dart'; /// A [CollisionBehavior] that fires only when the [Parent] entity collides /// with a [ScreenHitbox]. /// -/// This is a thin specialisation of [CollisionBehavior] that pins the -/// `Collider` type parameter to [ScreenHitbox] and exposes screen-specific -/// callbacks that drop the redundant `other` argument (which is always the -/// [ScreenHitbox]). -/// -/// Subclass it to react when an entity touches the bounds of the screen, -/// e.g. to clamp its position, bounce, or remove it from the world. +/// 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 BounceOffScreen extends ScreenCollisionBehavior { +/// class WrapAroundScreen extends ScreenCollisionBehavior { /// @override -/// void onScreenCollisionStart(Set intersectionPoints) { -/// parent.velocity.negate(); +/// 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 use a [ScreenHitbox] for -/// the screen edges. +/// [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}); - - /// Called every tick the entity is overlapping the screen edge. - void onScreenCollision(Set intersectionPoints) {} - - /// Called the first frame the entity starts overlapping the screen edge. - void onScreenCollisionStart(Set intersectionPoints) {} - - /// Called the first frame the entity stops overlapping the screen edge. - void onScreenCollisionEnd() {} - - @override - void onCollision(Set intersectionPoints, ScreenHitbox other) { - onScreenCollision(intersectionPoints); - } - - @override - void onCollisionStart(Set intersectionPoints, ScreenHitbox other) { - onScreenCollisionStart(intersectionPoints); - } - - @override - void onCollisionEnd(ScreenHitbox other) { - onScreenCollisionEnd(); - } } 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 index 7d57f4a40de..b8992818fb9 100644 --- a/packages/flame_behaviors/test/src/behaviors/screen_collision_behavior_test.dart +++ b/packages/flame_behaviors/test/src/behaviors/screen_collision_behavior_test.dart @@ -15,21 +15,24 @@ class _TrackingScreenCollisionBehavior bool startCalled = false; bool collisionCalled = false; bool endCalled = false; - Set lastStartPoints = const {}; + ScreenHitbox? lastOther; @override - void onScreenCollisionStart(Set intersectionPoints) { + void onCollisionStart(Set intersectionPoints, ScreenHitbox other) { + super.onCollisionStart(intersectionPoints, other); startCalled = true; - lastStartPoints = intersectionPoints; + lastOther = other; } @override - void onScreenCollision(Set intersectionPoints) { + void onCollision(Set intersectionPoints, ScreenHitbox other) { + super.onCollision(intersectionPoints, other); collisionCalled = true; } @override - void onScreenCollisionEnd() { + void onCollisionEnd(ScreenHitbox other) { + super.onCollisionEnd(other); endCalled = true; } } @@ -43,11 +46,11 @@ void main() { group('$ScreenCollisionBehavior', () { flameTester.testGameWidget( - 'fires onScreenCollisionStart when entity touches the screen edge', + 'fires onCollisionStart when entity touches the screen edge, ' + 'with the ScreenHitbox passed through', setUp: (game, tester) async { await game.ready(); final behavior = _TrackingScreenCollisionBehavior(); - // Position the entity so it overlaps the left screen edge. final entity = _Entity( behaviors: [ PropagatingCollisionBehavior(RectangleHitbox()), @@ -59,19 +62,18 @@ void main() { }, verify: (game, tester) async { final entity = game.firstChild<_Entity>()!; - final behavior = entity - .firstChild<_TrackingScreenCollisionBehavior>()!; + final behavior = entity.firstChild<_TrackingScreenCollisionBehavior>()!; game.update(0); expect(behavior.startCalled, isTrue); expect(behavior.collisionCalled, isTrue); - expect(behavior.lastStartPoints, isNotEmpty); + expect(behavior.lastOther, isA()); }, ); flameTester.testGameWidget( - 'fires onScreenCollisionEnd when entity leaves the screen edge', + 'fires onCollisionEnd when entity leaves the screen edge', setUp: (game, tester) async { await game.ready(); final behavior = _TrackingScreenCollisionBehavior(); @@ -86,14 +88,12 @@ void main() { }, verify: (game, tester) async { final entity = game.firstChild<_Entity>()!; - final behavior = entity - .firstChild<_TrackingScreenCollisionBehavior>()!; + final behavior = entity.firstChild<_TrackingScreenCollisionBehavior>()!; game.update(0); expect(behavior.startCalled, isTrue); expect(behavior.endCalled, isFalse); - // Move the entity well inside the screen. entity.position = game.size / 2; game.update(0); @@ -113,7 +113,6 @@ void main() { ], position: game.size / 2, ); - // A second non-screen entity overlapping the first one. final other = _Entity( behaviors: [PropagatingCollisionBehavior(RectangleHitbox())], position: game.size / 2, @@ -125,8 +124,7 @@ void main() { final entity = game.firstChildWhere<_Entity>( (e) => e.firstChild<_TrackingScreenCollisionBehavior>() != null, )!; - final behavior = entity - .firstChild<_TrackingScreenCollisionBehavior>()!; + final behavior = entity.firstChild<_TrackingScreenCollisionBehavior>()!; game.update(0); 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))); }, );