diff --git a/doc/flame/collision_detection.md b/doc/flame/collision_detection.md index 028f5fdd0..632494afd 100644 --- a/doc/flame/collision_detection.md +++ b/doc/flame/collision_detection.md @@ -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 diff --git a/examples/.metadata b/examples/.metadata index f6caaefdd..e7c100105 100644 --- a/examples/.metadata +++ b/examples/.metadata @@ -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' diff --git a/examples/lib/stories/collision_detection/collision_detection.dart b/examples/lib/stories/collision_detection/collision_detection.dart index 99fcf81da..b9b8e49dd 100644 --- a/examples/lib/stories/collision_detection/collision_detection.dart +++ b/examples/lib/stories/collision_detection/collision_detection.dart @@ -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, ); } diff --git a/examples/lib/stories/collision_detection/raycast_example.dart b/examples/lib/stories/collision_detection/raycast_example.dart new file mode 100644 index 000000000..a3736cfa1 --- /dev/null +++ b/examples/lib/stories/collision_detection/raycast_example.dart @@ -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 rays = []; + final List> results = []; + + late Path path; + @override + Future 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> 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); + } +} diff --git a/examples/lib/stories/collision_detection/raycast_light_example.dart b/examples/lib/stories/collision_detection/raycast_light_example.dart new file mode 100644 index 000000000..8baab31eb --- /dev/null +++ b/examples/lib/stories/collision_detection/raycast_light_example.dart @@ -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 rays = []; + final List tapRays = []; + final List> results = []; + final List> tapResults = []; + + late Path path; + @override + Future 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> 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, + ); + } + } +} diff --git a/examples/lib/stories/collision_detection/raytrace_example.dart b/examples/lib/stories/collision_detection/raytrace_example.dart new file mode 100644 index 000000000..7d8b39c24 --- /dev/null +++ b/examples/lib/stories/collision_detection/raytrace_example.dart @@ -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 rays = []; + final List> results = []; + + late Path path; + @override + Future onLoad() async { + addAll([ + ScreenHitbox(), + CircleComponent( + radius: min(camera.canvasSize.x, camera.canvasSize.y) / 2, + paint: boxPaint, + children: [CircleHitbox()], + ), + ]); + } + + bool isClicked = false; + final extraChildren = []; + @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> 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; + } + } +} diff --git a/packages/flame/lib/collisions.dart b/packages/flame/lib/collisions.dart index ebf9beb35..daf885c30 100644 --- a/packages/flame/lib/collisions.dart +++ b/packages/flame/lib/collisions.dart @@ -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'; diff --git a/packages/flame/lib/extensions.dart b/packages/flame/lib/extensions.dart index eb7061d97..03065ed51 100644 --- a/packages/flame/lib/extensions.dart +++ b/packages/flame/lib/extensions.dart @@ -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'; diff --git a/packages/flame/lib/geometry.dart b/packages/flame/lib/geometry.dart index 624834238..cc9d7f4d2 100644 --- a/packages/flame/lib/geometry.dart +++ b/packages/flame/lib/geometry.dart @@ -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'; diff --git a/packages/flame/lib/src/collisions/broadphase.dart b/packages/flame/lib/src/collisions/broadphase.dart index 5419ee650..4bd5dcf9c 100644 --- a/packages/flame/lib/src/collisions/broadphase.dart +++ b/packages/flame/lib/src/collisions/broadphase.dart @@ -12,6 +12,11 @@ abstract class Broadphase> { Broadphase({List? 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> query(); } diff --git a/packages/flame/lib/src/collisions/collision_detection.dart b/packages/flame/lib/src/collisions/collision_detection.dart index b40c5c7ee..7ad0f81e9 100644 --- a/packages/flame/lib/src/collisions/collision_detection.dart +++ b/packages/flame/lib/src/collisions/collision_detection.dart @@ -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> { /// 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> { void handleCollisionStart(Set intersectionPoints, T itemA, T itemB); void handleCollision(Set 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 { - StandardCollisionDetection({Broadphase? broadphase}) - : super(broadphase: broadphase ?? Sweep()); - - /// Check what the intersection points of two collidables are, - /// returns an empty list if there are no intersections. - @override - Set 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 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 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? raycast(Ray2 ray, {RaycastResult? 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> raycastAll( + Vector2 origin, { + required int numberOfRays, + double startAngle = 0, + double sweepAngle = tau, + List? rays, + List>? 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> raytrace( + Ray2 ray, { + int maxDepth = 10, + List>? out, + }); } diff --git a/packages/flame/lib/src/collisions/has_collision_detection.dart b/packages/flame/lib/src/collisions/has_collision_detection.dart index 6687c3133..b9f5a3c7a 100644 --- a/packages/flame/lib/src/collisions/has_collision_detection.dart +++ b/packages/flame/lib/src/collisions/has_collision_detection.dart @@ -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 _collisionDetection = StandardCollisionDetection(); - CollisionDetection get collisionDetection => _collisionDetection; + CollisionDetection _collisionDetection = + StandardCollisionDetection(); + CollisionDetection get collisionDetection => _collisionDetection; - set collisionDetection(CollisionDetection cd) { + set collisionDetection(CollisionDetection 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> on FlameGame { + CollisionDetection? _collisionDetection; + CollisionDetection get collisionDetection => _collisionDetection!; + + set collisionDetection(CollisionDetection cd) { + if (_collisionDetection != null) { + cd.addAll(_collisionDetection!.items); + } + _collisionDetection = cd; + } + + @override + void update(double dt) { + super.update(dt); + _collisionDetection?.run(); + } +} diff --git a/packages/flame/lib/src/collisions/hitboxes/circle_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/circle_hitbox.dart index 439154afc..7aa1dd10b 100644 --- a/packages/flame/lib/src/collisions/hitboxes/circle_hitbox.dart +++ b/packages/flame/lib/src/collisions/hitboxes/circle_hitbox.dart @@ -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? rayIntersection( + Ray2 ray, { + RaycastResult? 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; + } + } } diff --git a/packages/flame/lib/src/collisions/hitboxes/polygon_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/polygon_hitbox.dart index 021ed1057..4eee3baf2 100644 --- a/packages/flame/lib/src/collisions/hitboxes/polygon_hitbox.dart +++ b/packages/flame/lib/src/collisions/hitboxes/polygon_hitbox.dart @@ -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, diff --git a/packages/flame/lib/src/collisions/hitboxes/rectangle_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/rectangle_hitbox.dart index abf982985..86159f2ef 100644 --- a/packages/flame/lib/src/collisions/hitboxes/rectangle_hitbox.dart +++ b/packages/flame/lib/src/collisions/hitboxes/rectangle_hitbox.dart @@ -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 { @override final bool shouldFillParent; diff --git a/packages/flame/lib/src/collisions/hitboxes/shape_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/shape_hitbox.dart index 1a3cbe424..de2dd6c18 100644 --- a/packages/flame/lib/src/collisions/hitboxes/shape_hitbox.dart +++ b/packages/flame/lib/src/collisions/hitboxes/shape_hitbox.dart @@ -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 { 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? rayIntersection( + Ray2 ray, { + RaycastResult? out, + }); + /// This determines how the shape should scale if it should try to fill its /// parents boundaries. void fillParent(); diff --git a/packages/flame/lib/src/collisions/standard_collision_detection.dart b/packages/flame/lib/src/collisions/standard_collision_detection.dart new file mode 100644 index 000000000..8d884ebee --- /dev/null +++ b/packages/flame/lib/src/collisions/standard_collision_detection.dart @@ -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 { + StandardCollisionDetection({Broadphase? broadphase}) + : super(broadphase: broadphase ?? Sweep()); + + /// Check what the intersection points of two collidables are, + /// returns an empty list if there are no intersections. + @override + Set 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 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 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(); + + @override + RaycastResult? raycast( + Ray2 ray, { + RaycastResult? 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> raycastAll( + Vector2 origin, { + required int numberOfRays, + double startAngle = 0, + double sweepAngle = tau, + List? rays, + List>? out, + }) { + final isFullCircle = (sweepAngle % tau).abs() < 0.0001; + final angle = sweepAngle / (numberOfRays + (isFullCircle ? 0 : -1)); + final results = >[]; + 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? 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> raytrace( + Ray2 ray, { + int maxDepth = 10, + List>? 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(); + if (raycast(currentRay, out: currentResult) != null) { + currentRay = currentResult.reflectionRay!; + if (!hasResultObject && out != null) { + out.add(currentResult); + } + yield currentResult; + } else { + break; + } + } + } +} diff --git a/packages/flame/lib/src/collisions/sweep.dart b/packages/flame/lib/src/collisions/sweep.dart index 804dd80e1..c4b422cf3 100644 --- a/packages/flame/lib/src/collisions/sweep.dart +++ b/packages/flame/lib/src/collisions/sweep.dart @@ -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> extends Broadphase { Sweep({super.items}); - final List _active = []; - final Set> _potentials = {}; + late final List _active = []; + late final Set> _potentials = {}; + + @override + void update() { + items.sort((a, b) => a.aabb.min.x.compareTo(b.aabb.min.x)); + } @override Set> 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; diff --git a/packages/flame/lib/src/experimental/geometry/shapes/circle.dart b/packages/flame/lib/src/experimental/geometry/shapes/circle.dart index 1f8575bb3..3f0532156 100644 --- a/packages/flame/lib/src/experimental/geometry/shapes/circle.dart +++ b/packages/flame/lib/src/experimental/geometry/shapes/circle.dart @@ -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π diff --git a/packages/flame/lib/src/experimental/raycast_result.dart b/packages/flame/lib/src/experimental/raycast_result.dart new file mode 100644 index 000000000..83674b782 --- /dev/null +++ b/packages/flame/lib/src/experimental/raycast_result.dart @@ -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> { + 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 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 clone() { + return RaycastResult( + hitbox: hitbox, + reflectionRay: _reflectionRay.clone(), + normal: _normal.clone(), + distance: distance, + isInsideHitbox: isInsideHitbox, + ); + } +} diff --git a/packages/flame/lib/src/geometry/circle_component.dart b/packages/flame/lib/src/geometry/circle_component.dart index 648d8f504..8a601e025 100644 --- a/packages/flame/lib/src/geometry/circle_component.dart +++ b/packages/flame/lib/src/geometry/circle_component.dart @@ -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 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(); } diff --git a/packages/flame/lib/src/geometry/constants.dart b/packages/flame/lib/src/geometry/constants.dart new file mode 100644 index 000000000..2c59dc526 --- /dev/null +++ b/packages/flame/lib/src/geometry/constants.dart @@ -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; diff --git a/packages/flame/lib/src/geometry/polygon_component.dart b/packages/flame/lib/src/geometry/polygon_component.dart index 4e5c7f7bf..5e95ada7d 100644 --- a/packages/flame/lib/src/geometry/polygon_component.dart +++ b/packages/flame/lib/src/geometry/polygon_component.dart @@ -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. diff --git a/packages/flame/lib/src/geometry/polygon_ray_intersection.dart b/packages/flame/lib/src/geometry/polygon_ray_intersection.dart new file mode 100644 index 000000000..9ae5255cf --- /dev/null +++ b/packages/flame/lib/src/geometry/polygon_ray_intersection.dart @@ -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 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? rayIntersection( + Ray2 ray, { + RaycastResult? 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()) + ..setWith( + hitbox: this as T, + reflectionRay: reflectionRay, + normal: _temporaryNormal, + distance: closestDistance, + isInsideHitbox: isInsideHitbox, + ); + } + out?.reset(); + return null; + } +} diff --git a/packages/flame/lib/src/geometry/ray2.dart b/packages/flame/lib/src/geometry/ray2.dart index 9c51a4d54..8c0bab096 100644 --- a/packages/flame/lib/src/geometry/ray2.dart +++ b/packages/flame/lib/src/geometry/ray2.dart @@ -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)'; } diff --git a/packages/flame/lib/src/palette.dart b/packages/flame/lib/src/palette.dart index a84aedd37..1bd502146 100644 --- a/packages/flame/lib/src/palette.dart +++ b/packages/flame/lib/src/palette.dart @@ -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)); diff --git a/packages/flame/test/collisions/collision_callback_benchmark.dart b/packages/flame/test/collisions/collision_callback_benchmark.dart index 8e14a613a..8cfa331c3 100644 --- a/packages/flame/test/collisions/collision_callback_benchmark.dart +++ b/packages/flame/test/collisions/collision_callback_benchmark.dart @@ -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, diff --git a/packages/flame/test/collisions/collision_callback_test.dart b/packages/flame/test/collisions/collision_callback_test.dart index 285f37c69..5d580523e 100644 --- a/packages/flame/test/collisions/collision_callback_test.dart +++ b/packages/flame/test/collisions/collision_callback_test.dart @@ -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)) diff --git a/packages/flame/test/collisions/collision_detection_test.dart b/packages/flame/test/collisions/collision_detection_test.dart index a1914644b..b83346979 100644 --- a/packages/flame/test/collisions/collision_detection_test.dart +++ b/packages/flame/test/collisions/collision_detection_test.dart @@ -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()); + }); + }); } diff --git a/packages/flame/test/collisions/collision_passthrough_test.dart b/packages/flame/test/collisions/collision_passthrough_test.dart index d60014f3c..172aace00 100644 --- a/packages/flame/test/collisions/collision_passthrough_test.dart +++ b/packages/flame/test/collisions/collision_passthrough_test.dart @@ -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) diff --git a/packages/flame/test/collisions/collision_test_helpers.dart b/packages/flame/test/collisions/collision_test_helpers.dart index 0a0d14552..d9d92b81e 100644 --- a/packages/flame/test/collisions/collision_test_helpers.dart +++ b/packages/flame/test/collisions/collision_test_helpers.dart @@ -7,7 +7,7 @@ import 'package:meta/meta.dart'; class HasCollidablesGame extends FlameGame with HasCollisionDetection {} @isTest -Future testCollidableGame( +Future testCollisionDetectionGame( String testName, Future Function(HasCollidablesGame) testBody, ) { diff --git a/packages/flame/test/collisions/collision_type_test.dart b/packages/flame/test/collisions/collision_type_test.dart index 0e126f008..41053e775 100644 --- a/packages/flame/test/collisions/collision_type_test.dart +++ b/packages/flame/test/collisions/collision_type_test.dart @@ -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), diff --git a/packages/flame/test/extensions/vector2_test.dart b/packages/flame/test/extensions/vector2_test.dart index db19e3832..0979432e7 100644 --- a/packages/flame/test/extensions/vector2_test.dart +++ b/packages/flame/test/extensions/vector2_test.dart @@ -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)); + }); + }); } diff --git a/packages/flame/test/geometry/ray2_test.dart b/packages/flame/test/geometry/ray2_test.dart index 260d99680..c96a6c8fb 100644 --- a/packages/flame/test/geometry/ray2_test.dart +++ b/packages/flame/test/geometry/ray2_test.dart @@ -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); },