feat!: Raycasting and raytracing (#1785)

This PR implements raytracing and raycasting for the built-in hitboxes.

If you pass in your own collision detection system to the HasCollisionDetection mixin you have to change the signature of that to: CollisionDetection<ShapeHitbox>instead of CollisionDetection<Hitbox>.
This commit is contained in:
Lukas Klingsbo
2022-08-19 22:44:18 +02:00
committed by GitHub
parent e2de70c98a
commit ed452dd172
34 changed files with 1896 additions and 160 deletions

View File

@ -223,6 +223,141 @@ class MyGame extends FlameGame with HasCollisionDetection {
```
## Ray casting and Ray tracing
Ray casting and ray tracing are methods for sending out rays from a point in your game and
being able to see what these rays collide with and how they reflect after hitting
something.
### Ray casting
Ray casting is the operation of casting out one or more rays from a point and see if they hit
anything, in Flame's case, hitboxes.
We provide two methods for doing so, `raycast` and `raycastAll`. The first one just casts out
a single ray and gets back a result with information about what and where the ray hit, and some
extra information like the distance, the normal and the reflection ray. The second one, `raycastAll`,
works similarly but sends out multiple rays uniformly around the origin, or within an angle
centered at the origin.
To use the ray casting functionality you have to have the `HasCollisionDetection` mixin on your
game. After you have added that you can call `collisionDetection.raycast(...)` on your game class.
Example:
```dart
class MyGame extends FlameGame with HasCollisionDetection {
@override
void update(double dt) {
super.update(dt);
final ray = Ray2(
origin: Vector2(0, 100),
direction: Vector2(1, 0),
);
final result = collisionDetection.raycast(ray);
}
}
```
In this example one can see that the `Ray2` class is being used, this class defines a ray from an
origin position and a direction (which are both defined by `Vector2`s). This particular ray starts
from `0, 100` and shoots a ray straight to the right.
The result from this operation will either be `null` if the ray didn't hit anything, or a
`RaycastResult` which contains:
- Which hitbox the ray hit
- The intersection point of the collision
- The reflection ray, i.e. how the ray would reflect on the hitbox that it hix
- The normal of the collision, i.e. a vector perpendicular to the face of the hitbox that it hits
If you are concerned about performance you can pre create a `RaycastResult` object that you send in
to the method with the `out` argument, this will make it possible for the method to reuse this
object instead of creating a new one for each iteration. This can be good if you do a lot of
ray casting in your `update` methods.
#### raycastAll
Sometimes you want to send out rays in all, or a limited range, of directions from an origin. This
can have a lot of applications, for example you could calculate the field of view of a player or
enemy, or it can also be used to create light sources.
Example:
```dart
class MyGame extends FlameGame with HasCollisionDetection {
@override
void update(double dt) {
super.update(dt);
final origin = Vector2(200, 200);
final result = collisionDetection.raycastAll(
origin,
numberOfRays: 100,
);
}
}
```
In this example we would send out 100 rays from (200, 200) uniformingly spread in all directions.
If you want to limit the directions you can use the `startAngle` and the `sweepAngle` arguments.
Where the `startAngle` (counting from straight up) is where the rays will start and then the rays
will end at `startAngle + sweepAngle`.
If you are concerned about performance you can re-use the `RaycastResult` objects that are created
by the function by sending them in as a list with the `out` argument.
### Ray tracing
Ray tracing is similar to ray casting, but instead of just checking what the ray hits you can
continue to trace the ray and see what its reflection ray (the ray bouncing off the hitbox) will
hit and then what that casted reflection ray's reflection ray will hit and so on, until you decide
that you have traced the ray for long enough. If you imagine how a pool ball would bounce on a pool
table for example, that information could be retrieved with the help of ray tracing.
Example:
```dart
class MyGame extends FlameGame with HasCollisionDetection {
@override
void update(double dt) {
super.update(dt);
final ray = Ray2(
origin: Vector2(0, 100),
direction: Vector2(1, 1)..normalize()
);
final results = collisionDetection.raytrace(
ray,
maxDepth: 100,
);
for (final result in results) {
if (result.intersectionPoint.distanceTo(ray.origin) > 300) {
break;
}
}
}
}
```
In the example above we send out a ray from (0, 100) diagonally down to the right and we say that we
want it the bounce on at most 100 hitboxes, it doesn't necessarily have to get 100 results since at
some point one of the reflection rays might not hit a hitbox and then the method is done.
The method is lazy, which means that it will only do the calculations that you ask for, so you have
to loop through the iterable that it returns to get the results, or do `toList()` to directly
calculate all the results.
In the for-loop it can be seen how this can be used, in that loop we check whether the current
reflection rays intersection point (where the previous ray hit the hitbox) is further away than 300
pixels from the origin of the starting ray, and if it is we don't care about the rest of the results
(and then they don't have to be calculated either).
If you are concerned about performance you can re-use the `RaycastResult` objects that are created
by the function by sending them in as a list with the `out` argument.
## Comparison to Forge2D
If you want to have a full-blown physics engine in your game we recommend that you use

View File

@ -1,10 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
# This file should be version controlled.
version:
revision: f30b7f4db93ee747cd727df747941a28ead25ff5
channel: beta
revision: 85684f9300908116a78138ea4c6036c35c9a1236
channel: stable
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- platform: android
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- platform: ios
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- platform: linux
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- platform: macos
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- platform: web
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- platform: windows
create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@ -3,6 +3,9 @@ import 'package:examples/commons/commons.dart';
import 'package:examples/stories/collision_detection/circles_example.dart';
import 'package:examples/stories/collision_detection/collidable_animation_example.dart';
import 'package:examples/stories/collision_detection/multiple_shapes_example.dart';
import 'package:examples/stories/collision_detection/raycast_example.dart';
import 'package:examples/stories/collision_detection/raycast_light_example.dart';
import 'package:examples/stories/collision_detection/raytrace_example.dart';
import 'package:flame/game.dart';
void addCollisionDetectionStories(Dashbook dashbook) {
@ -25,5 +28,23 @@ void addCollisionDetectionStories(Dashbook dashbook) {
(_) => GameWidget(game: MultipleShapesExample()),
codeLink: baseLink('collision_detection/multiple_shapes_example.dart'),
info: MultipleShapesExample.description,
)
..add(
'Raycasting (light)',
(_) => GameWidget(game: RaycastLightExample()),
codeLink: baseLink('collision_detection/raycast_light_example.dart'),
info: RaycastLightExample.description,
)
..add(
'Raycasting',
(_) => GameWidget(game: RaycastExample()),
codeLink: baseLink('collision_detection/raycast_example.dart'),
info: RaycastExample.description,
)
..add(
'Raytracing',
(_) => GameWidget(game: RaytraceExample()),
codeLink: baseLink('collision_detection/raytrace.dart'),
info: RaytraceExample.description,
);
}

View File

@ -0,0 +1,137 @@
import 'dart:math';
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame/geometry.dart';
import 'package:flame/palette.dart';
import 'package:flutter/material.dart';
class RaycastExample extends FlameGame with HasCollisionDetection {
static const description = '''
In this example the raycast functionality is showcased. The circle moves around
and casts 10 rays and checks how far the nearest hitboxes are and naively moves
around trying not to hit them.
''';
Ray2? ray;
Ray2? reflection;
Vector2 origin = Vector2(250, 100);
Paint paint = Paint()..color = Colors.amber.withOpacity(0.6);
final speed = 100;
final inertia = 3.0;
final safetyDistance = 50;
final direction = Vector2(0, 1);
final velocity = Vector2.zero();
final random = Random();
static const numberOfRays = 10;
final List<Ray2> rays = [];
final List<RaycastResult<ShapeHitbox>> results = [];
late Path path;
@override
Future<void> onLoad() async {
final paint = BasicPalette.gray.paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
add(ScreenHitbox());
add(
CircleComponent(
position: Vector2(100, 100),
radius: 50,
paint: paint,
children: [CircleHitbox()],
),
);
add(
CircleComponent(
position: Vector2(150, 500),
radius: 50,
paint: paint,
children: [CircleHitbox()],
),
);
add(
RectangleComponent(
position: Vector2.all(300),
size: Vector2.all(100),
paint: paint,
children: [RectangleHitbox()],
),
);
add(
RectangleComponent(
position: Vector2.all(500),
size: Vector2(100, 200),
paint: paint,
children: [RectangleHitbox()],
),
);
add(
RectangleComponent(
position: Vector2(550, 200),
size: Vector2(200, 150),
paint: paint,
children: [RectangleHitbox()],
),
);
}
final _velocityModifier = Vector2.zero();
@override
void update(double dt) {
super.update(dt);
collisionDetection.raycastAll(
origin,
numberOfRays: numberOfRays,
rays: rays,
out: results,
);
velocity.scale(inertia);
for (final result in results) {
_velocityModifier
..setFrom(result.intersectionPoint!)
..sub(origin)
..normalize();
if (result.distance! < safetyDistance) {
_velocityModifier.negate();
} else if (random.nextDouble() < 0.2) {
velocity.add(_velocityModifier);
}
velocity.add(_velocityModifier);
}
velocity
..normalize()
..scale(speed * dt);
origin.add(velocity);
}
@override
void render(Canvas canvas) {
super.render(canvas);
renderResult(canvas, origin, results, paint);
}
void renderResult(
Canvas canvas,
Vector2 origin,
List<RaycastResult<ShapeHitbox>> results,
Paint paint,
) {
final originOffset = origin.toOffset();
for (final result in results) {
if (!result.isActive) {
continue;
}
final intersectionPoint = result.intersectionPoint!.toOffset();
canvas.drawLine(
originOffset,
intersectionPoint,
paint,
);
}
canvas.drawCircle(originOffset, 5, paint);
}
}

View File

@ -0,0 +1,163 @@
import 'dart:math';
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame/geometry.dart';
import 'package:flame/input.dart';
import 'package:flame/palette.dart';
import 'package:flutter/material.dart';
class RaycastLightExample extends FlameGame
with HasCollisionDetection, TapDetector, MouseMovementDetector {
static const description = '''
In this example the raycast functionality is showcased by using it as a light
source, if you move the mouse around the canvas the rays will be cast from its
location. You can also tap to create a permanent source of rays that wont move
with with mouse.
''';
Ray2? ray;
Ray2? reflection;
Vector2? origin;
Vector2? tapOrigin;
bool isOriginCasted = false;
bool isTapOriginCasted = false;
Paint paint = Paint();
Paint tapPaint = Paint();
final _colorTween = ColorTween(
begin: Colors.blue.withOpacity(0.2),
end: Colors.red.withOpacity(0.2),
);
static const numberOfRays = 2000;
final List<Ray2> rays = [];
final List<Ray2> tapRays = [];
final List<RaycastResult<ShapeHitbox>> results = [];
final List<RaycastResult<ShapeHitbox>> tapResults = [];
late Path path;
@override
Future<void> onLoad() async {
final paint = BasicPalette.gray.paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
add(ScreenHitbox());
add(
CircleComponent(
position: Vector2(100, 100),
radius: 50,
paint: paint,
children: [CircleHitbox()],
),
);
add(
CircleComponent(
position: Vector2(150, 500),
radius: 50,
paint: paint,
children: [CircleHitbox()],
),
);
add(
RectangleComponent(
position: Vector2.all(300),
size: Vector2.all(100),
paint: paint,
children: [RectangleHitbox()],
),
);
add(
RectangleComponent(
position: Vector2.all(500),
size: Vector2(100, 200),
paint: paint,
children: [RectangleHitbox()],
),
);
add(
RectangleComponent(
position: Vector2(550, 200),
size: Vector2(200, 150),
paint: paint,
children: [RectangleHitbox()],
),
);
}
@override
bool onTapDown(TapDownInfo info) {
super.onTapDown(info);
final origin = info.eventPosition.game;
isTapOriginCasted = origin == tapOrigin;
tapOrigin = origin;
return false;
}
@override
void onMouseMove(PointerHoverInfo info) {
final origin = info.eventPosition.game;
isOriginCasted = origin == this.origin;
this.origin = origin;
}
var _timePassed = 0.0;
@override
void update(double dt) {
super.update(dt);
_timePassed += dt;
paint.color = _colorTween.transform(0.5 + (sin(_timePassed) / 2))!;
tapPaint.color = _colorTween.transform(0.5 + (cos(_timePassed) / 2))!;
if (origin != null && !isOriginCasted) {
collisionDetection.raycastAll(
origin!,
numberOfRays: numberOfRays,
rays: rays,
out: results,
);
isOriginCasted = true;
}
if (tapOrigin != null && !isTapOriginCasted) {
collisionDetection.raycastAll(
tapOrigin!,
numberOfRays: numberOfRays,
rays: tapRays,
out: tapResults,
);
isTapOriginCasted = true;
}
}
@override
void render(Canvas canvas) {
super.render(canvas);
if (origin != null) {
renderResult(canvas, origin!, results, paint);
}
if (tapOrigin != null) {
renderResult(canvas, tapOrigin!, tapResults, tapPaint);
}
}
void renderResult(
Canvas canvas,
Vector2 origin,
List<RaycastResult<ShapeHitbox>> results,
Paint paint,
) {
final originOffset = origin.toOffset();
for (final result in results) {
if (!result.isActive) {
continue;
}
final intersectionPoint = result.intersectionPoint!.toOffset();
canvas.drawLine(
originOffset,
intersectionPoint,
paint,
);
}
}
}

View File

@ -0,0 +1,191 @@
import 'dart:math';
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flame/geometry.dart';
import 'package:flame/palette.dart';
import 'package:flutter/material.dart';
class RaytraceExample extends FlameGame
with
HasCollisionDetection,
TapDetector,
MouseMovementDetector,
TapDetector {
static const description = '''
In this example the raytrace functionality is showcased.
Click to start sending out a ray which will bounce around to visualize how it
works. If you move the mouse around the canvas, rays and their reflections will
be moved rendered and if you click again some more objects that the rays can
bounce on will appear.
''';
final _colorTween = ColorTween(
begin: Colors.amber.withOpacity(1.0),
end: Colors.lightBlueAccent.withOpacity(1.0),
);
final random = Random();
Ray2? ray;
Ray2? reflection;
Vector2? origin;
bool isOriginCasted = false;
Paint rayPaint = Paint();
final boxPaint = BasicPalette.gray.paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
final List<Ray2> rays = [];
final List<RaycastResult<ShapeHitbox>> results = [];
late Path path;
@override
Future<void> onLoad() async {
addAll([
ScreenHitbox(),
CircleComponent(
radius: min(camera.canvasSize.x, camera.canvasSize.y) / 2,
paint: boxPaint,
children: [CircleHitbox()],
),
]);
}
bool isClicked = false;
final extraChildren = <Component>[];
@override
void onTap() {
if (!isClicked) {
isClicked = true;
return;
}
_timePassed = 0;
if (extraChildren.isEmpty) {
addAll(
extraChildren
..addAll(
[
CircleComponent(
position: Vector2(100, 100),
radius: 50,
paint: boxPaint,
children: [CircleHitbox()],
),
CircleComponent(
position: Vector2(150, 500),
radius: 50,
paint: boxPaint,
anchor: Anchor.center,
children: [CircleHitbox()],
),
CircleComponent(
position: Vector2(150, 500),
radius: 150,
paint: boxPaint,
anchor: Anchor.center,
children: [CircleHitbox()],
),
RectangleComponent(
position: Vector2.all(300),
size: Vector2.all(100),
paint: boxPaint,
children: [RectangleHitbox()],
),
RectangleComponent(
position: Vector2.all(500),
size: Vector2(100, 200),
paint: boxPaint,
children: [RectangleHitbox()],
),
CircleComponent(
position: Vector2(650, 275),
radius: 50,
paint: boxPaint,
anchor: Anchor.center,
children: [CircleHitbox()],
),
RectangleComponent(
position: Vector2(550, 200),
size: Vector2(200, 150),
paint: boxPaint,
children: [RectangleHitbox()],
),
RectangleComponent(
position: Vector2(350, 30),
size: Vector2(200, 150),
paint: boxPaint,
angle: tau / 10,
children: [RectangleHitbox()],
),
],
),
);
} else {
removeAll(extraChildren);
extraChildren.clear();
}
}
@override
void onMouseMove(PointerHoverInfo info) {
final origin = info.eventPosition.game;
isOriginCasted = origin == this.origin;
this.origin = origin;
}
final Ray2 _ray = Ray2.zero();
var _timePassed = 0.0;
@override
void update(double dt) {
super.update(dt);
if (isClicked) {
_timePassed += dt;
}
rayPaint.color = _colorTween.transform(0.5 + (sin(_timePassed) / 2))!;
if (origin != null) {
_ray.origin.setFrom(origin!);
_ray.direction
..setValues(1, 1)
..normalize();
collisionDetection
.raytrace(
_ray,
maxDepth: min((_timePassed * 8).ceil(), 1000),
out: results,
)
.toList();
isOriginCasted = true;
}
}
@override
void render(Canvas canvas) {
super.render(canvas);
if (origin != null) {
renderResult(canvas, origin!, results, rayPaint);
}
}
void renderResult(
Canvas canvas,
Vector2 origin,
List<RaycastResult<ShapeHitbox>> results,
Paint paint,
) {
var originOffset = origin.toOffset();
for (final result in results) {
if (!result.isActive) {
continue;
}
final intersectionPoint = result.intersectionPoint!.toOffset();
canvas.drawLine(
originOffset,
intersectionPoint,
paint,
);
originOffset = intersectionPoint;
}
}
}

View File

@ -9,4 +9,6 @@ export 'src/collisions/hitboxes/polygon_hitbox.dart';
export 'src/collisions/hitboxes/rectangle_hitbox.dart';
export 'src/collisions/hitboxes/screen_hitbox.dart';
export 'src/collisions/hitboxes/shape_hitbox.dart';
export 'src/collisions/standard_collision_detection.dart';
export 'src/collisions/sweep.dart';
export 'src/experimental/raycast_result.dart';

View File

@ -1,5 +1,6 @@
export 'src/extensions/canvas.dart';
export 'src/extensions/color.dart';
export 'src/extensions/double.dart';
export 'src/extensions/image.dart';
export 'src/extensions/matrix4.dart';
export 'src/extensions/offset.dart';

View File

@ -1,7 +1,9 @@
export 'src/geometry/circle_component.dart';
export 'src/geometry/constants.dart';
export 'src/geometry/line.dart';
export 'src/geometry/line_segment.dart';
export 'src/geometry/polygon_component.dart';
export 'src/geometry/polygon_ray_intersection.dart';
export 'src/geometry/ray2.dart';
export 'src/geometry/rectangle_component.dart';
export 'src/geometry/shape_component.dart';

View File

@ -12,6 +12,11 @@ abstract class Broadphase<T extends Hitbox<T>> {
Broadphase({List<T>? items}) : items = items ?? [];
/// This method can be used if there are things that needs to be prepared in
/// each tick.
void update() {}
/// Returns the potential hitbox collisions
Set<CollisionProspect<T>> query();
}

View File

@ -1,5 +1,6 @@
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
/// [CollisionDetection] is the foundation of the collision detection system in
/// Flame.
@ -26,6 +27,7 @@ abstract class CollisionDetection<T extends Hitbox<T>> {
/// Run collision detection for the current state of [items].
void run() {
broadphase.update();
final potentials = broadphase.query();
potentials.forEach((tuple) {
final itemA = tuple.a;
@ -64,64 +66,49 @@ abstract class CollisionDetection<T extends Hitbox<T>> {
void handleCollisionStart(Set<Vector2> intersectionPoints, T itemA, T itemB);
void handleCollision(Set<Vector2> intersectionPoints, T itemA, T itemB);
void handleCollisionEnd(T itemA, T itemB);
}
/// The default implementation of [CollisionDetection].
/// Checks whether any [ShapeHitbox]s in [items] collide with each other and
/// calls their callback methods accordingly.
///
/// By default the [Sweep] broadphase is used, this can be configured by
/// passing in another [Broadphase] to the constructor.
class StandardCollisionDetection extends CollisionDetection<ShapeHitbox> {
StandardCollisionDetection({Broadphase<ShapeHitbox>? broadphase})
: super(broadphase: broadphase ?? Sweep<ShapeHitbox>());
/// Check what the intersection points of two collidables are,
/// returns an empty list if there are no intersections.
@override
Set<Vector2> intersections(
ShapeHitbox hitboxA,
ShapeHitbox hitboxB,
) {
return hitboxA.intersections(hitboxB);
}
/// Calls the two colliding hitboxes when they first starts to collide.
/// They are called with the [intersectionPoints] and instances of each other,
/// so that they can determine what hitbox (and what
/// [ShapeHitbox.hitboxParent] that they have collided with.
@override
void handleCollisionStart(
Set<Vector2> intersectionPoints,
ShapeHitbox hitboxA,
ShapeHitbox hitboxB,
) {
hitboxA.onCollisionStart(intersectionPoints, hitboxB);
hitboxB.onCollisionStart(intersectionPoints, hitboxA);
}
/// Calls the two colliding hitboxes every tick when they are colliding.
/// They are called with the [intersectionPoints] and instances of each other,
/// so that they can determine what hitbox (and what
/// [ShapeHitbox.hitboxParent] that they have collided with.
@override
void handleCollision(
Set<Vector2> intersectionPoints,
ShapeHitbox hitboxA,
ShapeHitbox hitboxB,
) {
hitboxA.onCollision(intersectionPoints, hitboxB);
hitboxB.onCollision(intersectionPoints, hitboxA);
}
/// Calls the two colliding hitboxes once when two hitboxes have stopped
/// colliding.
/// They are called with instances of each other, so that they can determine
/// what hitbox (and what [ShapeHitbox.hitboxParent] that they have stopped
/// colliding with.
@override
void handleCollisionEnd(ShapeHitbox hitboxA, ShapeHitbox hitboxB) {
hitboxA.onCollisionEnd(hitboxB);
hitboxB.onCollisionEnd(hitboxA);
}
/// Returns the first hitbox that the given [ray] hits and the associated
/// intersection information; or null if the ray doesn't hit any hitbox.
///
/// If [out] is provided that object will be modified and returned with the
/// result.
RaycastResult<T>? raycast(Ray2 ray, {RaycastResult<T>? out});
/// Casts rays uniformly between [startAngle] to [startAngle]+[sweepAngle]
/// from the given [origin] and returns all hitboxes and intersection points
/// the rays hit.
/// [numberOfRays] is the number of rays that should be casted.
///
/// If the [rays] argument is provided its [Ray2]s are populated with the rays
/// needed to perform the operation.
/// If there are less objects in [rays] than the operation requires, the
/// missing [Ray2] objects will be created and added to [rays].
///
/// If [out] is provided the [RaycastResult]s in that list be modified and
/// returned with the result. If there are less objects in [out] than the
/// result requires, the missing [RaycastResult] objects will be created.
List<RaycastResult<T>> raycastAll(
Vector2 origin, {
required int numberOfRays,
double startAngle = 0,
double sweepAngle = tau,
List<Ray2>? rays,
List<RaycastResult<T>>? out,
});
/// Follows the ray and its reflections until [maxDepth] is reached and then
/// returns all hitboxes, intersection points, normals and reflection rays
/// (bundled in a list of [RaycastResult]s) from where the ray hits.
///
/// [maxDepth] is how many times the ray should collide before returning a
/// result, defaults to 10.
///
/// If [out] is provided the [RaycastResult]s in that list be modified and
/// returned with the result. If there are less objects in [out] than the
/// result requires, the missing [RaycastResult] objects will be created.
Iterable<RaycastResult<T>> raytrace(
Ray2 ray, {
int maxDepth = 10,
List<RaycastResult<T>>? out,
});
}

View File

@ -4,10 +4,11 @@ import 'package:flame/game.dart';
/// Keeps track of all the [ShapeHitbox]s in the component tree and initiates
/// collision detection every tick.
mixin HasCollisionDetection on FlameGame {
CollisionDetection<Hitbox> _collisionDetection = StandardCollisionDetection();
CollisionDetection<Hitbox> get collisionDetection => _collisionDetection;
CollisionDetection<ShapeHitbox> _collisionDetection =
StandardCollisionDetection();
CollisionDetection<ShapeHitbox> get collisionDetection => _collisionDetection;
set collisionDetection(CollisionDetection<Hitbox> cd) {
set collisionDetection(CollisionDetection<ShapeHitbox> cd) {
cd.addAll(_collisionDetection.items);
_collisionDetection = cd;
}
@ -18,3 +19,26 @@ mixin HasCollisionDetection on FlameGame {
collisionDetection.run();
}
}
/// This mixin is useful if you have written your own collision detection which
/// isn't operating on [ShapeHitbox] since you can have any hitbox here.
///
/// Do note that [collisionDetection] has to be initialized before the game
/// starts the update loop for the collision detection to work.
mixin HasGenericCollisionDetection<T extends Hitbox<T>> on FlameGame {
CollisionDetection<T>? _collisionDetection;
CollisionDetection<T> get collisionDetection => _collisionDetection!;
set collisionDetection(CollisionDetection<T> cd) {
if (_collisionDetection != null) {
cd.addAll(_collisionDetection!.items);
}
_collisionDetection = cd;
}
@override
void update(double dt) {
super.update(dt);
_collisionDetection?.run();
}
}

View File

@ -2,6 +2,7 @@
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
/// A [Hitbox] in the shape of a circle.
class CircleHitbox extends CircleComponent with ShapeHitbox {
@ -32,4 +33,68 @@ class CircleHitbox extends CircleComponent with ShapeHitbox {
// There is no need to do anything here since the size already is bound to
// the parent size and the radius is defined from the shortest side.
}
late final _temporaryLineSegment = LineSegment.zero();
late final _temporaryNormal = Vector2.zero();
late final _temporaryCenter = Vector2.zero();
late final _temporaryAbsoluteCenter = Vector2.zero();
@override
RaycastResult<ShapeHitbox>? rayIntersection(
Ray2 ray, {
RaycastResult<ShapeHitbox>? out,
}) {
var isInsideHitbox = false;
_temporaryLineSegment.from.setFrom(ray.origin);
_temporaryAbsoluteCenter.setFrom(absoluteCenter);
_temporaryCenter
..setFrom(_temporaryAbsoluteCenter)
..sub(ray.origin);
_temporaryCenter.projection(ray.direction, out: _temporaryLineSegment.to);
_temporaryLineSegment.to
..x *= (ray.direction.x.sign * _temporaryLineSegment.to.x.sign)
..y *= (ray.direction.y.sign * _temporaryLineSegment.to.y.sign);
if (_temporaryLineSegment.to.length2 < radius * radius) {
_temporaryLineSegment.to.scaleTo(2 * radius);
isInsideHitbox = true;
}
_temporaryLineSegment.to.add(ray.origin);
final intersections = lineSegmentIntersections(_temporaryLineSegment).where(
(i) => i.distanceToSquared(ray.origin) > 0.0000001,
);
if (intersections.isEmpty) {
out?.reset();
return null;
} else {
final result = out ?? RaycastResult();
final intersectionPoint = intersections.first;
_temporaryNormal
..setFrom(intersectionPoint)
..sub(_temporaryAbsoluteCenter)
..normalize();
if (isInsideHitbox) {
_temporaryNormal.invert();
}
final reflectionDirection =
(out?.reflectionRay?.direction ?? Vector2.zero())
..setFrom(ray.direction)
..reflect(_temporaryNormal);
final reflectionRay = (out?.reflectionRay
?..setWith(
origin: intersectionPoint,
direction: reflectionDirection,
)) ??
Ray2(origin: intersectionPoint, direction: reflectionDirection);
result.setWith(
hitbox: this,
reflectionRay: reflectionRay,
normal: _temporaryNormal,
distance: ray.origin.distanceTo(intersectionPoint),
isInsideHitbox: isInsideHitbox,
);
return result;
}
}
}

View File

@ -1,10 +1,11 @@
// ignore_for_file: comment_references
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
/// A [Hitbox] in the shape of a polygon.
class PolygonHitbox extends PolygonComponent with ShapeHitbox {
class PolygonHitbox extends PolygonComponent
with ShapeHitbox, PolygonRayIntersection {
PolygonHitbox(
super.vertices, {
super.angle,

View File

@ -2,9 +2,11 @@
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/src/geometry/polygon_ray_intersection.dart';
/// A [Hitbox] in the shape of a rectangle (a simplified polygon).
class RectangleHitbox extends RectangleComponent with ShapeHitbox {
class RectangleHitbox extends RectangleComponent
with ShapeHitbox, PolygonRayIntersection<RectangleHitbox> {
@override
final bool shouldFillParent;

View File

@ -1,6 +1,7 @@
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame/geometry.dart';
import 'package:flame/src/geometry/shape_intersections.dart'
as intersection_system;
import 'package:meta/meta.dart';
@ -129,6 +130,15 @@ mixin ShapeHitbox on ShapeComponent implements Hitbox<ShapeHitbox> {
return collisionAllowed && aabb.intersectsWithAabb2(other.aabb);
}
/// Returns information about how the ray intersects the shape.
///
/// If you are only interested in the intersection point use
/// [RaycastResult.intersectionPoint] of the result.
RaycastResult<ShapeHitbox>? rayIntersection(
Ray2 ray, {
RaycastResult<ShapeHitbox>? out,
});
/// This determines how the shape should scale if it should try to fill its
/// parents boundaries.
void fillParent();

View File

@ -0,0 +1,155 @@
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
/// The default implementation of [CollisionDetection].
/// Checks whether any [ShapeHitbox]s in [items] collide with each other and
/// calls their callback methods accordingly.
///
/// By default the [Sweep] broadphase is used, this can be configured by
/// passing in another [Broadphase] to the constructor.
class StandardCollisionDetection extends CollisionDetection<ShapeHitbox> {
StandardCollisionDetection({Broadphase<ShapeHitbox>? broadphase})
: super(broadphase: broadphase ?? Sweep<ShapeHitbox>());
/// Check what the intersection points of two collidables are,
/// returns an empty list if there are no intersections.
@override
Set<Vector2> intersections(
ShapeHitbox hitboxA,
ShapeHitbox hitboxB,
) {
return hitboxA.intersections(hitboxB);
}
/// Calls the two colliding hitboxes when they first starts to collide.
/// They are called with the [intersectionPoints] and instances of each other,
/// so that they can determine what hitbox (and what
/// [ShapeHitbox.hitboxParent] that they have collided with.
@override
void handleCollisionStart(
Set<Vector2> intersectionPoints,
ShapeHitbox hitboxA,
ShapeHitbox hitboxB,
) {
hitboxA.onCollisionStart(intersectionPoints, hitboxB);
hitboxB.onCollisionStart(intersectionPoints, hitboxA);
}
/// Calls the two colliding hitboxes every tick when they are colliding.
/// They are called with the [intersectionPoints] and instances of each other,
/// so that they can determine what hitbox (and what
/// [ShapeHitbox.hitboxParent] that they have collided with.
@override
void handleCollision(
Set<Vector2> intersectionPoints,
ShapeHitbox hitboxA,
ShapeHitbox hitboxB,
) {
hitboxA.onCollision(intersectionPoints, hitboxB);
hitboxB.onCollision(intersectionPoints, hitboxA);
}
/// Calls the two colliding hitboxes once when two hitboxes have stopped
/// colliding.
/// They are called with instances of each other, so that they can determine
/// what hitbox (and what [ShapeHitbox.hitboxParent] that they have stopped
/// colliding with.
@override
void handleCollisionEnd(ShapeHitbox hitboxA, ShapeHitbox hitboxB) {
hitboxA.onCollisionEnd(hitboxB);
hitboxB.onCollisionEnd(hitboxA);
}
static final _temporaryRaycastResult = RaycastResult<ShapeHitbox>();
@override
RaycastResult<ShapeHitbox>? raycast(
Ray2 ray, {
RaycastResult<ShapeHitbox>? out,
}) {
var finalResult = out?..reset();
for (final item in items) {
final currentResult =
item.rayIntersection(ray, out: _temporaryRaycastResult);
final possiblyFirstResult = !(finalResult?.isActive ?? false);
if (currentResult != null &&
(possiblyFirstResult ||
currentResult.distance! < finalResult!.distance!)) {
if (finalResult == null) {
finalResult = currentResult.clone();
} else {
finalResult.setFrom(currentResult);
}
}
}
return (finalResult?.isActive ?? false) ? finalResult : null;
}
@override
List<RaycastResult<ShapeHitbox>> raycastAll(
Vector2 origin, {
required int numberOfRays,
double startAngle = 0,
double sweepAngle = tau,
List<Ray2>? rays,
List<RaycastResult<ShapeHitbox>>? out,
}) {
final isFullCircle = (sweepAngle % tau).abs() < 0.0001;
final angle = sweepAngle / (numberOfRays + (isFullCircle ? 0 : -1));
final results = <RaycastResult<ShapeHitbox>>[];
final direction = Vector2(1, 0);
for (var i = 0; i < numberOfRays; i++) {
Ray2 ray;
if (i < (rays?.length ?? 0)) {
ray = rays![i];
} else {
ray = Ray2.zero();
rays?.add(ray);
}
ray.origin.setFrom(origin);
direction
..setValues(0, -1)
..rotate(startAngle - angle * i);
ray.direction = direction;
RaycastResult<ShapeHitbox>? result;
if (i < (out?.length ?? 0)) {
result = out![i];
} else {
result = RaycastResult();
out?.add(result);
}
result = raycast(ray, out: result);
if (result != null) {
results.add(result);
}
}
return results;
}
@override
Iterable<RaycastResult<ShapeHitbox>> raytrace(
Ray2 ray, {
int maxDepth = 10,
List<RaycastResult<ShapeHitbox>>? out,
}) sync* {
out?.forEach((e) => e.reset());
var currentRay = ray;
for (var i = 0; i < maxDepth; i++) {
final hasResultObject = (out?.length ?? 0) > i;
final currentResult =
hasResultObject ? out![i] : RaycastResult<ShapeHitbox>();
if (raycast(currentRay, out: currentResult) != null) {
currentRay = currentResult.reflectionRay!;
if (!hasResultObject && out != null) {
out.add(currentResult);
}
yield currentResult;
} else {
break;
}
}
}
}

View File

@ -1,18 +1,20 @@
import 'package:flame/src/collisions/broadphase.dart';
import 'package:flame/src/collisions/collision_callbacks.dart';
import 'package:flame/src/collisions/hitboxes/hitbox.dart';
import 'package:flame/collisions.dart';
class Sweep<T extends Hitbox<T>> extends Broadphase<T> {
Sweep({super.items});
final List<T> _active = [];
final Set<CollisionProspect<T>> _potentials = {};
late final List<T> _active = [];
late final Set<CollisionProspect<T>> _potentials = {};
@override
void update() {
items.sort((a, b) => a.aabb.min.x.compareTo(b.aabb.min.x));
}
@override
Set<CollisionProspect<T>> query() {
_active.clear();
_potentials.clear();
items.sort((a, b) => (a.aabb.min.x - b.aabb.min.x).ceil());
for (final item in items) {
if (item.collisionType == CollisionType.inactive) {
continue;

View File

@ -1,9 +1,9 @@
import 'dart:ui';
import 'package:flame/geometry.dart';
import 'package:flame/src/experimental/geometry/shapes/shape.dart';
import 'package:flame/src/extensions/vector2.dart';
import 'package:flame/src/game/transform2d.dart';
import 'package:meta/meta.dart';
/// The circle with a given [center] and a [radius].
///
@ -85,6 +85,3 @@ class Circle extends Shape {
@override
String toString() => 'Circle([${_center.x}, ${_center.y}], $_radius)';
}
@internal
const tau = Transform2D.tau; // 2π

View File

@ -0,0 +1,91 @@
import 'package:flame/collisions.dart';
import 'package:flame/extensions.dart';
import 'package:flame/src/geometry/ray2.dart';
/// The result of a raycasting operation.
///
/// Note that the members of this class is heavily re-used. If you want to
/// keep the result in an object, clone the parts you want, or the whole
/// [RaycastResult] with [clone].
///
/// NOTE: This class might be subject to breaking changes in an upcoming
/// version, to make it possible to calculate the values lazily.
class RaycastResult<T extends Hitbox<T>> {
RaycastResult({
T? hitbox,
Ray2? reflectionRay,
Vector2? normal,
double? distance,
bool isInsideHitbox = false,
}) : _isInsideHitbox = isInsideHitbox,
_hitbox = hitbox,
_reflectionRay = reflectionRay ?? Ray2.zero(),
_normal = normal ?? Vector2.zero(),
_distance = distance ?? double.maxFinite;
/// Whether this result has active results in it.
///
/// This is used so that the objects in there can continue to live even when
/// there is no result from a ray cast.
bool get isActive => _hitbox != null;
/// Whether the origin of the ray was inside the hitbox.
bool get isInsideHitbox => _isInsideHitbox;
bool _isInsideHitbox;
T? _hitbox;
T? get hitbox => isActive ? _hitbox : null;
final Ray2 _reflectionRay;
Ray2? get reflectionRay => isActive ? _reflectionRay : null;
Vector2? get intersectionPoint => reflectionRay?.origin;
double _distance;
double? get distance => isActive ? _distance : null;
final Vector2 _normal;
Vector2? get normal => isActive ? _normal : null;
void reset() => _hitbox = null;
/// Sets this [RaycastResult]'s objects to the values stored in [other].
void setFrom(RaycastResult<T> other) {
setWith(
hitbox: other.hitbox,
reflectionRay: other.reflectionRay,
normal: other.normal,
distance: other.distance,
isInsideHitbox: other.isInsideHitbox,
);
}
/// Sets the values of the result from the specified arguments.
void setWith({
T? hitbox,
Ray2? reflectionRay,
Vector2? normal,
double? distance,
bool isInsideHitbox = false,
}) {
_hitbox = hitbox;
if (reflectionRay != null) {
_reflectionRay.setFrom(reflectionRay);
}
if (normal != null) {
_normal.setFrom(normal);
}
_distance = distance ?? double.maxFinite;
_isInsideHitbox = isInsideHitbox;
}
RaycastResult<T> clone() {
return RaycastResult(
hitbox: hitbox,
reflectionRay: _reflectionRay.clone(),
normal: _normal.clone(),
distance: distance,
isInsideHitbox: isInsideHitbox,
);
}
}

View File

@ -87,8 +87,10 @@ class CircleComponent extends ShapeComponent implements SizeProvider {
///
/// This can be an empty list (if they don't intersect), one point (if the
/// line is tangent) or two points (if the line is secant).
/// An edge point of the [lineSegment] that originates on the edge of the
/// circle doesn't count as an intersection.
List<Vector2> lineSegmentIntersections(
LineSegment line, {
LineSegment lineSegment, {
double epsilon = double.minPositive,
}) {
// A point on a line is `from + t*(to - from)`. We're trying to solve the
@ -97,18 +99,18 @@ class CircleComponent extends ShapeComponent implements SizeProvider {
// Expanding the norm, this becomes a square equation in `t`:
// `t²Δ₂₁² + 2tΔ₂₁Δ₁₀ + Δ₁₀² - radius² == 0`.
_delta21
..setFrom(line.to)
..sub(line.from); // to - from
..setFrom(lineSegment.to)
..sub(lineSegment.from); // to - from
_delta10
..setFrom(line.from)
..setFrom(lineSegment.from)
..sub(absoluteCenter); // from - absoluteCenter
final a = _delta21.length2;
final b = 2 * _delta21.dot(_delta10);
final c = _delta10.length2 - radius * radius;
return solveQuadratic(a, b, c)
.where((t) => t >= 0 && t <= 1)
.map((t) => line.from.clone()..addScaled(_delta21, t))
.where((t) => t > 0 && t <= 1)
.map((t) => lineSegment.from.clone()..addScaled(_delta21, t))
.toList();
}

View File

@ -0,0 +1,6 @@
import 'dart:math';
/// A simpler constant to use for angles than 2pi (well it is 2pi).
///
/// For example: tau/2 is 180 degrees, or pi radians.
const tau = pi * 2;

View File

@ -2,12 +2,11 @@ import 'dart:math';
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:flame/geometry.dart';
import 'package:flame/src/anchor.dart';
import 'package:flame/src/cache/value_cache.dart';
import 'package:flame/src/extensions/rect.dart';
import 'package:flame/src/extensions/vector2.dart';
import 'package:flame/src/geometry/line_segment.dart';
import 'package:flame/src/geometry/shape_component.dart';
import 'package:meta/meta.dart';
class PolygonComponent extends ShapeComponent {
@ -154,15 +153,13 @@ class PolygonComponent extends ShapeComponent {
scale,
angle,
])) {
var i = 0;
for (final vertex in vertices) {
vertices.forEachIndexed((i, vertex) {
_globalVertices[i]
..setFrom(vertex)
..multiply(scale)
..add(position)
..rotate(angle, center: position);
i++;
}
});
if (scale.y.isNegative || scale.x.isNegative) {
// Since the list will be clockwise we have to reverse it for it to
// become counterclockwise.

View File

@ -0,0 +1,78 @@
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
/// Used to add the [rayIntersection] method to [RectangleHitbox] and
/// [PolygonHitbox], used by the raytracing and raycasting methods.
mixin PolygonRayIntersection<T extends ShapeHitbox> on PolygonComponent {
late final _temporaryNormal = Vector2.zero();
/// Returns whether the [RaycastResult] if the [ray] intersects the polygon.
///
/// If [out] is defined that is used to populate with the result and then
/// returned, to minimize the creation of new objects.
RaycastResult<ShapeHitbox>? rayIntersection(
Ray2 ray, {
RaycastResult<ShapeHitbox>? out,
}) {
final vertices = globalVertices();
var closestDistance = double.infinity;
LineSegment? closestSegment;
var crossings = 0;
var isOverlappingPoint = false;
for (var i = 0; i < vertices.length; i++) {
final lineSegment = getEdge(i, vertices: vertices);
final distance = ray.lineSegmentIntersection(lineSegment);
// Using a small value above 0 just because of rounding errors later that
// might cause a ray to go in the wrong direction.
if (distance != null && distance > 0.0000000001) {
crossings++;
if (distance < closestDistance) {
isOverlappingPoint = false;
closestDistance = distance;
closestSegment = lineSegment;
} else if (distance == closestDistance) {
isOverlappingPoint = true;
}
}
}
if (crossings > 0) {
final intersectionPoint =
ray.point(closestDistance, out: out?.intersectionPoint);
// This is "from" to "to" since it is defined ccw in the canvas
// coordinate system
_temporaryNormal
..setFrom(closestSegment!.from)
..sub(closestSegment.to);
_temporaryNormal
..setValues(_temporaryNormal.y, -_temporaryNormal.x)
..normalize();
var isInsideHitbox = false;
if (crossings == 1 || isOverlappingPoint) {
_temporaryNormal.invert();
isInsideHitbox = true;
}
final reflectionDirection =
(out?.reflectionRay?.direction ?? Vector2.zero())
..setFrom(ray.direction)
..reflect(_temporaryNormal);
final reflectionRay = (out?.reflectionRay
?..setWith(
origin: intersectionPoint,
direction: reflectionDirection,
)) ??
Ray2(origin: intersectionPoint, direction: reflectionDirection);
return (out ?? RaycastResult<ShapeHitbox>())
..setWith(
hitbox: this as T,
reflectionRay: reflectionRay,
normal: _temporaryNormal,
distance: closestDistance,
isInsideHitbox: isInsideHitbox,
);
}
out?.reset();
return null;
}
}

View File

@ -1,29 +1,41 @@
import 'dart:math';
import 'package:flame/extensions.dart';
import 'package:flame/geometry.dart';
import 'package:flame/src/extensions/double.dart';
import 'package:meta/meta.dart';
import 'package:vector_math/vector_math_64.dart';
/// A ray in the 2d plane.
///
/// The [direction] should be normalized.
class Ray2 {
Ray2(this.origin, Vector2 direction) {
Ray2({required this.origin, required Vector2 direction}) {
this.direction = direction;
}
Ray2.zero() : this(Vector2.zero(), Vector2(1, 0));
Ray2.zero() : this(origin: Vector2.zero(), direction: Vector2(1, 0));
/// The point where the ray originates from.
Vector2 origin;
final Vector2 _direction = Vector2.zero();
/// The normalized direction of the ray.
///
/// The values within the direction object should not be updated manually, use
/// the setter instead.
Vector2 get direction => _direction;
set direction(Vector2 direction) {
_direction.setFrom(direction);
_updateInverses();
}
final Vector2 _direction = Vector2.zero();
/// Should be called if the [direction] values are updated within the object
/// instead of by the setter.
void _updateInverses() {
assert(
(direction.length2 - 1).abs() < 0.000001,
'direction must be normalized',
);
_direction.setFrom(direction);
directionInvX = (1 / direction.x).toFinite();
directionInvY = (1 / direction.y).toFinite();
}
@ -74,7 +86,9 @@ class Ray2 {
/// [LineSegment] or null if there is no intersection.
///
/// A ray that is parallel and overlapping with the [segment] is considered to
/// not intersect.
/// not intersect. This is due to that a single intersection point can't be
/// determined and that a [LineSegment] is almost always connected to another
/// line segment which will get the intersection on one of its ends instead.
double? lineSegmentIntersection(LineSegment segment) {
_v1
..setFrom(origin)
@ -95,7 +109,7 @@ class Ray2 {
/// Deep clones the object, i.e. both [origin] and [direction] are cloned into
/// a new [Ray2] object.
Ray2 clone() => Ray2(origin.clone(), direction.clone());
Ray2 clone() => Ray2(origin: origin.clone(), direction: direction.clone());
/// Sets the values by copying them from [other].
void setFrom(Ray2 other) {
@ -106,4 +120,7 @@ class Ray2 {
this.origin.setFrom(origin);
this.direction = direction;
}
@override
String toString() => 'Ray2(origin: $origin, direction: $direction)';
}

View File

@ -25,6 +25,7 @@ class PaletteEntry {
}
class BasicPalette {
static const PaletteEntry transparent = PaletteEntry(Color(0x00FFFFFF));
static const PaletteEntry white = PaletteEntry(Color(0xFFFFFFFF));
static const PaletteEntry black = PaletteEntry(Color(0xFF000000));
static const PaletteEntry red = PaletteEntry(Color(0xFFFF0000));

View File

@ -40,7 +40,7 @@ class _TestBlock extends PositionComponent with CollisionCallbacks {
void main() {
group('Benchmark collision detection', () {
testCollidableGame('collidable callbacks are called', (game) async {
testCollisionDetectionGame('collidable callbacks are called', (game) async {
final rng = Random(0);
final blocks = List.generate(
100,

View File

@ -7,7 +7,7 @@ import 'collision_test_helpers.dart';
void main() {
group('Collision callbacks', () {
testCollidableGame('collidable callbacks are called', (game) async {
testCollisionDetectionGame('collidable callbacks are called', (game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -48,7 +48,7 @@ void main() {
expect(blockB.endCounter, 1);
});
testCollidableGame(
testCollisionDetectionGame(
'collidable callbacks are called when removing a Collidable',
(game) async {
final blockA = TestBlock(
@ -76,7 +76,7 @@ void main() {
},
);
testCollidableGame('hitbox callbacks are called', (game) async {
testCollisionDetectionGame('hitbox callbacks are called', (game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -115,7 +115,7 @@ void main() {
});
});
testCollidableGame(
testCollisionDetectionGame(
'hitbox callbacks are called when Collidable is removed',
(game) async {
final blockA = TestBlock(
@ -145,7 +145,7 @@ void main() {
},
);
testCollidableGame(
testCollisionDetectionGame(
'hitbox end callbacks are called when hitbox is moved away fast',
(game) async {
final blockA = TestBlock(
@ -175,7 +175,7 @@ void main() {
},
);
testCollidableGame(
testCollisionDetectionGame(
'onCollisionEnd is only called when there previously was a collision',
(game) async {
final blockA = TestBlock(
@ -199,7 +199,7 @@ void main() {
},
);
testCollidableGame(
testCollisionDetectionGame(
'callbacks are only called once for hitboxes on each other',
(game) async {
final blockA = TestBlock(
@ -257,7 +257,7 @@ void main() {
},
);
testCollidableGame(
testCollisionDetectionGame(
'end and start callbacks are only called once for hitboxes sharing a side',
(game) async {
final blockA = TestBlock(
@ -316,7 +316,7 @@ void main() {
);
// Reproduced #1478
testCollidableGame(
testCollisionDetectionGame(
'collision callbacks with many hitboxes added',
(game) async {
const side = 10.0;
@ -379,7 +379,7 @@ void main() {
);
// Reproduced #1478
testCollidableGame(
testCollisionDetectionGame(
'collision callbacks with changed game size',
(game) async {
final block = TestBlock(Vector2.all(20), Vector2.all(10))

View File

@ -1,8 +1,12 @@
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
import 'package:flame/geometry.dart' as geometry;
import 'package:flame_test/flame_test.dart';
import 'package:test/test.dart';
import 'collision_test_helpers.dart';
void main() {
group('LineSegment.isPointOnSegment', () {
test('can catch simple point', () {
@ -675,4 +679,561 @@ void main() {
);
});
});
group('Raycasting', () {
testCollisionDetectionGame('one hitbox', (game) async {
game.ensureAdd(
PositionComponent(
children: [RectangleHitbox()],
position: Vector2(100, 0),
size: Vector2.all(100),
anchor: Anchor.center,
),
);
await game.ready();
final ray = Ray2(
origin: Vector2.zero(),
direction: Vector2(1, 0),
);
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.reflectionRay?.origin, closeToVector(Vector2(50, 0)));
expect(result?.reflectionRay?.direction, closeToVector(Vector2(-1, 0)));
});
testCollisionDetectionGame(
'multiple hitboxes after each other',
(game) async {
game.ensureAddAll([
for (var i = 0.0; i < 10; i++)
PositionComponent(
position: Vector2.all(100 + i * 10),
size: Vector2.all(20 - i),
anchor: Anchor.center,
)..add(RectangleHitbox()),
]);
await game.ready();
final ray = Ray2(
origin: Vector2.zero(),
direction: Vector2.all(1)..normalize(),
);
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.reflectionRay?.origin, closeToVector(Vector2.all(90)));
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2(-1, 1)..normalize()),
);
},
);
testCollisionDetectionGame(
'ray with origin on hitbox corner',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.all(10),
size: Vector2.all(10),
)..add(RectangleHitbox()),
]);
await game.ready();
final ray = Ray2(
origin: Vector2.all(10),
direction: Vector2.all(1)..normalize(),
);
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.reflectionRay?.origin, closeToVector(Vector2(20, 20)));
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2(1, -1)..normalize()),
);
},
);
group('Rectangle hitboxes', () {
testCollisionDetectionGame(
'ray from within RectangleHitbox',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.all(0),
size: Vector2.all(10),
)..add(RectangleHitbox()),
]);
await game.ready();
final ray = Ray2(
origin: Vector2.all(5),
direction: Vector2.all(1)..normalize(),
);
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.normal, closeToVector(Vector2(0, -1)));
expect(result?.reflectionRay?.origin, closeToVector(Vector2(10, 10)));
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2(1, -1)..normalize()),
);
},
);
testCollisionDetectionGame(
'ray from the left of RectangleHitbox',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.zero(),
size: Vector2.all(10),
)..add(RectangleHitbox()),
]);
await game.ready();
final ray = Ray2(origin: Vector2(-5, 5), direction: Vector2(1, 0));
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.reflectionRay?.origin, closeToVector(Vector2(0, 5)));
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2(-1, 0)),
);
},
);
testCollisionDetectionGame(
'ray from the top of RectangleHitbox',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.zero(),
size: Vector2.all(10),
)..add(RectangleHitbox()),
]);
await game.ready();
final ray = Ray2(origin: Vector2(5, -5), direction: Vector2(0, 1));
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.reflectionRay?.origin, closeToVector(Vector2(5, 0)));
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2(0, -1)),
);
},
);
testCollisionDetectionGame(
'ray from the right of RectangleHitbox',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.zero(),
size: Vector2.all(10),
)..add(RectangleHitbox()),
]);
await game.ready();
final ray = Ray2(origin: Vector2(15, 5), direction: Vector2(-1, 0));
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.reflectionRay?.origin, closeToVector(Vector2(10, 5)));
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2(1, 0)),
);
},
);
testCollisionDetectionGame(
'ray from the bottom of RectangleHitbox',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.zero(),
size: Vector2.all(10),
)..add(RectangleHitbox()),
]);
await game.ready();
final ray = Ray2(origin: Vector2(5, 15), direction: Vector2(0, -1));
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.reflectionRay?.origin, closeToVector(Vector2(5, 10)));
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2(0, 1)),
);
},
);
});
group('Circle hitboxes', () {
testCollisionDetectionGame(
'ray from top to bottom within CircleHitbox',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.zero(),
size: Vector2.all(10),
)..add(CircleHitbox()),
]);
await game.ready();
final ray = Ray2(origin: Vector2(5, 4), direction: Vector2(0, 1));
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.normal, closeToVector(Vector2(0, -1)));
expect(result?.reflectionRay?.origin, closeToVector(Vector2(5, 10)));
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2(0, -1)),
);
},
);
testCollisionDetectionGame(
'ray from bottom-right to top-left within CircleHitbox',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.zero(),
size: Vector2.all(10),
)..add(CircleHitbox()),
]);
await game.ready();
final ray = Ray2(
origin: Vector2.all(6),
direction: Vector2.all(-1)..normalize(),
);
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.normal, closeToVector(Vector2.all(0.707106781186547)));
expect(
result?.intersectionPoint,
closeToVector(Vector2.all(1.4644660940672631)),
);
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2.all(1)..normalize()),
);
},
);
testCollisionDetectionGame(
'ray from bottom within CircleHitbox going down',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.zero(),
size: Vector2.all(10),
)..add(CircleHitbox()),
]);
await game.ready();
final direction = Vector2(0, 1);
final ray = Ray2(origin: Vector2(5, 6), direction: direction);
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.normal, closeToVector(Vector2(0, -1)));
expect(
result?.intersectionPoint,
closeToVector(Vector2(5, 10)),
);
expect(
result?.reflectionRay?.direction,
closeToVector(direction.inverted()),
);
},
);
testCollisionDetectionGame(
'ray from the left of CircleHitbox',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.zero(),
size: Vector2.all(10),
)..add(CircleHitbox()),
]);
await game.ready();
final ray = Ray2(origin: Vector2(-5, 5), direction: Vector2(1, 0));
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.reflectionRay?.origin, closeToVector(Vector2(0, 5)));
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2(-1, 0)),
);
},
);
testCollisionDetectionGame(
'ray from the top of CircleHitbox',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.zero(),
size: Vector2.all(10),
)..add(CircleHitbox()),
]);
await game.ready();
final ray = Ray2(origin: Vector2(5, -5), direction: Vector2(0, 1));
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.reflectionRay?.origin, closeToVector(Vector2(5, 0)));
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2(0, -1)),
);
},
);
testCollisionDetectionGame(
'ray from the right of CircleHitbox',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.zero(),
size: Vector2.all(10),
)..add(CircleHitbox()),
]);
await game.ready();
final ray = Ray2(origin: Vector2(15, 5), direction: Vector2(-1, 0));
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.reflectionRay?.origin, closeToVector(Vector2(10, 5)));
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2(1, 0)),
);
},
);
testCollisionDetectionGame(
'ray from the bottom of CircleHitbox',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2.zero(),
size: Vector2.all(10),
)..add(CircleHitbox()),
]);
await game.ready();
final ray = Ray2(origin: Vector2(5, 15), direction: Vector2(0, -1));
final result = game.collisionDetection.raycast(ray);
expect(result?.hitbox?.parent, game.children.first);
expect(result?.reflectionRay?.origin, closeToVector(Vector2(5, 10)));
expect(
result?.reflectionRay?.direction,
closeToVector(Vector2(0, 1)),
);
},
);
});
group('raycastAll', () {
testCollisionDetectionGame(
'All directions and all hits',
(game) async {
game.ensureAddAll([
PositionComponent(
position: Vector2(10, 0),
size: Vector2.all(10),
)..add(RectangleHitbox()),
PositionComponent(
position: Vector2(20, 10),
size: Vector2.all(10),
)..add(RectangleHitbox()),
PositionComponent(
position: Vector2(10, 20),
size: Vector2.all(10),
)..add(RectangleHitbox()),
PositionComponent(
position: Vector2(0, 10),
size: Vector2.all(10),
)..add(RectangleHitbox()),
]);
await game.ready();
final origin = Vector2.all(15);
final results = game.collisionDetection.raycastAll(
origin,
numberOfRays: 4,
);
expect(results.every((r) => r.isActive), isTrue);
expect(results.length, 4);
},
);
});
});
group('Raytracing', () {
testCollisionDetectionGame('on single circle', (game) async {
final circle = CircleComponent(
radius: 10.0,
position: Vector2.all(20),
anchor: Anchor.center,
)..add(CircleHitbox());
await game.ensureAdd(circle);
final ray = Ray2(
origin: Vector2(0, 10),
direction: Vector2.all(1.0)..normalize(),
);
final results = game.collisionDetection.raytrace(ray);
expect(results.length, 1);
expect(results.first.isActive, isTrue);
expect(results.first.isInsideHitbox, isFalse);
expect(results.first.intersectionPoint, Vector2(10, 20));
final reflectionRay = results.first.reflectionRay;
expect(reflectionRay?.origin, Vector2(10, 20));
expect(reflectionRay?.direction, Vector2(-1, 1)..normalize());
expect(results.first.normal, Vector2(-1, 0));
});
testCollisionDetectionGame('on single rectangle', (game) async {
final rectangle = RectangleComponent(
position: Vector2.all(20),
size: Vector2.all(20),
anchor: Anchor.center,
)..add(RectangleHitbox());
await game.ensureAdd(rectangle);
final ray = Ray2(
origin: Vector2(0, 10),
direction: Vector2.all(1.0)..normalize(),
);
final results = game.collisionDetection.raytrace(ray);
expect(results.length, 1);
expect(results.first.isActive, isTrue);
expect(results.first.isInsideHitbox, isFalse);
expect(results.first.intersectionPoint, Vector2(10, 20));
final reflectionRay = results.first.reflectionRay;
expect(reflectionRay?.origin, Vector2(10, 20));
expect(reflectionRay?.direction, Vector2(-1, 1)..normalize());
expect(results.first.normal, Vector2(-1, 0));
});
testCollisionDetectionGame('on single rectangle with ray with negative X',
(game) async {
final rectangle = RectangleComponent(
position: Vector2(-20, 40),
size: Vector2.all(20),
anchor: Anchor.center,
)..add(RectangleHitbox());
await game.ensureAdd(rectangle);
final ray = Ray2(
origin: Vector2(10, 20),
direction: Vector2(-1, 1)..normalize(),
);
final results = game.collisionDetection.raytrace(ray);
expect(results.length, 1);
expect(results.first.isActive, isTrue);
expect(results.first.isInsideHitbox, isFalse);
expect(results.first.intersectionPoint, Vector2(-10, 40));
final reflectionRay = results.first.reflectionRay;
expect(reflectionRay?.origin, Vector2(-10, 40));
expect(reflectionRay?.direction, Vector2(1, 1)..normalize());
expect(results.first.normal, Vector2(1, 0));
});
testCollisionDetectionGame('on two circles', (game) async {
final circle1 = CircleComponent(
position: Vector2.all(20),
radius: 10,
anchor: Anchor.center,
)..add(CircleHitbox());
final circle2 = CircleComponent(
position: Vector2(-20, 40),
radius: 10,
anchor: Anchor.center,
)..add(CircleHitbox());
await game.ensureAddAll([circle1, circle2]);
final ray = Ray2(
origin: Vector2(0, 10),
direction: Vector2.all(1.0)..normalize(),
);
final results = game.collisionDetection.raytrace(ray).toList();
expect(results.length, 2);
expect(results.every((e) => e.isActive), isTrue);
expect(results.every((e) => e.isInsideHitbox), isFalse);
// First box
expect(results[0].intersectionPoint, Vector2(10, 20));
expect(results[0].normal, Vector2(-1, 0));
final reflectionRay1 = results[0].reflectionRay;
expect(reflectionRay1?.origin, Vector2(10, 20));
expect(reflectionRay1?.direction, Vector2(-1, 1)..normalize());
final results2 = game.collisionDetection.raytrace(reflectionRay1!);
expect(results2.length, 1);
// Second box
expect(results[1].intersectionPoint, Vector2(-10, 40));
expect(results[1].normal, Vector2(1, 0));
final reflectionRay2 = results[1].reflectionRay;
expect(reflectionRay2?.origin, Vector2(-10, 40));
expect(reflectionRay2?.direction, Vector2(1, 1)..normalize());
});
testCollisionDetectionGame('on two rectangles', (game) async {
final rectangle1 = RectangleComponent(
position: Vector2.all(20),
size: Vector2.all(20),
anchor: Anchor.center,
)..add(RectangleHitbox());
final rectangle2 = RectangleComponent(
position: Vector2(-20, 40),
size: Vector2.all(20),
anchor: Anchor.center,
)..add(RectangleHitbox());
await game.ensureAddAll([rectangle1, rectangle2]);
final ray = Ray2(
origin: Vector2(0, 10),
direction: Vector2.all(1.0)..normalize(),
);
final results = game.collisionDetection.raytrace(ray).toList();
expect(results.length, 2);
expect(results.every((e) => e.isActive), isTrue);
expect(results.every((e) => e.isInsideHitbox), isFalse);
// First box
expect(results[0].intersectionPoint, Vector2(10, 20));
expect(results[0].normal, Vector2(-1, 0));
final reflectionRay1 = results[0].reflectionRay;
expect(reflectionRay1?.origin, Vector2(10, 20));
expect(reflectionRay1?.direction, Vector2(-1, 1)..normalize());
final results2 =
game.collisionDetection.raytrace(reflectionRay1!).toList();
expect(results2.length, 1);
// Second box
expect(results[1].intersectionPoint, Vector2(-10, 40));
expect(results[1].normal, Vector2(1, 0));
final reflectionRay2 = results[1].reflectionRay;
expect(reflectionRay2?.origin, Vector2(-10, 40));
expect(reflectionRay2?.direction, Vector2(1, 1)..normalize());
});
testCollisionDetectionGame('on a rectangle within another', (game) async {
final rectangle1 = RectangleComponent(
position: Vector2.all(20),
size: Vector2.all(20),
)..add(RectangleHitbox());
final rectangle2 = RectangleComponent(
size: Vector2.all(200),
)..add(RectangleHitbox());
await game.ensureAddAll([rectangle1, rectangle2]);
final ray = Ray2(
origin: Vector2(20, 10),
direction: Vector2.all(1.0)..normalize(),
);
final results = game.collisionDetection.raytrace(ray).toList();
expect(results.length, 10);
expect(results.every((e) => e.isActive), isTrue);
expect(results[0].isInsideHitbox, isFalse);
expect(results[1].isInsideHitbox, isTrue);
// First box
expect(results[0].intersectionPoint, Vector2(30, 20));
expect(results[0].normal, Vector2(0, -1));
final reflectionRay1 = results[0].reflectionRay;
expect(reflectionRay1?.origin, Vector2(30, 20));
expect(reflectionRay1?.direction, Vector2(1, -1)..normalize());
final results2 =
game.collisionDetection.raytrace(reflectionRay1!).toList();
expect(results2.length, 10);
// Second box
expect(results[1].intersectionPoint, Vector2(50, 0));
expect(results[1].normal, Vector2(0, 1));
final reflectionRay2 = results[1].reflectionRay;
expect(reflectionRay2?.origin, Vector2(50, 0));
expect(reflectionRay2?.direction, Vector2(1, 1)..normalize());
});
});
}

View File

@ -10,7 +10,7 @@ class Passthrough extends TestBlock with CollisionPassthrough {
void main() {
group('CollisionPassthrough', () {
testCollidableGame('Passing collisions to parent', (game) async {
testCollisionDetectionGame('Passing collisions to parent', (game) async {
final passthrough = Passthrough();
final hitboxParent =
TestBlock(Vector2.zero(), Vector2.all(10), addTestHitbox: false)

View File

@ -7,7 +7,7 @@ import 'package:meta/meta.dart';
class HasCollidablesGame extends FlameGame with HasCollisionDetection {}
@isTest
Future<void> testCollidableGame(
Future<void> testCollisionDetectionGame(
String testName,
Future Function(HasCollidablesGame) testBody,
) {

View File

@ -10,7 +10,7 @@ import 'collision_test_helpers.dart';
void main() {
group('CollisionType', () {
testCollidableGame('actives do collide', (game) async {
testCollisionDetectionGame('actives do collide', (game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -27,7 +27,7 @@ void main() {
expect(blockB.activeCollisions.length, 1);
});
testCollidableGame('passives do not collide', (game) async {
testCollisionDetectionGame('passives do not collide', (game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -44,7 +44,7 @@ void main() {
expect(blockB.activeCollisions.isEmpty, true);
});
testCollidableGame('inactives do not collide', (game) async {
testCollisionDetectionGame('inactives do not collide', (game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -61,7 +61,7 @@ void main() {
expect(blockB.activeCollisions.isEmpty, true);
});
testCollidableGame('active collides with static', (game) async {
testCollisionDetectionGame('active collides with static', (game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -79,7 +79,7 @@ void main() {
expect(blockB.activeCollisions.length, 1);
});
testCollidableGame('passive collides with active', (game) async {
testCollisionDetectionGame('passive collides with active', (game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -97,7 +97,8 @@ void main() {
expect(blockB.activeCollisions.length, 1);
});
testCollidableGame('passive does not collide with inactive', (game) async {
testCollisionDetectionGame('passive does not collide with inactive',
(game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -114,7 +115,8 @@ void main() {
expect(blockB.activeCollisions.length, 0);
});
testCollidableGame('inactive does not collide with static', (game) async {
testCollisionDetectionGame('inactive does not collide with static',
(game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -131,7 +133,8 @@ void main() {
expect(blockB.activeCollisions.length, 0);
});
testCollidableGame('active does not collide with inactive', (game) async {
testCollisionDetectionGame('active does not collide with inactive',
(game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -147,7 +150,8 @@ void main() {
expect(blockB.activeCollisions.length, 0);
});
testCollidableGame('inactive does not collide with active', (game) async {
testCollisionDetectionGame('inactive does not collide with active',
(game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -163,7 +167,7 @@ void main() {
expect(blockB.activeCollisions.length, 0);
});
testCollidableGame(
testCollisionDetectionGame(
'correct collisions with many involved collidables',
(game) async {
final rng = Random(0);
@ -192,7 +196,7 @@ void main() {
},
);
testCollidableGame('detects collision after scale', (game) async {
testCollisionDetectionGame('detects collision after scale', (game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -217,7 +221,7 @@ void main() {
expect(blockB.activeCollisions.length, 1);
});
testCollidableGame('detects collision after flip', (game) async {
testCollisionDetectionGame('detects collision after flip', (game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -242,7 +246,7 @@ void main() {
expect(blockB.activeCollisions.length, 1);
});
testCollidableGame('detects collision after scale', (game) async {
testCollisionDetectionGame('detects collision after scale', (game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -267,7 +271,8 @@ void main() {
expect(blockB.activeCollisions.length, 1);
});
testCollidableGame('testPoint detects point after flip', (game) async {
testCollisionDetectionGame('testPoint detects point after flip',
(game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -280,7 +285,8 @@ void main() {
expect(blockA.containsPoint(Vector2(-1, 1)), true);
});
testCollidableGame('testPoint detects point after scale', (game) async {
testCollisionDetectionGame('testPoint detects point after scale',
(game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),
@ -293,7 +299,8 @@ void main() {
expect(blockA.containsPoint(Vector2.all(11)), true);
});
testCollidableGame('detects collision on child components', (game) async {
testCollisionDetectionGame('detects collision on child components',
(game) async {
final blockA = TestBlock(
Vector2.zero(),
Vector2.all(10),

View File

@ -263,4 +263,19 @@ void main() {
expect(w, Vector2.all(-1));
});
});
group('inversion', () {
test('invert', () {
final v = Vector2.all(1);
v.invert();
expect(v, Vector2.all(-1));
});
test('inverted', () {
final v = Vector2.all(1);
final w = v.inverted();
expect(v, Vector2.all(1));
expect(w, Vector2.all(-1));
});
});
}

View File

@ -1,6 +1,5 @@
import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
import 'package:flame/src/experimental/geometry/shapes/circle.dart';
import 'package:flame_test/flame_test.dart';
import 'package:test/test.dart';
@ -9,8 +8,8 @@ void main() {
test('Properly updates direction inverses', () {
final direction = Vector2(-10.0, 3).normalized();
final ray = Ray2(
Vector2.all(2.0),
direction,
origin: Vector2.all(2.0),
direction: direction,
);
expect(
ray.directionInvX,
@ -41,13 +40,16 @@ void main() {
expect(
() {
Ray2(
Vector2.all(2.0),
direction,
origin: Vector2.all(2.0),
direction: direction,
);
},
failsAssert('direction must be normalized'),
);
final ray = Ray2(Vector2.all(2.0), direction.normalized());
final ray = Ray2(
origin: Vector2.all(2.0),
direction: direction.normalized(),
);
expect(
() => ray.direction = direction,
failsAssert('direction must be normalized'),
@ -57,7 +59,8 @@ void main() {
group('intersectsWithAabb2', () {
test('Ray from the east', () {
final direction = Vector2(1.0, 0.0);
final ray = Ray2(Vector2.zero(), direction.normalized());
final ray =
Ray2(origin: Vector2.zero(), direction: direction.normalized());
expect(
ray.intersectsWithAabb2(Aabb2.minMax(Vector2(1, -1), Vector2(2, 1))),
isTrue,
@ -66,7 +69,8 @@ void main() {
test('Ray from the north', () {
final direction = Vector2(0.0, 1.0);
final ray = Ray2(Vector2.zero(), direction.normalized());
final ray =
Ray2(origin: Vector2.zero(), direction: direction.normalized());
expect(
ray.intersectsWithAabb2(Aabb2.minMax(Vector2(-1, 1), Vector2(1, 2))),
isTrue,
@ -75,7 +79,8 @@ void main() {
test('Ray from the west', () {
final direction = Vector2(-1.0, 0.0);
final ray = Ray2(Vector2.zero(), direction.normalized());
final ray =
Ray2(origin: Vector2.zero(), direction: direction.normalized());
expect(
ray.intersectsWithAabb2(
Aabb2.minMax(Vector2(-2, -1), Vector2(-1, 1)),
@ -86,7 +91,8 @@ void main() {
test('Ray from the south', () {
final direction = Vector2(0.0, -1.0);
final ray = Ray2(Vector2.zero(), direction.normalized());
final ray =
Ray2(origin: Vector2.zero(), direction: direction.normalized());
expect(
ray.intersectsWithAabb2(
Aabb2.minMax(Vector2(-1, -2), Vector2(1, -1)),
@ -97,7 +103,8 @@ void main() {
test('Ray from the northEast', () {
final direction = Vector2(0.5, 0.5);
final ray = Ray2(Vector2.zero(), direction.normalized());
final ray =
Ray2(origin: Vector2.zero(), direction: direction.normalized());
expect(
ray.intersectsWithAabb2(Aabb2.minMax(Vector2(1, 1), Vector2(2, 2))),
isTrue,
@ -106,7 +113,8 @@ void main() {
test('Ray from the northWest', () {
final direction = Vector2(-0.5, 0.5);
final ray = Ray2(Vector2.zero(), direction.normalized());
final ray =
Ray2(origin: Vector2.zero(), direction: direction.normalized());
expect(
ray.intersectsWithAabb2(Aabb2.minMax(Vector2(-2, 1), Vector2(-1, 2))),
isTrue,
@ -115,7 +123,8 @@ void main() {
test('Ray from the southWest', () {
final direction = Vector2(-0.5, -0.5);
final ray = Ray2(Vector2.zero(), direction.normalized());
final ray =
Ray2(origin: Vector2.zero(), direction: direction.normalized());
expect(
ray.intersectsWithAabb2(
Aabb2.minMax(Vector2(-2, -2), Vector2(-1, -1)),
@ -126,7 +135,8 @@ void main() {
test('Ray from the southEast', () {
final direction = Vector2(0.5, -0.5);
final ray = Ray2(Vector2.zero(), direction.normalized());
final ray =
Ray2(origin: Vector2.zero(), direction: direction.normalized());
expect(
ray.intersectsWithAabb2(Aabb2.minMax(Vector2(1, -2), Vector2(2, -1))),
isTrue,
@ -140,7 +150,8 @@ void main() {
const numberOfDirections = 16;
for (var i = 0; i < numberOfDirections; i++) {
direction.rotate(tau * (i / numberOfDirections));
final ray = Ray2(Vector2.all(5), direction.normalized());
final ray =
Ray2(origin: Vector2.all(5), direction: direction.normalized());
final aabb2 = Aabb2.minMax(Vector2.zero(), Vector2.all(10));
expect(
ray.intersectsWithAabb2(aabb2),
@ -160,7 +171,8 @@ void main() {
final angle =
(tau / 2 - 2 * epsilon) * (i / numberOfDirections) + epsilon;
direction.rotate(angle);
final ray = Ray2(Vector2(10, 5), direction.normalized());
final ray =
Ray2(origin: Vector2(10, 5), direction: direction.normalized());
final aabb2 = Aabb2.minMax(Vector2.zero(), Vector2.all(10));
expect(
ray.intersectsWithAabb2(aabb2),
@ -180,7 +192,8 @@ void main() {
final angle =
(tau / 2 - 2 * epsilon) * (i / numberOfDirections) + epsilon;
direction.rotate(-angle);
final ray = Ray2(Vector2(10, 5), direction.normalized());
final ray =
Ray2(origin: Vector2(10, 5), direction: direction.normalized());
final aabb2 = Aabb2.minMax(Vector2.zero(), Vector2.all(10));
expect(
ray.intersectsWithAabb2(aabb2),
@ -191,15 +204,17 @@ void main() {
);
test(
'Rays that originates and follows a box edge intersects',
'Rays that originates and follows a box edge does intersects',
() {
final rayVertical = Ray2(Vector2(10, 5), Vector2(0, 1));
final rayVertical =
Ray2(origin: Vector2(10, 5), direction: Vector2(0, 1));
final aabb2 = Aabb2.minMax(Vector2.zero(), Vector2.all(10));
expect(
rayVertical.intersectsWithAabb2(aabb2),
isTrue,
);
final rayHorizontal = Ray2(Vector2(5, 0), Vector2(1, 0));
final rayHorizontal =
Ray2(origin: Vector2(5, 0), direction: Vector2(1, 0));
expect(
rayHorizontal.intersectsWithAabb2(aabb2),
isTrue,
@ -210,13 +225,15 @@ void main() {
test(
'Rays that originates in a corner intersects',
() {
final rayZero = Ray2(Vector2.zero(), Vector2(0, 1));
final rayZero =
Ray2(origin: Vector2.zero(), direction: Vector2(0, 1));
final aabb2 = Aabb2.minMax(Vector2.zero(), Vector2.all(10));
expect(
rayZero.intersectsWithAabb2(aabb2),
isTrue,
);
final rayTen = Ray2(Vector2.all(10), Vector2(0, -1));
final rayTen =
Ray2(origin: Vector2.all(10), direction: Vector2(0, -1));
expect(
rayTen.intersectsWithAabb2(aabb2),
isTrue,
@ -228,7 +245,8 @@ void main() {
'Ray in the opposite direction does not intersect',
() {
final direction = Vector2(1, 0);
final ray = Ray2(Vector2(15, 5), direction.normalized());
final ray =
Ray2(origin: Vector2(15, 5), direction: direction.normalized());
final aabb2 = Aabb2.minMax(Vector2.zero(), Vector2.all(10));
expect(
ray.intersectsWithAabb2(aabb2),
@ -243,7 +261,8 @@ void main() {
'Correct intersection point length on ray going east',
() {
final direction = Vector2(1, 0);
final ray = Ray2(Vector2(5, 5), direction.normalized());
final ray =
Ray2(origin: Vector2(5, 5), direction: direction.normalized());
final segment = LineSegment(Vector2(10, 0), Vector2.all(10));
expect(ray.lineSegmentIntersection(segment), 5);
},
@ -253,7 +272,8 @@ void main() {
'Correct intersection point length on ray going west',
() {
final direction = Vector2(-1, 0);
final ray = Ray2(Vector2(5, 5), direction.normalized());
final ray =
Ray2(origin: Vector2(5, 5), direction: direction.normalized());
final segment = LineSegment(Vector2(0, 0), Vector2(0, 10));
expect(ray.lineSegmentIntersection(segment), 5);
},
@ -263,7 +283,8 @@ void main() {
'Correct intersection point length on ray going south',
() {
final direction = Vector2(0, 1);
final ray = Ray2(Vector2(5, 5), direction.normalized());
final ray =
Ray2(origin: Vector2(5, 5), direction: direction.normalized());
final segment = LineSegment(Vector2(0, 10), Vector2(10, 10));
expect(ray.lineSegmentIntersection(segment), 5);
},
@ -273,17 +294,19 @@ void main() {
'Correct intersection point length on ray going north',
() {
final direction = Vector2(0, -1);
final ray = Ray2(Vector2(5, 5), direction.normalized());
final ray =
Ray2(origin: Vector2(5, 5), direction: direction.normalized());
final segment = LineSegment(Vector2(0, 0), Vector2(10, 0));
expect(ray.lineSegmentIntersection(segment), 5);
},
);
test(
'Correct intersection point when ray originates on segment',
'Origin as intersection point when ray originates on segment',
() {
final direction = Vector2(0, -1);
final ray = Ray2(Vector2(5, 0), direction.normalized());
final ray =
Ray2(origin: Vector2(5, 0), direction: direction.normalized());
final segment = LineSegment(Vector2(0, 0), Vector2(10, 0));
expect(ray.lineSegmentIntersection(segment), 0);
},
@ -293,7 +316,8 @@ void main() {
'No intersection when ray is parallel and originates on segment',
() {
final direction = Vector2(1, 0);
final ray = Ray2(Vector2(5, 0), direction.normalized());
final ray =
Ray2(origin: Vector2(5, 0), direction: direction.normalized());
final segment = LineSegment(Vector2(0, 0), Vector2(10, 0));
expect(ray.lineSegmentIntersection(segment), null);
},
@ -303,7 +327,8 @@ void main() {
'No intersection point when ray is parallel to the segment',
() {
final direction = Vector2(1, 0);
final ray = Ray2(Vector2(-5, 0), direction.normalized());
final ray =
Ray2(origin: Vector2(-5, 0), direction: direction.normalized());
final segment = LineSegment(Vector2(0, 0), Vector2(10, 0));
expect(ray.lineSegmentIntersection(segment), null);
},
@ -313,7 +338,8 @@ void main() {
'No intersection point when ray is parallel without intersection',
() {
final direction = Vector2(1, 0);
final ray = Ray2(Vector2(5, 5), direction.normalized());
final ray =
Ray2(origin: Vector2(5, 5), direction: direction.normalized());
final segment = LineSegment(Vector2(0, 0), Vector2(10, 0));
expect(ray.lineSegmentIntersection(segment), null);
},