Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/flame_behaviors/lib/src/behaviors/behaviors.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export 'behavior.dart';
export 'events/events.dart';
export 'propagating_collision_behavior.dart';
export 'screen_collision_behavior.dart';
Original file line number Diff line number Diff line change
@@ -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<MyEntity> {
/// @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<Parent extends EntityMixin>
extends CollisionBehavior<ScreenHitbox, Parent> {
/// {@macro screen_collision_behavior}
ScreenCollisionBehavior({super.children, super.priority, super.key});
}
Original file line number Diff line number Diff line change
@@ -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<Vector2> intersectionPoints, ScreenHitbox other) {
super.onCollisionStart(intersectionPoints, other);
startCalled = true;
lastOther = other;
}

@override
void onCollision(Set<Vector2> 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<ScreenHitbox>());
},
);

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<T extends Component>(bool Function(T) test) {
for (final c in children.whereType<T>()) {
if (test(c)) {
return c;
}
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export 'screen_collision_behavior.dart';
export 'screen_wrapping_behavior.dart';
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScreenHitbox, PositionedEntity> {
class ScreenWrappingBehavior extends ScreenCollisionBehavior<PositionedEntity> {
@override
void onCollisionEnd(ScreenHitbox other) {
if (parent.position.x < other.position.x) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class Dot extends PositionedEntity with Steerable {
],
behaviors: [
PropagatingCollisionBehavior(CircleHitbox()),
ScreenCollisionBehavior(),
ScreenWrappingBehavior(),
WanderBehavior(
circleDistance: 3 * relativeValue,
maximumAngle: 45 * degrees2Radians,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class _TestEntity extends PositionedEntity {
void main() {
final flameTester = FlameTester(TestGame.new);

group('ScreenCollisionBehavior', () {
group('ScreenWrappingBehavior', () {
late ScreenHitbox screenHitbox;

setUp(() {
Expand All @@ -29,69 +29,69 @@ 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)));
},
);

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

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

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

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)));
},
);
Expand Down
Loading