diff --git a/doc/flame/collision_detection.md b/doc/flame/collision_detection.md index cb8848508ef..d47cc0b31e3 100644 --- a/doc/flame/collision_detection.md +++ b/doc/flame/collision_detection.md @@ -298,6 +298,38 @@ follows the outline closely enough for your game. The contour is walked when the so create it once and not in every tick. +### PathHitbox + +A `PathHitbox` follows all the closed contours of a `Path` at once, with one polygon for each of +them, where `PolygonHitbox.fromPath` follows a single contour. The polygons form a single hitbox: +it collides, contains points and is hit by rays as a whole, whichever of its polygons is involved, +and it reports one collision to its parent even when several of its polygons touch the other +hitbox. A ray hits the nearest of the polygons. + +The `PathHitbox` has the same constructor as the [](components/shape_components.md#pathcomponent), +see that section for the `sampling`, `tolerance` and `filter` arguments, and it takes the +`collisionType` like the other hitboxes. When rendered, it draws its polygons and not the path, so +that you can see what actually collides: + +```dart +class Spaceship extends SpriteComponent with CollisionCallbacks { + Spaceship(this.outline); + + final Path outline; + + @override + Future onLoad() async { + await super.onLoad(); + add(PathHitbox(path: outline, sampling: 2)); + } +} +``` + +Every polygon takes part in the collision detection, so a path with many contours or many vertices +costs accordingly. Keep the `filter` on unless the inner contours matter, and use the highest +`sampling` that still follows the outline closely enough. + + ### RectangleHitbox The `RectangleHitbox` has the same constructors as the [](components/shape_components.md#rectanglecomponent), diff --git a/doc/flame/components/shape_components.md b/doc/flame/components/shape_components.md index d33efa8e8ee..dd7698e8d21 100644 --- a/doc/flame/components/shape_components.md +++ b/doc/flame/components/shape_components.md @@ -97,20 +97,22 @@ The component gets the size of the contour, so that curves which reach the bound not cut short. If no `position` is given the polygon ends up where the contour is in the coordinates of the path. -There are two arguments that control how the path is followed: +There are three arguments that control how the path is followed: - `contour`: A path has one contour for each shape that was added to it, and for each `moveTo`. This is the index of the one that the polygon is made from, and it defaults to the first one. An index that the path does not have results in a `RangeError`. - `sampling`: The step that curves are followed with, in the units of the path, which defaults - to `1.0`. The polygon stays within about half of it from the path. A higher value gives fewer - vertices, which makes collision detection and ray casting cheaper, and a lower value follows the - curves more closely. A path that is defined in small units, like meters, needs a sampling that - is small compared to its size. Straight stretches cost the same whatever the sampling is. + to `1.0`. A higher value gives fewer vertices, which makes collision detection and ray casting + cheaper, and a lower value follows the curves more closely. A path that is defined in small + units, like meters, needs a sampling that is small compared to its size. Straight stretches cost + the same whatever the sampling is. +- `tolerance`: How far the polygon may stray from the path, in the units of the path. The samples + that are not needed to stay within it are left out. It defaults to half of the `sampling`. The constructor is built on the `walkContours`, `walkContourAt` and `walkContour` extension methods -on `Path` and `PathMetric`, which return the vertices as lists of `Offset`s. Those also accept a -`tolerance`, in case the simplification of the sampled contour should not follow the sampling. +on `Path` and `PathMetric`, which take the same `sampling` and `tolerance` and return the vertices +as lists of `Offset`s. ```dart void main() { @@ -127,6 +129,41 @@ void main() { ``` +## PathComponent + +When a whole `Path` is needed instead of a single contour, a `PathComponent` renders the path as it +is and follows each of its closed contours with a polygon, in the same way as +`PolygonComponent.fromPath` follows one contour. The polygons decide whether a point is inside of +the component, so taps and drags only count on the shapes of the path and not in the space between +them. The component gets the size of the bounds of the path, and the path is moved so that those +bounds start at the origin of the component, so the anchor and the transforms apply to it like to +any other shape. + +Using the previous two-contour `Path`, a `PathComponent` that renders and covers both shapes is +created like this: + +```dart +void main() { + final path = Path() + ..addOval(const Rect.fromLTWH(0, 0, 100, 60)) + ..addRect(const Rect.fromLTWH(200, 0, 50, 50)); + + final component = PathComponent(path: path); +} +``` + +The `sampling` and `tolerance` arguments control how the contours are followed, see +[](#from-a-path). The vertices of each polygon are available in `polygons`. + +Contours with fewer than three vertices, like open lines, are rendered but do not become polygons. +By default, the polygons whose vertices all lie inside of the largest polygon are left out as well, +since the largest one already covers them; the eyes of a face are an example of this. Pass +`filter: false` to keep every polygon, for example when the inner contours should be hit by rays. + +The `PathHitbox` is the hitbox counterpart of the `PathComponent`, see +[](../collision_detection.md#pathhitbox). + + ## RectangleComponent A `RectangleComponent` is created very similarly to how a `PositionComponent` is created, since it diff --git a/examples/lib/commons/path_component.dart b/examples/lib/commons/path_component.dart deleted file mode 100644 index 7ef0c5126a0..00000000000 --- a/examples/lib/commons/path_component.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:collection/collection.dart'; -import 'package:examples/commons/paths.dart'; -import 'package:flame/collisions.dart'; -import 'package:flame/extensions.dart'; -import 'package:flame/geometry.dart'; - -/// Renders a [Path] and gives it a hitbox for each of its contours. -/// -/// The path is moved so that its bounds start at the origin of the component, -/// which gets the size of those bounds, so that the anchor and the transform -/// of the component apply to the path like to any other shape. -class PathComponent extends ShapeComponent - with CollisionCallbacks, CollisionPassthrough { - PathComponent({ - required Path path, - this.addHitboxes = false, - this.loadHitboxes = true, - this.renderHitboxes = false, - this.filterHitboxes = true, - this.hitboxesPaint, - super.position, - super.scale, - super.angle, - super.anchor, - super.children, - super.priority, - super.key, - super.paint, - super.paintLayers, - }) : path = path.toOrigin, - super(size: path.getBounds().size.toVector2()) { - if (addHitboxes) { - _addHitboxes(); - } - } - - /// The path to display, already rooted at the origin. - final Path path; - - /// Whether the hitboxes are added right away, in the constructor. - final bool addHitboxes; - - /// Whether the hitboxes are added when the component loads. - final bool loadHitboxes; - - /// Whether the hitboxes are rendered or not (the default). - final bool renderHitboxes; - - /// Whether the hitboxes are filtered to include only disjoint ones. - final bool filterHitboxes; - - /// The paint used to render the hitboxes. - final Paint? hitboxesPaint; - - var _hitboxesAdded = false; - late final _hitboxes = _hitboxesFor(path); - - @override - FutureOr onLoad() async { - await super.onLoad(); - if (loadHitboxes) { - _addHitboxes(); - } - } - - @override - void render(Canvas canvas) { - if (renderShape) { - if (hasPaintLayers) { - for (final paint in paintLayers) { - canvas.drawPath(path, paint); - } - } else { - canvas.drawPath(path, paint); - } - } - } - - @override - void renderDebugMode(Canvas canvas) { - super.renderDebugMode(canvas); - canvas.drawPath(path, debugPaint); - } - - void _addHitboxes() { - if (_hitboxesAdded) { - return; - } - addAll(_filterHitboxes(_hitboxes)); - _hitboxesAdded = true; - } - - List _filterHitboxes(List hitboxes) { - if (hitboxes.length < 2) { - return hitboxes; - } - // Sort the hitboxes by size: we will use the largest area in order to - // approximate full inclusion. - hitboxes.sortBy((hitbox) => hitbox.size.length2); - final largest = hitboxes.last; - final area = largest.toRect(); - - // We always keep the largest hitbox: the others are discarded if they fit - // entirely within it. - if (filterHitboxes) { - hitboxes.removeWhere((element) { - if (element == largest) { - return false; - } - return area.expandToInclude(element.toRect()) == area; - }); - } - return hitboxes; - } - - List _hitboxesFor(Path path) { - final count = path.contours.length; - return [ - for (var contour = 0; contour < count; contour++) - PolygonHitbox.fromPath(path, contour: contour) - ..priority = priority + 1 - ..paint = hitboxesPaint ?? whiteStroke - ..renderShape = renderHitboxes, - ]; - } -} diff --git a/examples/lib/commons/paths.dart b/examples/lib/commons/paths.dart index 1f48d85941b..ee2c26409e1 100644 --- a/examples/lib/commons/paths.dart +++ b/examples/lib/commons/paths.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'dart:ui'; -import 'package:examples/commons/path_component.dart'; +import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; import 'package:flame/palette.dart'; @@ -88,6 +88,7 @@ PathComponent pathComponent( List? paintLayers, Paint? contourPaint, bool? renderHitboxes, + bool? filter, Anchor? anchor, }) { // Create a standard test path that fits within our chosen size with its @@ -101,6 +102,7 @@ PathComponent pathComponent( paintLayers: paintLayers, contourPaint: contourPaint, renderHitboxes: renderHitboxes, + filter: filter, anchor: anchor, ); } @@ -114,14 +116,22 @@ PathComponent pathComponentWith( List? paintLayers, Paint? contourPaint, bool? renderHitboxes, + bool? filter, Anchor? anchor, }) { // Adjust the path such that fits within our chosen size with its // original aspect ratio. final path = resize ? srcPath.resizeTo(size, keepRatio: true) : srcPath; - // Create a component that displays the whole path: we filter all hitboxes - // that are (approximately) fully enclosed in the largest one. + // The hitbox follows the same path as the component, so that the component + // collides and reacts to gestures as a whole. By default, the polygons that + // lie inside of the largest one are left out of both. + final hitbox = PathHitbox(path: path, filter: filter ?? true); + if (renderHitboxes ?? false) { + hitbox + ..renderShape = true + ..paint = contourPaint ?? whiteStroke; + } return PathComponent( path: path, priority: shapePriority, @@ -129,7 +139,7 @@ PathComponent pathComponentWith( anchor: anchor ?? Anchor.center, paint: paint ?? pathStroke, paintLayers: paintLayers, - hitboxesPaint: contourPaint, - renderHitboxes: renderHitboxes ?? false, - )..renderShape = true; + filter: filter ?? true, + children: [hitbox], + ); } diff --git a/examples/lib/stories/collision_detection/multiple_shapes_example.dart b/examples/lib/stories/collision_detection/multiple_shapes_example.dart index 23d2dd4287b..846c8a19a2b 100644 --- a/examples/lib/stories/collision_detection/multiple_shapes_example.dart +++ b/examples/lib/stories/collision_detection/multiple_shapes_example.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:examples/commons/path_component.dart'; import 'package:examples/commons/paths.dart'; import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; @@ -198,62 +197,22 @@ class CollidablePolygon extends MyCollidable { } } -class CollidablePath extends MyCollidable with CollisionPassthrough { +class CollidablePath extends MyCollidable { CollidablePath( super.position, super.size, super.velocity, super.screenHitbox, ) { - // The path is centered on the origin, so the hitbox is placed in the - // middle of the component. - final path = randomPath(size.toSize()); - _pathPaint = Paint.from(pathStroke)..color = defaultColor; - _component = pathComponentWith( - path, - size.toSize(), - paint: _pathPaint, - anchor: .center, - ); - add(_component); - } - - @override - bool containsLocalPoint(Vector2 point) { - var result = super.containsLocalPoint(point); - if (!result) { - final area = Rect.fromCenter(center: .zero, width: width, height: height); - result = area.containsPoint(point); - } - return result; - } - - @override - void render(Canvas canvas) { - if (isDragged) { - canvas.drawCircle(.zero, 5, dragIndicatorPaint); - } - } - - @override - void onCollisionStart( - List intersectionPoints, - PositionComponent other, - ) { - super.onCollisionStart(intersectionPoints, other); - _pathPaint.color = other is ScreenHitbox ? screenColor : collisionColor; - } - - @override - void onCollisionEnd(PositionComponent other) { - super.onCollisionEnd(other); - if (!isColliding) { - _pathPaint.color = defaultColor; - } + // The path keeps its aspect ratio within the size, so the hitbox is + // centered in the component. + hitbox = PathHitbox( + path: randomPath(size.toSize()), + position: size / 2, + anchor: Anchor.center, + )..renderShape = true; + add(hitbox!); } - - late final PathComponent _component; - late final Paint _pathPaint; } class CollidableRectangle extends MyCollidable { diff --git a/examples/lib/stories/collision_detection/raytrace_example.dart b/examples/lib/stories/collision_detection/raytrace_example.dart index f4d83e5dfb7..c04751ffb95 100644 --- a/examples/lib/stories/collision_detection/raytrace_example.dart +++ b/examples/lib/stories/collision_detection/raytrace_example.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:examples/commons/path_component.dart'; import 'package:examples/commons/paths.dart'; import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; @@ -43,6 +42,7 @@ bounce on will appear. @override Future onLoad() async { final halfCanvas = Size.square(min(canvasSize.x, canvasSize.y) / 2); + final canvasPath = randomPath(halfCanvas * 2); addAll([ ScreenHitbox(), if (random.nextDouble() <= 0.5) @@ -52,12 +52,14 @@ bounce on will appear. children: [CircleHitbox()], ) else + // The rays should bounce on the inner contours too, so the hitbox + // keeps every polygon of the path. PathComponent( - path: randomPath(halfCanvas * 2), + path: canvasPath, position: halfCanvas.toVector2(), anchor: .center, paint: boxPaint, - filterHitboxes: false, + children: [PathHitbox(path: canvasPath, filter: false)], ), ]); } @@ -73,6 +75,7 @@ bounce on will appear. } _timePassed = 0; if (extraChildren.isEmpty) { + final extraPath = path; addAll( extraChildren..addAll( [ @@ -97,11 +100,11 @@ bounce on will appear. children: [CircleHitbox()], ), PathComponent( - path: path, + path: extraPath, position: Vector2.all(350), anchor: Anchor.center, paint: boxPaint, - filterHitboxes: false, + children: [PathHitbox(path: extraPath, filter: false)], ), RectangleComponent( position: Vector2.all(500), diff --git a/examples/lib/stories/input/gesture_hitboxes_example.dart b/examples/lib/stories/input/gesture_hitboxes_example.dart index 244f81c0f21..25c8c6556e6 100644 --- a/examples/lib/stories/input/gesture_hitboxes_example.dart +++ b/examples/lib/stories/input/gesture_hitboxes_example.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:examples/commons/path_component.dart'; import 'package:examples/commons/paths.dart'; import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; @@ -92,11 +91,19 @@ class MyPathComponent extends PathComponent super.position, super.scale, super.angle, - }) : super(anchor: .center, renderHitboxes: true); + }) : super(anchor: .center) { + // The hitbox follows the same path, and it is rendered so that you can + // see the polygons that receive the gestures. + add( + PathHitbox(path: path) + ..renderShape = true + ..paint = whiteStroke, + ); + } @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); baseColor = ColorExtension.random(withAlpha: 0.8, base: 100); paint.color = baseColor; } @@ -131,7 +138,7 @@ class MyShapeComponent extends PositionComponent @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); baseColor = ColorExtension.random(withAlpha: 0.8, base: 100); hitbox.paint.color = baseColor; hitbox.renderShape = true; diff --git a/packages/flame/lib/collisions.dart b/packages/flame/lib/collisions.dart index d7c3a21c2d2..9f2633a249b 100644 --- a/packages/flame/lib/collisions.dart +++ b/packages/flame/lib/collisions.dart @@ -12,6 +12,7 @@ export 'src/collisions/collision_passthrough.dart'; export 'src/collisions/hitboxes/circle_hitbox.dart'; export 'src/collisions/hitboxes/composite_hitbox.dart'; export 'src/collisions/hitboxes/hitbox.dart'; +export 'src/collisions/hitboxes/path_hitbox.dart'; export 'src/collisions/hitboxes/polygon_hitbox.dart'; export 'src/collisions/hitboxes/rectangle_hitbox.dart'; export 'src/collisions/hitboxes/screen_hitbox.dart'; diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index c674fd5fc04..ef569a3f3fe 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -57,6 +57,7 @@ export 'src/components/timer_component.dart'; export 'src/components/widget_component.dart'; export 'src/extensions/vector2.dart'; export 'src/geometry/circle_component.dart'; +export 'src/geometry/path_component.dart'; export 'src/geometry/polygon_component.dart'; export 'src/geometry/rectangle_component.dart'; export 'src/geometry/shape_component.dart'; diff --git a/packages/flame/lib/geometry.dart b/packages/flame/lib/geometry.dart index cc9d7f4d204..7d60a1b2048 100644 --- a/packages/flame/lib/geometry.dart +++ b/packages/flame/lib/geometry.dart @@ -2,6 +2,7 @@ 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/path_component.dart'; export 'src/geometry/polygon_component.dart'; export 'src/geometry/polygon_ray_intersection.dart'; export 'src/geometry/ray2.dart'; diff --git a/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart new file mode 100644 index 00000000000..bff2b576eb4 --- /dev/null +++ b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart @@ -0,0 +1,124 @@ +import 'package:flame/collisions.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/geometry.dart'; +import 'package:meta/meta.dart'; + +/// A [Hitbox] in the shape of all the closed contours of a [Path]. +/// +/// The polygons form a single hitbox: it collides, contains points and is hit +/// by rays as a whole, whichever of its polygons is involved. See +/// [PathComponent] for how the polygons are made from the path. +class PathHitbox extends PathComponent with ShapeHitbox { + /// With this constructor you create a [PathHitbox] from all the closed + /// contours of the [path]. + /// + /// See [PathComponent.new] for the [sampling], [tolerance] and [filter] + /// parameters. Fewer vertices make the collision detection cheaper, so use + /// the highest sampling that still follows the path closely enough. + PathHitbox({ + required super.path, + super.sampling, + super.tolerance, + super.filter, + super.position, + super.angle, + super.anchor, + super.isSolid, + CollisionType collisionType = CollisionType.active, + }) { + this.collisionType = collisionType; + } + + late final _temporaryNormal = Vector2.zero(); + late final _temporaryResult = RaycastResult(); + late final _closestResult = RaycastResult(); + + /// Renders the polygons of the hitbox, since those are what collides. + @override + void render(Canvas canvas) { + if (renderShape) { + if (hasPaintLayers) { + for (final paint in paintLayers) { + canvas.drawPath(polygonsPath, paint); + } + } else { + canvas.drawPath(polygonsPath, paint); + } + } + } + + /// Returns information about how the ray intersects the closest of the + /// polygons. + /// + /// If you are only interested in the intersection point use + /// [RaycastResult.intersectionPoint] of the result. + @override + RaycastResult? rayIntersection( + Ray2 ray, { + RaycastResult? out, + }) { + var closestDistance = double.infinity; + for (final vertices in globalPolygons()) { + final result = PolygonRayIntersection.intersectPolygon( + ray, + vertices, + hitbox: this, + normal: _temporaryNormal, + out: _temporaryResult, + ); + final distance = result?.distance; + if (distance != null && distance < closestDistance) { + closestDistance = distance; + _closestResult.setFrom(result!); + } + } + if (closestDistance.isInfinite) { + out?.reset(); + return null; + } + return (out ?? RaycastResult())..setFrom(_closestResult); + } + + @override + void fillParent() { + throw UnsupportedError( + 'Use the RectangleHitbox if you want to fill the parent', + ); + } + + /// Computes the [aabb] from the vertices of all the polygons. + @override + @protected + void computeAabb(Aabb2 aabb) { + final polygons = globalPolygons(); + if (polygons.isEmpty) { + super.computeAabb(aabb); + return; + } + final first = polygons.first.first; + var minX = first.x; + var minY = first.y; + var maxX = first.x; + var maxY = first.y; + for (final vertices in polygons) { + for (final v in vertices) { + if (v.x < minX) { + minX = v.x; + } + if (v.y < minY) { + minY = v.y; + } + if (v.x > maxX) { + maxX = v.x; + } + if (v.y > maxY) { + maxY = v.y; + } + } + } + // Add a small epsilon since points on the AABB edge are counted as outside. + const epsilon = 0.000000000000001; + aabb.min.setValues(minX - epsilon, minY - epsilon); + aabb.max.setValues(maxX + epsilon, maxY + epsilon); + } +} diff --git a/packages/flame/lib/src/collisions/hitboxes/polygon_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/polygon_hitbox.dart index 46e833f6c62..8476a790dc6 100644 --- a/packages/flame/lib/src/collisions/hitboxes/polygon_hitbox.dart +++ b/packages/flame/lib/src/collisions/hitboxes/polygon_hitbox.dart @@ -61,13 +61,16 @@ class PolygonHitbox extends PolygonComponent /// [sampling] of the contour are left out. Higher values give fewer vertices, /// which makes the collision detection cheaper, and a looser fit, while /// straight stretches and the corners between them are exact whatever the - /// [sampling] is. + /// [sampling] is. The given [tolerance] decides what samples to discard: + /// by default, its value is half the [sampling]. /// - /// See [PathMetricExtension.walkContour] for the details of the sampling. + /// See [PathMetricExtension.walkContour] for the details of the [sampling] + /// and [tolerance] parameters. PolygonHitbox.fromPath( super.path, { super.contour, super.sampling, + super.tolerance, super.position, super.angle, super.anchor, diff --git a/packages/flame/lib/src/experimental/geometry/shapes/polygon.dart b/packages/flame/lib/src/experimental/geometry/shapes/polygon.dart index 17182c9f259..337862ed682 100644 --- a/packages/flame/lib/src/experimental/geometry/shapes/polygon.dart +++ b/packages/flame/lib/src/experimental/geometry/shapes/polygon.dart @@ -42,15 +42,18 @@ class Polygon extends Shape { /// and the samples that are not needed to stay within about half of the /// [sampling] of the contour are left out. Higher values give fewer vertices /// and a looser fit, while straight stretches and the corners between them - /// are exact whatever the [sampling] is. + /// are exact whatever the [sampling] is. The given [tolerance] decides + /// what samples to discard: by default, its value is half the [sampling]. /// - /// See [PathMetricExtension.walkContour] for the details of the sampling. + /// See [PathMetricExtension.walkContour] for the details of the [sampling] + /// and [tolerance] parameters. factory Polygon.fromPath( Path path, { int contour = 0, double sampling = 1.0, + double? tolerance, }) { - return Polygon(path.walkContourAt(contour, sampling).vertices); + return Polygon(path.walkContourAt(contour, sampling, tolerance).vertices); } /// The vertices (corners) of the polygon. diff --git a/packages/flame/lib/src/extensions/path.dart b/packages/flame/lib/src/extensions/path.dart index 99eb7f86250..d8dc00774a9 100644 --- a/packages/flame/lib/src/extensions/path.dart +++ b/packages/flame/lib/src/extensions/path.dart @@ -142,12 +142,12 @@ extension PathMetricExtension on PathMetric { if (length <= 0) { return []; } - final validGranularity = sampling.isFinite && sampling > 0 ? sampling : 1.0; + final validSampling = sampling.isFinite && sampling > 0 ? sampling : 1.0; // A closed contour is sampled in at least three steps, so that it can be a // polygon no matter how coarse the sampling is. final step = isClosed - ? min(max(validGranularity, length / _maxSteps), length / 3) - : max(validGranularity, length / _maxSteps); + ? min(max(validSampling, length / _maxSteps), length / 3) + : max(validSampling, length / _maxSteps); final maxDeviation = tolerance ?? step / 2; final sampler = _ContourSampler(this, step, maxDeviation / 6)..sample(); final points = _simplify( diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart new file mode 100644 index 00000000000..d0bb411a4e1 --- /dev/null +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -0,0 +1,263 @@ +import 'package:collection/collection.dart'; +import 'package:flame/cache.dart'; +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/geometry.dart'; +import 'package:meta/meta.dart'; + +/// Renders a [Path] and gives it a polygon for each of its closed contours. +/// +/// The path is moved so that its bounds start at the origin of the component, +/// which gets the size of those bounds, so that the anchor and the transform +/// of the component apply to the path like to any other shape. +/// +/// The polygons follow the contours with straight edges, in the same way as +/// [PolygonComponent.fromPath] follows a single contour, and they decide +/// whether a point is inside of the component. Their vertices are available +/// in [polygons]. +class PathComponent extends ShapeComponent { + /// With this constructor you create a [PathComponent] from all the contours + /// of the [path]. + /// + /// The contours are sampled every [sampling] along their length, and the + /// samples that are not needed to stay within the [tolerance] of the contour + /// are left out; by default, the tolerance is half the [sampling]. See + /// [PathMetricExtension.walkContour] for the details of both parameters. + /// + /// Contours that end up with fewer than three vertices, like open lines, do + /// not become polygons. When [filter] is true, the polygons whose vertices + /// all lie inside of the largest polygon are left out too, since they are + /// details of the shape that it already covers, like the eyes of a face. + PathComponent({ + required Path path, + this.sampling = 1.0, + this.tolerance, + this.filter = true, + super.position, + super.scale, + super.angle, + super.anchor, + super.children, + super.priority, + super.key, + super.paint, + super.paintLayers, + super.isSolid, + }) : path = path.toOrigin, + super(size: path.getBounds().size.toVector2()) { + _polygons = _polygonsOf( + this.path, + sampling: sampling, + tolerance: tolerance, + filter: filter, + ); + _globalPolygons = [ + for (final polygon in _polygons) + List.generate(polygon.length, (_) => Vector2.zero(), growable: false), + ]; + _lineSegments = [ + for (final polygon in _polygons) + List.generate( + polygon.length, + (_) => LineSegment.zero(), + growable: false, + ), + ]; + for (final polygon in _polygons) { + _polygonsPath.addPolygon( + polygon.map((vertex) => vertex.toOffset()).toList(growable: false), + true, + ); + } + } + + /// The path to display, already rooted at the origin. + final Path path; + + /// The step used when sampling the contours of the [path]. + final double sampling; + + /// The tolerance used when simplifying the sampled contours; if not given, + /// it is half the [sampling]. + final double? tolerance; + + /// Whether the polygons that lie inside of the largest one are left out. + final bool filter; + + late final List> _polygons; + + /// The vertices of each polygon, in the local coordinates of the component. + /// + /// There is one polygon for each closed contour of the [path], unless it was + /// left out by the [filter], and the vertices of each one go + /// counterclockwise in the screen coordinate system. + UnmodifiableListView> get polygons => + UnmodifiableListView([ + for (final polygon in _polygons) UnmodifiableListView(polygon), + ]); + + // These lists are used to minimize the amount of objects that are created, + // and only change the contained objects if the corresponding `ValueCache` is + // deemed outdated. + late final List> _globalPolygons; + late final List> _lineSegments; + final Path _polygonsPath = Path(); + + final _cachedGlobalPolygons = ValueCache>>(); + + /// The vertices of each polygon in the global coordinate system, see + /// [PolygonComponent.globalVertices]. + List> globalPolygons() { + final scale = absoluteScale; + final shouldReverse = scale.y.isNegative ^ scale.x.isNegative; + final angle = absoluteAngle; + final position = absoluteTopLeftPosition; + if (!_cachedGlobalPolygons.isCacheValid([ + position, + size, + scale, + angle, + ])) { + for (var i = 0; i < _polygons.length; i++) { + final polygon = _polygons[i]; + final globalPolygon = _globalPolygons[i]; + for (var j = 0; j < polygon.length; j++) { + globalPolygon[j].setFrom(absolutePositionOf(polygon[j])); + } + if (shouldReverse) { + // Since the list will be clockwise we have to reverse it for it to + // become counterclockwise. + globalPolygon.reverse(); + } + } + _cachedGlobalPolygons.updateCache(_globalPolygons, [ + position.clone(), + size.clone(), + scale.clone(), + angle, + ]); + } + return _cachedGlobalPolygons.value!; + } + + @override + void render(Canvas canvas) { + if (renderShape) { + if (hasPaintLayers) { + for (final paint in paintLayers) { + canvas.drawPath(path, paint); + } + } else { + canvas.drawPath(path, paint); + } + } + } + + @override + void renderDebugMode(Canvas canvas) { + super.renderDebugMode(canvas); + canvas.drawPath(_polygonsPath, debugPaint); + } + + /// The [polygons] as a single [Path], in the local coordinates of the + /// component. + @protected + Path get polygonsPath => _polygonsPath; + + bool _containsPoint(Vector2 point, List> polygons) { + // If the size is 0 then it can't contain any points + if (size.x == 0 || size.y == 0) { + return false; + } + for (final polygon in polygons) { + if (PolygonComponent.polygonContainsPoint(point, polygon)) { + return true; + } + } + return false; + } + + /// Whether any of the polygons contains the [point], which is in the global + /// coordinate system. + @override + bool containsPoint(Vector2 point) { + return _containsPoint(point, globalPolygons()); + } + + /// Whether any of the polygons contains the [point], which is in the local + /// coordinate system of the component. + @override + bool containsLocalPoint(Vector2 point) { + return _containsPoint(point, _polygons); + } + + /// Return all edges of all polygons as [LineSegment]s that intersect [rect], + /// if [rect] is null return all edges as [LineSegment]s. + List possibleIntersectionVertices(Rect? rect) { + final rectIntersections = []; + if ((rect?.width == 0 || false) || + (rect?.height == 0 || false) || + width == 0 || + height == 0) { + return rectIntersections; + } + final polygons = globalPolygons(); + for (var i = 0; i < polygons.length; i++) { + final vertices = polygons[i]; + final lineSegments = _lineSegments[i]; + for (var j = 0; j < vertices.length; j++) { + final edge = lineSegments[j] + ..from.setFrom(vertices[j]) + ..to.setFrom(vertices[(j + 1) % vertices.length]); + if (rect?.intersectsSegment(edge.from, edge.to) ?? true) { + rectIntersections.add(edge); + } + } + } + return rectIntersections; + } + + /// Returns the polygon of each closed contour of the [path] with at least + /// three vertices, with the vertices going counterclockwise. + static List> _polygonsOf( + Path path, { + required double sampling, + required double? tolerance, + required bool filter, + }) { + final polygons = >[]; + for (final metric in path.computeMetrics()) { + if (!metric.isClosed) { + continue; + } + final contour = metric.walkContour(sampling, tolerance); + if (contour.length > 2) { + final vertices = contour.vertices; + if (PolygonComponent.isClockwise(vertices)) { + vertices.reverse(); + } + polygons.add(vertices); + } + } + if (filter && polygons.length > 1) { + final largest = polygons.reduce((a, b) => _area(a) >= _area(b) ? a : b); + final largestPath = Path() + ..addPolygon( + largest.map((vertex) => vertex.toOffset()).toList(growable: false), + true, + ); + polygons.removeWhere( + (polygon) => + polygon != largest && + polygon.every((vertex) => largestPath.contains(vertex.toOffset())), + ); + } + return polygons; + } + + /// The area of the bounds of the polygon with the given [vertices]. + static double _area(List vertices) { + final bounds = RectExtension.getBounds(vertices); + return bounds.width * bounds.height; + } +} diff --git a/packages/flame/lib/src/geometry/polygon_component.dart b/packages/flame/lib/src/geometry/polygon_component.dart index a615c1a6b56..73f79bb56f7 100644 --- a/packages/flame/lib/src/geometry/polygon_component.dart +++ b/packages/flame/lib/src/geometry/polygon_component.dart @@ -102,13 +102,16 @@ class PolygonComponent extends ShapeComponent { /// and the samples that are not needed to stay within about half of the /// [sampling] of the contour are left out. Higher values give fewer vertices /// and a looser fit, while straight stretches and the corners between them - /// are exact whatever the [sampling] is. + /// are exact whatever the [sampling] is. The given [tolerance] decides + /// what samples to discard: by default, its value is half the [sampling]. /// - /// See [PathMetricExtension.walkContour] for the details of the sampling. + /// See [PathMetricExtension.walkContour] for the details of the [sampling] + /// and [tolerance] parameters. PolygonComponent.fromPath( Path path, { int contour = 0, double sampling = 1.0, + double? tolerance, Vector2? position, Vector2? scale, double? angle, @@ -121,7 +124,7 @@ class PolygonComponent extends ShapeComponent { ComponentKey? key, List? children, }) : this( - path.walkContourAt(contour, sampling).vertices, + path.walkContourAt(contour, sampling, tolerance).vertices, position: position, angle: angle, anchor: anchor, @@ -196,7 +199,7 @@ class PolygonComponent extends ShapeComponent { ); // If the list isn't ccw we have to reverse the order in order for // `containsPoint` to work. - if (_isClockwise(newVertices)) { + if (isClockwise(newVertices)) { newVertices.reverse(); } final topLeft = Vector2.zero(); @@ -271,7 +274,16 @@ class PolygonComponent extends ShapeComponent { if (size.x == 0 || size.y == 0) { return false; } + return polygonContainsPoint(point, vertices); + } + /// Whether the polygon with the given [vertices] contains the [point], where + /// a point on an edge counts as contained. + /// + /// The polygon can be either convex or concave, since the check counts the + /// edges that are crossed by going left from the point. + @internal + static bool polygonContainsPoint(Vector2 point, List vertices) { // Count the amount of edges crossed by going left from the point var count = 0; for (var i = 0; i < vertices.length; i++) { @@ -362,7 +374,10 @@ class PolygonComponent extends ShapeComponent { } } - bool _isClockwise(List vertices) { + /// Whether the given [vertices] go clockwise in the screen coordinate + /// system, where the y axis points down. + @internal + static bool isClockwise(List vertices) { var area = 0.0; for (var i = 0; i < vertices.length; i++) { final j = (i + 1) % vertices.length; diff --git a/packages/flame/lib/src/geometry/polygon_ray_intersection.dart b/packages/flame/lib/src/geometry/polygon_ray_intersection.dart index 5bce4c389bd..c24022f4a55 100644 --- a/packages/flame/lib/src/geometry/polygon_ray_intersection.dart +++ b/packages/flame/lib/src/geometry/polygon_ray_intersection.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; import 'package:flame/geometry.dart'; +import 'package:meta/meta.dart'; /// Used to add the [rayIntersection] method to [RectangleHitbox] and /// [PolygonHitbox], used by the raytracing and raycasting methods. @@ -20,7 +21,28 @@ mixin PolygonRayIntersection on PolygonComponent { Ray2 ray, { RaycastResult? out, }) { - final vertices = globalVertices(); + return intersectPolygon( + ray, + globalVertices(), + hitbox: this as T, + normal: _temporaryNormal, + out: out, + ); + } + + /// Returns the [RaycastResult] of the [ray] against the polygon with the + /// given global [vertices], which is reported as a hit on [hitbox]. + /// + /// The [normal] is used as scratch space for the normal of the hit edge, and + /// [out] is populated and returned when it is given, see [rayIntersection]. + @internal + static RaycastResult? intersectPolygon( + Ray2 ray, + List vertices, { + required ShapeHitbox hitbox, + required Vector2 normal, + RaycastResult? out, + }) { var closestDistance = double.infinity; Vector2? closestFrom; Vector2? closestTo; @@ -71,20 +93,20 @@ mixin PolygonRayIntersection on PolygonComponent { ); // This is "from" to "to" since it is defined ccw in the canvas // coordinate system - _temporaryNormal + normal ..setFrom(closestFrom) ..sub(closestTo); - _temporaryNormal - ..setValues(_temporaryNormal.y, -_temporaryNormal.x) + normal + ..setValues(normal.y, -normal.x) ..normalize(); final isInsideHitbox = crossings.isOdd; if (isInsideHitbox) { - _temporaryNormal.invert(); + normal.invert(); } final reflectionDirection = (out?.reflectionRay?.direction ?? Vector2.zero()) ..setFrom(ray.direction) - ..reflect(_temporaryNormal); + ..reflect(normal); // Reflect() can introduce sub-epsilon drift. Normalize to keep Ray2's // unit-length assertion satisfied. reflectionDirection.normalize(); @@ -96,9 +118,9 @@ mixin PolygonRayIntersection on PolygonComponent { )) ?? Ray2(origin: intersectionPoint, direction: reflectionDirection); return (out ?? RaycastResult())..setWith( - hitbox: this as T, + hitbox: hitbox, reflectionRay: reflectionRay, - normal: _temporaryNormal, + normal: normal, distance: closestDistance, isInsideHitbox: isInsideHitbox, ); diff --git a/packages/flame/lib/src/geometry/shape_intersections.dart b/packages/flame/lib/src/geometry/shape_intersections.dart index 9bd49d4210b..ac538846215 100644 --- a/packages/flame/lib/src/geometry/shape_intersections.dart +++ b/packages/flame/lib/src/geometry/shape_intersections.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flame/extensions.dart'; import 'package:flame/geometry.dart'; @@ -28,6 +29,135 @@ abstract class Intersections< } } +/// The distinct intersection points of the [edgesA] and the [edgesB]. +List _edgeIntersections( + List edgesA, + List edgesB, +) { + final intersectionPoints = []; + for (final lineA in edgesA) { + for (final lineB in edgesB) { + for (final intersection in lineA.intersections(lineB)) { + if (!intersectionPoints.contains(intersection)) { + intersectionPoints.add(intersection); + } + } + } + } + return intersectionPoints; +} + +/// The center of the shape that is enclosed by the other, solid, shape, or +/// nothing when neither shape encloses the other or the outer one is hollow. +/// +/// Whether a shape is enclosed is decided by [pointA] and [pointB], which are +/// a point of [shapeA] and of [shapeB] respectively. +List _enclosedCenter( + ShapeComponent shapeA, + Vector2? pointA, + ShapeComponent shapeB, + Vector2? pointB, +) { + if (!shapeA.isSolid && !shapeB.isSolid) { + return []; + } + final ShapeComponent? outerShape; + if (pointB != null && shapeA.containsPoint(pointB)) { + outerShape = shapeA; + } else if (pointA != null && shapeB.containsPoint(pointA)) { + outerShape = shapeB; + } else { + outerShape = null; + } + if (outerShape != null && outerShape.isSolid) { + final innerShape = outerShape == shapeA ? shapeB : shapeA; + return [innerShape.absoluteCenter]; + } + return []; +} + +/// A vertex of the [path], or null when it has no polygons. +Vector2? _vertexOf(PathComponent path) { + return path.globalPolygons().firstOrNull?.first; +} + +class PathPathIntersections + extends Intersections { + /// Returns the intersection points of the edges of all the polygons of + /// [pathA] and [pathB]. + @override + List intersect( + PathComponent pathA, + PathComponent pathB, { + Rect? overlappingRect, + }) { + final intersectionPoints = _edgeIntersections( + pathA.possibleIntersectionVertices(overlappingRect), + pathB.possibleIntersectionVertices(overlappingRect), + ); + if (intersectionPoints.isEmpty) { + return _enclosedCenter(pathA, _vertexOf(pathA), pathB, _vertexOf(pathB)); + } + return intersectionPoints; + } +} + +class PathPolygonIntersections + extends Intersections { + /// Returns the intersection points of the edges of all the polygons of + /// [path] and the edges of [polygon]. + @override + List intersect( + PathComponent path, + PolygonComponent polygon, { + Rect? overlappingRect, + }) { + final intersectionPoints = _edgeIntersections( + path.possibleIntersectionVertices(overlappingRect), + polygon.possibleIntersectionVertices(overlappingRect), + ); + if (intersectionPoints.isEmpty) { + return _enclosedCenter( + path, + _vertexOf(path), + polygon, + polygon.globalVertices().first, + ); + } + return intersectionPoints; + } +} + +class CirclePathIntersections + extends Intersections { + /// Returns the intersection points of [circle] and the edges of all the + /// polygons of [path]. + @override + List intersect( + CircleComponent circle, + PathComponent path, { + Rect? overlappingRect, + }) { + final intersectionPoints = []; + for (final line in path.possibleIntersectionVertices(overlappingRect)) { + for (final intersection in circle.lineSegmentIntersections(line)) { + if (!intersectionPoints.contains(intersection)) { + intersectionPoints.add(intersection); + } + } + } + if (intersectionPoints.isEmpty) { + return _enclosedCenter( + circle, + circle.absoluteCenter, + path, + _vertexOf(path), + ); + } + return intersectionPoints; + } +} + class PolygonPolygonIntersections extends Intersections { /// Returns the intersection points of [polygonA] and [polygonB] @@ -198,6 +328,9 @@ final List _intersectionSystems = [ CircleCircleIntersections(), CirclePolygonIntersections(), PolygonPolygonIntersections(), + CirclePathIntersections(), + PathPolygonIntersections(), + PathPathIntersections(), ]; List intersections( diff --git a/packages/flame/test/collisions/path_hitbox_test.dart b/packages/flame/test/collisions/path_hitbox_test.dart new file mode 100644 index 00000000000..8c428256bb6 --- /dev/null +++ b/packages/flame/test/collisions/path_hitbox_test.dart @@ -0,0 +1,224 @@ +import 'dart:ui'; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/geometry.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +import 'collision_test_helpers.dart'; + +/// Two squares of side 10, with a gap of 10 between them. +Path _twoSquares() { + return Path() + ..addRect(const Rect.fromLTWH(0, 0, 10, 10)) + ..addRect(const Rect.fromLTWH(20, 0, 10, 10)); +} + +TestBlock _pathBlock(Vector2 position, {bool isSolid = false}) { + return TestBlock(position, Vector2(30, 10), addTestHitbox: false) + ..add(PathHitbox(path: _twoSquares(), isSolid: isSolid)); +} + +void main() { + group('PathHitbox', () { + test('has one polygon per closed contour', () { + final hitbox = PathHitbox(path: _twoSquares()); + + expect(hitbox.polygons.length, 2); + }); + + test('takes the collision type', () { + final hitbox = PathHitbox( + path: _twoSquares(), + collisionType: CollisionType.passive, + ); + + expect(hitbox.collisionType, CollisionType.passive); + }); + + test('can not fill its parent', () { + final hitbox = PathHitbox(path: _twoSquares()); + + expect(hitbox.fillParent, throwsUnsupportedError); + }); + + test('contains the points of its polygons and not the gap', () { + final hitbox = PathHitbox(path: _twoSquares()); + + expect(hitbox.containsLocalPoint(Vector2(5, 5)), isTrue); + expect(hitbox.containsLocalPoint(Vector2(25, 5)), isTrue); + expect(hitbox.containsLocalPoint(Vector2(15, 5)), isFalse); + }); + + runCollisionTestRegistry({ + 'the aabb covers all the polygons': (game) async { + final block = _pathBlock(Vector2(100, 100)); + final hitbox = block.firstChild()!; + await game.ensureAdd(block); + game.update(0); + + expect(hitbox.aabb.min, closeToVector(Vector2(100, 100), 0.01)); + expect(hitbox.aabb.max, closeToVector(Vector2(130, 110), 0.01)); + }, + 'the aabb follows the hitbox': (game) async { + final block = _pathBlock(Vector2(100, 100)); + final hitbox = block.firstChild()!; + await game.ensureAdd(block); + game.update(0); + block.position = Vector2(200, 200); + game.update(0); + + expect(hitbox.aabb.min, closeToVector(Vector2(200, 200), 0.01)); + expect(hitbox.aabb.max, closeToVector(Vector2(230, 210), 0.01)); + }, + 'contains the global points of its polygons': (game) async { + final block = _pathBlock(Vector2(100, 100)); + final hitbox = block.firstChild()!; + await game.ensureAdd(block); + game.update(0); + + expect(hitbox.containsPoint(Vector2(105, 105)), isTrue); + expect(hitbox.containsPoint(Vector2(125, 105)), isTrue); + expect(hitbox.containsPoint(Vector2(115, 105)), isFalse); + expect(hitbox.containsPoint(Vector2(95, 105)), isFalse); + }, + 'does not collide with itself': (game) async { + final block = _pathBlock(Vector2.zero()); + await game.ensureAdd(block); + game.update(0); + game.update(0); + + expect(block.startCounter, 0); + expect(block.isColliding, isFalse); + }, + 'collides with a circle that touches the second polygon': (game) async { + final block = _pathBlock(Vector2.zero()); + final circle = TestBlock( + Vector2(25, 5), + Vector2.all(10), + addTestHitbox: false, + )..add(CircleHitbox()); + await game.ensureAddAll([block, circle]); + game.update(0); + + expect(block.collidingWith(circle), isTrue); + expect(circle.collidingWith(block), isTrue); + expect(block.startCounter, 1); + expect(circle.startCounter, 1); + }, + 'does not collide with a circle in the gap': (game) async { + final block = _pathBlock(Vector2.zero()); + final circle = TestBlock( + Vector2(12.5, 2.5), + Vector2.all(5), + addTestHitbox: false, + )..add(CircleHitbox()); + await game.ensureAddAll([block, circle]); + game.update(0); + + expect(block.collidingWith(circle), isFalse); + expect(block.startCounter, 0); + expect(circle.startCounter, 0); + }, + 'collides with a rectangle that touches the second polygon': + (game) async { + final block = _pathBlock(Vector2.zero()); + final rectangle = TestBlock(Vector2(25, 5), Vector2.all(10)); + await game.ensureAddAll([block, rectangle]); + game.update(0); + + expect(block.collidingWith(rectangle), isTrue); + expect(rectangle.collidingWith(block), isTrue); + expect(block.startCounter, 1); + expect(rectangle.startCounter, 1); + }, + 'collides with another path': (game) async { + final blockA = _pathBlock(Vector2.zero()); + final blockB = _pathBlock(Vector2(25, 5)); + await game.ensureAddAll([blockA, blockB]); + game.update(0); + + expect(blockA.collidingWith(blockB), isTrue); + expect(blockB.collidingWith(blockA), isTrue); + expect(blockA.startCounter, 1); + expect(blockB.startCounter, 1); + }, + 'reports one collision when several polygons touch the other hitbox': + (game) async { + final block = _pathBlock(Vector2.zero()); + final rectangle = TestBlock(Vector2(5, 5), Vector2(20, 10)); + await game.ensureAddAll([block, rectangle]); + game.update(0); + + expect(block.startCounter, 1); + expect(rectangle.startCounter, 1); + expect(block.activeCollisions.length, 1); + expect(rectangle.activeCollisions.length, 1); + }, + 'reports the intersection points of all the polygons': (game) async { + final block = _pathBlock(Vector2.zero()); + final hitbox = block.firstChild()!; + final rectangle = TestBlock(Vector2(5, 5), Vector2(20, 10)); + await game.ensureAddAll([block, rectangle]); + game.update(0); + + final points = hitbox.intersections(rectangle.hitbox); + expect(points, containsAll([Vector2(10, 5), Vector2(20, 5)])); + }, + 'a hollow path does not collide with a shape inside of a polygon': + (game) async { + final block = _pathBlock(Vector2.zero()); + final rectangle = TestBlock(Vector2(2, 2), Vector2.all(6)); + await game.ensureAddAll([block, rectangle]); + game.update(0); + + expect(block.collidingWith(rectangle), isFalse); + }, + 'a solid path collides with a shape inside of a polygon': (game) async { + final block = _pathBlock(Vector2.zero(), isSolid: true); + final rectangle = TestBlock(Vector2(2, 2), Vector2.all(6)); + await game.ensureAddAll([block, rectangle]); + game.update(0); + + expect(block.collidingWith(rectangle), isTrue); + }, + 'a ray hits the nearest polygon': (game) async { + final block = _pathBlock(Vector2.zero()); + final hitbox = block.firstChild()!; + await game.ensureAdd(block); + game.update(0); + + final fromTheLeft = game.collisionDetection.raycast( + Ray2(origin: Vector2(-5, 5), direction: Vector2(1, 0)), + ); + expect(fromTheLeft?.hitbox, hitbox); + expect(fromTheLeft?.distance, closeTo(5, 0.01)); + expect(fromTheLeft?.isInsideHitbox, isFalse); + + final fromTheRight = game.collisionDetection.raycast( + Ray2(origin: Vector2(35, 5), direction: Vector2(-1, 0)), + ); + expect(fromTheRight?.hitbox, hitbox); + expect(fromTheRight?.distance, closeTo(5, 0.01)); + + final fromTheGap = game.collisionDetection.raycast( + Ray2(origin: Vector2(15, 5), direction: Vector2(1, 0)), + ); + expect(fromTheGap?.distance, closeTo(5, 0.01)); + expect(fromTheGap?.isInsideHitbox, isFalse); + + final fromInside = game.collisionDetection.raycast( + Ray2(origin: Vector2(5, 5), direction: Vector2(1, 0)), + ); + expect(fromInside?.distance, closeTo(5, 0.01)); + expect(fromInside?.isInsideHitbox, isTrue); + + final miss = game.collisionDetection.raycast( + Ray2(origin: Vector2(-5, 15), direction: Vector2(1, 0)), + ); + expect(miss, isNull); + }, + }); + }); +} diff --git a/packages/flame/test/geometry/path_component_test.dart b/packages/flame/test/geometry/path_component_test.dart new file mode 100644 index 00000000000..f18e728a2bd --- /dev/null +++ b/packages/flame/test/geometry/path_component_test.dart @@ -0,0 +1,132 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame_test/test_paths.dart'; +import 'package:test/test.dart'; + +void main() { + group('PathComponent', () { + test('roundRect preserves the path bounds', () { + const size = Size(64, 64); + final path = TestPaths.byName('roundRect', size); + + final pathComponent = PathComponent(path: path); + + expect(pathComponent.width, closeTo(size.width, 1e-10)); + expect(pathComponent.height, closeTo(size.height, 1e-10)); + }); + + test('flame preserves the path bounds', () { + const size = Size(64, 64); + final path = TestPaths.byName('flame', size); + final pathSize = path.getBounds().size; + + final pathComponent = PathComponent(path: path); + + expect(pathComponent.width, pathSize.width); + expect(pathComponent.height, pathSize.height); + }); + + test('invader1 preserves the aspect ratio', () { + const size = Size(64, 64); + final path = TestPaths.byName('invader1', size); + + final invader1 = TestPaths.invader1(); + final invader1Size = invader1.getBounds().size; + final scaleX = size.width / invader1Size.width; + final scaleY = size.height / invader1Size.height; + final scale = min(scaleX, scaleY); + + final pathComponent = PathComponent(path: path); + expect(pathComponent.width, closeTo(invader1Size.width * scale, 1e-6)); + expect(pathComponent.height, closeTo(invader1Size.height * scale, 1e-6)); + }); + + test('invader2 keeps only one disjoint contour', () { + const size = Size(64, 64); + final path = TestPaths.byName('invader2', size); + final pathComponent = PathComponent(path: path); + + expect(pathComponent.polygons.length, 1); + }); + + test('invader2 explicitly keeps all contours', () { + const size = Size(64, 64); + final path = TestPaths.byName('invader2', size); + + final pathComponent = PathComponent(path: path, filter: false); + + expect(pathComponent.polygons.length, 3); + }); + + test('alien2 implicitly keeps all disjoint contours', () { + const size = Size(64, 64); + final path = TestPaths.byName('alien2', size); + final pathComponent = PathComponent(path: path); + + expect(pathComponent.polygons.length, 4); + }); + + test('invader3 respects the given tolerance', () { + const size = Size(64, 64); + final path = TestPaths.byName('invader3', size); + + final path1 = PathComponent(path: path); + final path2 = PathComponent(path: path, tolerance: 1); + + expect( + path1.polygons.first.length, + greaterThan(path2.polygons.first.length), + ); + }); + + test('an open contour does not become a polygon', () { + final path = Path() + ..moveTo(0, 0) + ..lineTo(10, 0) + ..lineTo(10, 10); + final pathComponent = PathComponent(path: path); + + expect(pathComponent.polygons, isEmpty); + expect(pathComponent.containsLocalPoint(Vector2(9, 1)), isFalse); + }); + + test('the polygons go counterclockwise', () { + final path = Path()..addRect(const Rect.fromLTWH(0, 0, 10, 10)); + final pathComponent = PathComponent(path: path); + + expect( + PolygonComponent.isClockwise(pathComponent.polygons.first), + isFalse, + ); + }); + + test('contains the points inside of any polygon', () { + final path = Path() + ..addRect(const Rect.fromLTWH(0, 0, 10, 10)) + ..addRect(const Rect.fromLTWH(20, 0, 10, 10)); + final pathComponent = PathComponent(path: path); + + expect(pathComponent.containsLocalPoint(Vector2(5, 5)), isTrue); + expect(pathComponent.containsLocalPoint(Vector2(25, 5)), isTrue); + expect(pathComponent.containsLocalPoint(Vector2(15, 5)), isFalse); + expect(pathComponent.containsPoint(Vector2(5, 5)), isTrue); + expect(pathComponent.containsPoint(Vector2(15, 5)), isFalse); + }); + + test('contains the points inside of a transformed polygon', () { + final path = Path()..addRect(const Rect.fromLTWH(0, 0, 10, 10)); + final pathComponent = PathComponent( + path: path, + position: Vector2(100, 100), + anchor: Anchor.center, + angle: pi / 4, + ); + + expect(pathComponent.containsPoint(Vector2(100, 100)), isTrue); + expect(pathComponent.containsPoint(Vector2(106, 100)), isTrue); + expect(pathComponent.containsPoint(Vector2(105, 105)), isFalse); + }); + }); +}