From 7604df2459c110fe1235b20c24f2accc15fd585d Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sat, 19 Sep 2026 16:44:43 +0200 Subject: [PATCH 01/26] feat: Promote PathComponent to a standard component. PathComponent now lives in src/geometry and it is exported by components.dart: all affected examples now import it from there. --- examples/lib/commons/paths.dart | 1 - .../collision_detection/multiple_shapes_example.dart | 1 - .../lib/stories/collision_detection/raytrace_example.dart | 1 - examples/lib/stories/input/gesture_hitboxes_example.dart | 1 - packages/flame/lib/components.dart | 1 + .../flame/lib/src/geometry}/path_component.dart | 6 ++++-- 6 files changed, 5 insertions(+), 6 deletions(-) rename {examples/lib/commons => packages/flame/lib/src/geometry}/path_component.dart (95%) diff --git a/examples/lib/commons/paths.dart b/examples/lib/commons/paths.dart index 1f48d85941b..bb964c334be 100644 --- a/examples/lib/commons/paths.dart +++ b/examples/lib/commons/paths.dart @@ -1,7 +1,6 @@ import 'dart:math'; import 'dart:ui'; -import 'package:examples/commons/path_component.dart'; import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; import 'package:flame/palette.dart'; diff --git a/examples/lib/stories/collision_detection/multiple_shapes_example.dart b/examples/lib/stories/collision_detection/multiple_shapes_example.dart index 546ac0ccb5f..fee5c295c09 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'; diff --git a/examples/lib/stories/collision_detection/raytrace_example.dart b/examples/lib/stories/collision_detection/raytrace_example.dart index 84d20efda1f..7f0057ee594 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'; diff --git a/examples/lib/stories/input/gesture_hitboxes_example.dart b/examples/lib/stories/input/gesture_hitboxes_example.dart index 6a9ef654ebf..ea6d5602293 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'; diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index 88aefc8f204..3d081f3b11a 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -56,6 +56,7 @@ export 'src/components/text_element_component.dart'; export 'src/components/timer_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/examples/lib/commons/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart similarity index 95% rename from examples/lib/commons/path_component.dart rename to packages/flame/lib/src/geometry/path_component.dart index 7ef0c5126a0..8d4d3c3a217 100644 --- a/examples/lib/commons/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -2,7 +2,6 @@ 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'; @@ -37,6 +36,9 @@ class PathComponent extends ShapeComponent } } + /// The default paint used to render hitboxes. + static Paint hitboxStroke = Paint()..style = .stroke; + /// The path to display, already rooted at the origin. final Path path; @@ -122,7 +124,7 @@ class PathComponent extends ShapeComponent for (var contour = 0; contour < count; contour++) PolygonHitbox.fromPath(path, contour: contour) ..priority = priority + 1 - ..paint = hitboxesPaint ?? whiteStroke + ..paint = hitboxesPaint ?? hitboxStroke ..renderShape = renderHitboxes, ]; } From 2b79442d2828e0438373ea774c8d7b1859be9de0 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sat, 19 Sep 2026 17:03:08 +0200 Subject: [PATCH 02/26] fix: Use white for the default hitboxStroke paint in PathComponent. --- packages/flame/lib/src/geometry/path_component.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index 8d4d3c3a217..59778627b7f 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -37,7 +37,9 @@ class PathComponent extends ShapeComponent } /// The default paint used to render hitboxes. - static Paint hitboxStroke = Paint()..style = .stroke; + static Paint hitboxStroke = Paint() + ..color = Color(0xffffffff) + ..style = .stroke; /// The path to display, already rooted at the origin. final Path path; From 9ecc8f03053e7f719028561a89ed58f9e4655a28 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sat, 19 Sep 2026 17:18:49 +0200 Subject: [PATCH 03/26] refactor: Use PathComponent.hitboxStroke in the examples. --- examples/lib/commons/paths.dart | 4 ---- .../stories/collision_detection/rays_in_shape_example.dart | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/examples/lib/commons/paths.dart b/examples/lib/commons/paths.dart index bb964c334be..8d0ac923530 100644 --- a/examples/lib/commons/paths.dart +++ b/examples/lib/commons/paths.dart @@ -10,10 +10,6 @@ final _rnd = Random(); const shapePriority = 1; -final whiteStroke = Paint() - ..color = const Color(0xffffffff) - ..style = PaintingStyle.stroke; - final pathStroke = Paint() ..color = BasicPalette.blue.color ..style = PaintingStyle.stroke diff --git a/examples/lib/stories/collision_detection/rays_in_shape_example.dart b/examples/lib/stories/collision_detection/rays_in_shape_example.dart index 2dfb1f7f71e..17c85534512 100644 --- a/examples/lib/stories/collision_detection/rays_in_shape_example.dart +++ b/examples/lib/stories/collision_detection/rays_in_shape_example.dart @@ -400,7 +400,7 @@ class RaysInShapeWorld extends World radius: _componentSize.x * 0.6, anchor: Anchor.center, position: Vector2.zero(), - paint: whiteStroke, + paint: PathComponent.hitboxStroke, children: [CircleHitbox()], ), RectangleComponent( @@ -408,7 +408,7 @@ class RaysInShapeWorld extends World size: _componentSize, anchor: Anchor.center, position: Vector2.zero(), - paint: whiteStroke, + paint: PathComponent.hitboxStroke, children: [RectangleHitbox()], ), PositionComponent( @@ -426,7 +426,7 @@ class RaysInShapeWorld extends World anchor: Anchor.center, position: Vector2.zero(), ) - ..paint = whiteStroke + ..paint = PathComponent.hitboxStroke ..renderShape = true, ], ), From 73c5155ca954d1e000e3e704aac78f94280f42ec Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sat, 19 Sep 2026 17:53:23 +0200 Subject: [PATCH 04/26] feat: Add granularity and hitboxesPriority to the PathComponent constructor. --- .../flame/lib/src/geometry/path_component.dart | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index 59778627b7f..145b3c4f937 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -15,6 +15,8 @@ class PathComponent extends ShapeComponent with CollisionCallbacks, CollisionPassthrough { PathComponent({ required Path path, + this.granularity = 1.0, + this.hitboxesPriority, this.addHitboxes = false, this.loadHitboxes = true, this.renderHitboxes = false, @@ -38,12 +40,20 @@ class PathComponent extends ShapeComponent /// The default paint used to render hitboxes. static Paint hitboxStroke = Paint() - ..color = Color(0xffffffff) + ..color = const Color(0xffffffff) ..style = .stroke; /// The path to display, already rooted at the origin. final Path path; + /// The granularity used when sampling the path contours used to + /// create the hitboxes. + final double granularity; + + /// The hitboxes priority: if not specified, by default the hitboxes + /// use a relative priority of 1. + final int? hitboxesPriority; + /// Whether the hitboxes are added right away, in the constructor. final bool addHitboxes; @@ -97,6 +107,7 @@ class PathComponent extends ShapeComponent _hitboxesAdded = true; } + // Filter the hitboxes by keeping only the largest and all disjoint ones. List _filterHitboxes(List hitboxes) { if (hitboxes.length < 2) { return hitboxes; @@ -120,12 +131,13 @@ class PathComponent extends ShapeComponent return hitboxes; } + // Create a hitbox for each path contour. 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 + PolygonHitbox.fromPath(path, contour: contour, granularity: granularity) + ..priority = hitboxesPriority ?? priority + 1 ..paint = hitboxesPaint ?? hitboxStroke ..renderShape = renderHitboxes, ]; From 886b4125c29fe3d0822d3979c0218c52dc79e02b Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sat, 19 Sep 2026 19:25:28 +0200 Subject: [PATCH 05/26] feat: Add renderShape parameter in the PathComponent constructor. --- packages/flame/lib/src/geometry/path_component.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index 145b3c4f937..1306c92b2a9 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -31,8 +31,10 @@ class PathComponent extends ShapeComponent super.key, super.paint, super.paintLayers, + bool renderShape = true, }) : path = path.toOrigin, super(size: path.getBounds().size.toVector2()) { + this.renderShape = renderShape; if (addHitboxes) { _addHitboxes(); } From 312e74bd5469c94becefc45ab8cef7afbd17c823 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sat, 19 Sep 2026 19:48:13 +0200 Subject: [PATCH 06/26] docs: Add documentation for PathComponent. --- doc/flame/components/shape_components.md | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/doc/flame/components/shape_components.md b/doc/flame/components/shape_components.md index d33efa8e8ee..63cec3907eb 100644 --- a/doc/flame/components/shape_components.md +++ b/doc/flame/components/shape_components.md @@ -127,6 +127,31 @@ void main() { ``` +## PathComponent + +When a whole `Path` is needed (for rendering or collision detection) instead of a single contour, +creating a `PathComponent` automatically walks all contours in the given `Path` and creates +`PolygonHitbox` objects for each contour; by default, only disjoint contours become hitboxes, +but `PathComponent` supports keeping conjoint contours via the `filterHitboxes` parameter. +The component size is derived directly from the given `Path`. + +Also by default, the `Path` is rendered, whereas the hitboxes are not: this behavior may be +changed via (respectively) the `renderShape` and `renderHitboxes` parameters. + +Using the previous two-contour `Path`, creating a `PathComponent` for both contours works thusly: + + +```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); +} +``` + + ## RectangleComponent A `RectangleComponent` is created very similarly to how a `PositionComponent` is created, since it From a89df6d03ca38ea33619653a4cf2672016a36a41 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sat, 19 Sep 2026 23:48:28 +0200 Subject: [PATCH 07/26] chore: Add test for PathComponent. The path_component_test uses the TestPaths to verify both dimensions match and hitboxes filtering. --- packages/flame/lib/geometry.dart | 1 + .../test/geometry/path_component_test.dart | 75 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 packages/flame/test/geometry/path_component_test.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/test/geometry/path_component_test.dart b/packages/flame/test/geometry/path_component_test.dart new file mode 100644 index 00000000000..47ab1715129 --- /dev/null +++ b/packages/flame/test/geometry/path_component_test.dart @@ -0,0 +1,75 @@ +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() { + test('PathComponent 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('PathComponent 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('PathComponent 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('PathComponent invader2 keeps only one disjoint contour', () { + const size = Size(64, 64); + final path = TestPaths.byName('invader2', size); + + final pathComponent = PathComponent(path: path, addHitboxes: true); + + expect(pathComponent.children.length, 1); + }); + + test('PathComponent invader2 explicitly keeps all disjoint contours', () { + const size = Size(64, 64); + final path = TestPaths.byName('invader2', size); + + final pathComponent = PathComponent( + path: path, + addHitboxes: true, + filterHitboxes: false, + ); + + expect(pathComponent.children.length, 3); + }); + + test('PathComponent alien2 implicitly keeps all disjoint contours', () { + const size = Size(64, 64); + final path = TestPaths.byName('alien2', size); + + final pathComponent = PathComponent(path: path, addHitboxes: true); + + expect(pathComponent.children.length, 4); + }); +} From 6055c4f7854c89e4c66167911e073729f4a22b81 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sun, 20 Sep 2026 15:57:36 +0200 Subject: [PATCH 08/26] fix: Reject open contours before constructing PolygonHitbox. From Copilot: path.contours includes open contours, and walkContourAt returns only the endpoints for a line. This unconditionally constructs a PolygonHitbox for every contour, while PolygonComponent requires at least three vertices, so a valid Path with an open contour fails when hitboxes load. Skip or reject non-polygon contours explicitly and document the requirement. --- .../lib/src/geometry/path_component.dart | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index 1306c92b2a9..10f468ab6d9 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -72,7 +72,7 @@ class PathComponent extends ShapeComponent final Paint? hitboxesPaint; var _hitboxesAdded = false; - late final _hitboxes = _hitboxesFor(path); + late final _hitboxes = _createHitboxes(); @override FutureOr onLoad() async { @@ -133,15 +133,21 @@ class PathComponent extends ShapeComponent return hitboxes; } - // Create a hitbox for each path contour. - List _hitboxesFor(Path path) { - final count = path.contours.length; - return [ - for (var contour = 0; contour < count; contour++) - PolygonHitbox.fromPath(path, contour: contour, granularity: granularity) - ..priority = hitboxesPriority ?? priority + 1 - ..paint = hitboxesPaint ?? hitboxStroke - ..renderShape = renderHitboxes, - ]; + // Create a hitbox for each path contour with at least three vertices. + List _createHitboxes() { + final contours = path.walkContours(granularity); + final boxes = []; + for (var index = 0; index < contours.length; index++) { + final contour = contours[index]; + if (contour.length > 2) { + boxes.add( + PolygonHitbox(contour.vertices) + ..priority = hitboxesPriority ?? priority + 1 + ..paint = hitboxesPaint ?? hitboxStroke + ..renderShape = renderHitboxes, + ); + } + } + return boxes; } } From 24c69dea483afd9bb2a6c77f51f4372e8f3e5193 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Mon, 21 Sep 2026 20:13:31 +0200 Subject: [PATCH 09/26] refactor: Rename the 'granularity' parameter in PathComponent to 'sampling'. --- packages/flame/lib/src/extensions/path.dart | 6 +++--- packages/flame/lib/src/geometry/path_component.dart | 9 ++++----- 2 files changed, 7 insertions(+), 8 deletions(-) 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 index 10f468ab6d9..5e7987954dd 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -15,7 +15,7 @@ class PathComponent extends ShapeComponent with CollisionCallbacks, CollisionPassthrough { PathComponent({ required Path path, - this.granularity = 1.0, + this.sampling = 1.0, this.hitboxesPriority, this.addHitboxes = false, this.loadHitboxes = true, @@ -48,9 +48,8 @@ class PathComponent extends ShapeComponent /// The path to display, already rooted at the origin. final Path path; - /// The granularity used when sampling the path contours used to - /// create the hitboxes. - final double granularity; + /// The step used when sampling the path contours generating the hitboxes. + final double sampling; /// The hitboxes priority: if not specified, by default the hitboxes /// use a relative priority of 1. @@ -135,7 +134,7 @@ class PathComponent extends ShapeComponent // Create a hitbox for each path contour with at least three vertices. List _createHitboxes() { - final contours = path.walkContours(granularity); + final contours = path.walkContours(sampling); final boxes = []; for (var index = 0; index < contours.length; index++) { final contour = contours[index]; From 7001abe24e535bb4b8532f4306520f0f75329253 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Mon, 21 Sep 2026 21:30:07 +0200 Subject: [PATCH 10/26] feat: Add tolerance parameter in the PathComponent constructor. --- packages/flame/lib/src/geometry/path_component.dart | 10 ++++++++-- .../flame/test/geometry/path_component_test.dart | 13 +++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index 5e7987954dd..50e7646592d 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -16,6 +16,7 @@ class PathComponent extends ShapeComponent PathComponent({ required Path path, this.sampling = 1.0, + this.tolerance, this.hitboxesPriority, this.addHitboxes = false, this.loadHitboxes = true, @@ -48,9 +49,14 @@ class PathComponent extends ShapeComponent /// The path to display, already rooted at the origin. final Path path; - /// The step used when sampling the path contours generating the hitboxes. + /// The step used when sampling the path contours that generate + /// the hitboxes. final double sampling; + /// The tolerance used when sampling the path contours; if not specified, + /// it defaults to half the [sampling]. + final double? tolerance; + /// The hitboxes priority: if not specified, by default the hitboxes /// use a relative priority of 1. final int? hitboxesPriority; @@ -134,7 +140,7 @@ class PathComponent extends ShapeComponent // Create a hitbox for each path contour with at least three vertices. List _createHitboxes() { - final contours = path.walkContours(sampling); + final contours = path.walkContours(sampling, tolerance); final boxes = []; for (var index = 0; index < contours.length; index++) { final contour = contours[index]; diff --git a/packages/flame/test/geometry/path_component_test.dart b/packages/flame/test/geometry/path_component_test.dart index 47ab1715129..4f65263fef2 100644 --- a/packages/flame/test/geometry/path_component_test.dart +++ b/packages/flame/test/geometry/path_component_test.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'dart:ui'; import 'package:flame/components.dart'; +import 'package:flame/src/collisions/hitboxes/polygon_hitbox.dart'; import 'package:flame_test/test_paths.dart'; import 'package:test/test.dart'; @@ -72,4 +73,16 @@ void main() { expect(pathComponent.children.length, 4); }); + + test('PathComponent invader3 respects the given tolerance', () { + const size = Size(64, 64); + final path = TestPaths.byName('invader3', size); + + final path1 = PathComponent(path: path, addHitboxes: true); + final path2 = PathComponent(path: path, addHitboxes: true, tolerance: 1); + final hitbox1 = path1.firstChild(); + final hitbox2 = path2.firstChild(); + + expect(hitbox1!.vertices.length, greaterThan(hitbox2!.vertices.length)); + }); } From 3a673f54cd8dd195f7e2cf1788beceee313bdc4d Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sat, 26 Sep 2026 22:12:36 +0200 Subject: [PATCH 11/26] feat: Add tolerance parameter in Polygon/Polygon{Component,Hitbox} constructors. --- .../lib/src/collisions/hitboxes/polygon_hitbox.dart | 7 +++++-- .../lib/src/experimental/geometry/shapes/polygon.dart | 9 ++++++--- packages/flame/lib/src/geometry/polygon_component.dart | 9 ++++++--- 3 files changed, 17 insertions(+), 8 deletions(-) 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..508eb790d92 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/geometry/polygon_component.dart b/packages/flame/lib/src/geometry/polygon_component.dart index 9de58b5616e..8e8c399130c 100644 --- a/packages/flame/lib/src/geometry/polygon_component.dart +++ b/packages/flame/lib/src/geometry/polygon_component.dart @@ -104,13 +104,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, @@ -123,7 +126,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, From 191ab974a5aa33e03ed3b6f4ac59e9a5fd761f09 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sun, 27 Sep 2026 03:32:58 +0200 Subject: [PATCH 12/26] refactor: [WIP] Move hitboxes out of PathComponent, into the new PathHitbox. --- examples/lib/commons/paths.dart | 17 ++- .../rays_in_shape_example.dart | 6 +- .../collision_detection/raytrace_example.dart | 6 +- .../input/gesture_hitboxes_example.dart | 3 +- .../src/collisions/hitboxes/path_hitbox.dart | 96 +++++++++++++++ .../lib/src/geometry/path_component.dart | 110 +----------------- .../test/geometry/path_component_test.dart | 18 +-- 7 files changed, 132 insertions(+), 124 deletions(-) create mode 100644 packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart diff --git a/examples/lib/commons/paths.dart b/examples/lib/commons/paths.dart index 8d0ac923530..8be95d077cc 100644 --- a/examples/lib/commons/paths.dart +++ b/examples/lib/commons/paths.dart @@ -10,6 +10,10 @@ final _rnd = Random(); const shapePriority = 1; +final Paint hitboxStroke = Paint() + ..color = const Color(0xffffffff) + ..style = .stroke; + final pathStroke = Paint() ..color = BasicPalette.blue.color ..style = PaintingStyle.stroke @@ -117,14 +121,19 @@ PathComponent pathComponentWith( // Create a component that displays the whole path: we filter all hitboxes // that are (approximately) fully enclosed in the largest one. - return PathComponent( + final component = PathComponent( path: path, priority: shapePriority, position: position ?? Vector2.zero(), anchor: anchor ?? Anchor.center, paint: paint ?? pathStroke, paintLayers: paintLayers, - hitboxesPaint: contourPaint, - renderHitboxes: renderHitboxes ?? false, - )..renderShape = true; + // TODO(adario): support hitbox painting + // hitboxesPaint: contourPaint, + ); + // TODO(adario): support hitboxes + if (renderHitboxes ?? false) { + // TODO(adario): support hitboxes + } + return component; } diff --git a/examples/lib/stories/collision_detection/rays_in_shape_example.dart b/examples/lib/stories/collision_detection/rays_in_shape_example.dart index 17c85534512..bef8535c039 100644 --- a/examples/lib/stories/collision_detection/rays_in_shape_example.dart +++ b/examples/lib/stories/collision_detection/rays_in_shape_example.dart @@ -400,7 +400,7 @@ class RaysInShapeWorld extends World radius: _componentSize.x * 0.6, anchor: Anchor.center, position: Vector2.zero(), - paint: PathComponent.hitboxStroke, + paint: hitboxStroke, children: [CircleHitbox()], ), RectangleComponent( @@ -408,7 +408,7 @@ class RaysInShapeWorld extends World size: _componentSize, anchor: Anchor.center, position: Vector2.zero(), - paint: PathComponent.hitboxStroke, + paint: hitboxStroke, children: [RectangleHitbox()], ), PositionComponent( @@ -426,7 +426,7 @@ class RaysInShapeWorld extends World anchor: Anchor.center, position: Vector2.zero(), ) - ..paint = PathComponent.hitboxStroke + ..paint = hitboxStroke ..renderShape = true, ], ), diff --git a/examples/lib/stories/collision_detection/raytrace_example.dart b/examples/lib/stories/collision_detection/raytrace_example.dart index 7f0057ee594..eff2e413bc6 100644 --- a/examples/lib/stories/collision_detection/raytrace_example.dart +++ b/examples/lib/stories/collision_detection/raytrace_example.dart @@ -56,7 +56,8 @@ bounce on will appear. position: halfCanvas.toVector2(), anchor: .center, paint: boxPaint, - filterHitboxes: false, + // TODO(adario): disable hitbox filtering + // filterHitboxes: false, ), ]); } @@ -100,7 +101,8 @@ bounce on will appear. position: Vector2.all(350), anchor: Anchor.center, paint: boxPaint, - filterHitboxes: false, + // TODO(adario): disable hitbox filtering + // filterHitboxes: 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 ea6d5602293..18ec27baf89 100644 --- a/examples/lib/stories/input/gesture_hitboxes_example.dart +++ b/examples/lib/stories/input/gesture_hitboxes_example.dart @@ -91,7 +91,8 @@ class MyPathComponent extends PathComponent super.position, super.scale, super.angle, - }) : super(anchor: .center, renderHitboxes: true); + // TODO(adario): support hitbox rendering + }) : super(anchor: .center); @override Future onLoad() async { 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..bb8381fbdc8 --- /dev/null +++ b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart @@ -0,0 +1,96 @@ +import 'package:collection/collection.dart'; +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; + +/// In this [PathComponent], hitboxes are added to emulate a hitbox +/// that is a composition of other hitboxes. +class PathHitbox extends PathComponent + with CollisionCallbacks, CollisionPassthrough { + PathHitbox({ + required super.path, + this.filterHitboxes = true, + super.sampling, + super.tolerance, + super.position, + super.scale, + super.angle, + super.anchor, + super.children, + super.priority, + super.key, + super.paint, + super.paintLayers, + super.isSolid = false, + }) { + // TODO(adario): convenience... + _addHitboxes(); + } + + /// Whether the hitboxes are filtered to include only disjoint ones. + final bool filterHitboxes; + + @override + Future onLoad() async { + await super.onLoad(); + children.register(); + } + + /// Add all the hitboxes and return them. + List _addHitboxes() { + final boxes = _createHitboxes(path, sampling, tolerance); + addAll(_prepareHitboxes(boxes)); + return boxes; + } + + // Prepare the hitboxes by first sorting them by size; then, (potentially) + // filter them by keeping only the largest and all disjoint ones. + static List _prepareHitboxes( + List hitboxes, { + bool filterHitboxes = true, + }) { + 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; + } + + // Create a hitbox for each path contour with at least three vertices. + static List _createHitboxes( + Path path, + double sampling, + double? tolerance, + ) { + final contours = path.walkContours(sampling, tolerance); + final boxes = []; + for (var index = 0; index < contours.length; index++) { + final contour = contours[index]; + if (contour.length > 2) { + boxes.add( + PolygonHitbox(contour.vertices), + // TODO(adario): support hitboxes paint + // ..priority = hitboxesPriority ?? priority + 1 + // ..paint = hitboxesPaint ?? hitboxStroke + // ..renderShape = renderHitboxes, + ); + } + } + return boxes; + } +} diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index 50e7646592d..d4691c4c047 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -1,28 +1,16 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:collection/collection.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. +/// Renders a [Path]. /// /// 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 { +class PathComponent extends ShapeComponent { PathComponent({ required Path path, this.sampling = 1.0, this.tolerance, - this.hitboxesPriority, - this.addHitboxes = false, - this.loadHitboxes = true, - this.renderHitboxes = false, - this.filterHitboxes = true, - this.hitboxesPaint, super.position, super.scale, super.angle, @@ -32,19 +20,9 @@ class PathComponent extends ShapeComponent super.key, super.paint, super.paintLayers, - bool renderShape = true, + super.isSolid = false, }) : path = path.toOrigin, - super(size: path.getBounds().size.toVector2()) { - this.renderShape = renderShape; - if (addHitboxes) { - _addHitboxes(); - } - } - - /// The default paint used to render hitboxes. - static Paint hitboxStroke = Paint() - ..color = const Color(0xffffffff) - ..style = .stroke; + super(size: path.getBounds().size.toVector2()); /// The path to display, already rooted at the origin. final Path path; @@ -57,36 +35,6 @@ class PathComponent extends ShapeComponent /// it defaults to half the [sampling]. final double? tolerance; - /// The hitboxes priority: if not specified, by default the hitboxes - /// use a relative priority of 1. - final int? hitboxesPriority; - - /// 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 = _createHitboxes(); - - @override - FutureOr onLoad() async { - await super.onLoad(); - if (loadHitboxes) { - _addHitboxes(); - } - } - @override void render(Canvas canvas) { if (renderShape) { @@ -105,54 +53,4 @@ class PathComponent extends ShapeComponent super.renderDebugMode(canvas); canvas.drawPath(path, debugPaint); } - - void _addHitboxes() { - if (_hitboxesAdded) { - return; - } - addAll(_filterHitboxes(_hitboxes)); - _hitboxesAdded = true; - } - - // Filter the hitboxes by keeping only the largest and all disjoint ones. - 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; - } - - // Create a hitbox for each path contour with at least three vertices. - List _createHitboxes() { - final contours = path.walkContours(sampling, tolerance); - final boxes = []; - for (var index = 0; index < contours.length; index++) { - final contour = contours[index]; - if (contour.length > 2) { - boxes.add( - PolygonHitbox(contour.vertices) - ..priority = hitboxesPriority ?? priority + 1 - ..paint = hitboxesPaint ?? hitboxStroke - ..renderShape = renderHitboxes, - ); - } - } - return boxes; - } } diff --git a/packages/flame/test/geometry/path_component_test.dart b/packages/flame/test/geometry/path_component_test.dart index 4f65263fef2..bb5501317b1 100644 --- a/packages/flame/test/geometry/path_component_test.dart +++ b/packages/flame/test/geometry/path_component_test.dart @@ -46,8 +46,8 @@ void main() { test('PathComponent invader2 keeps only one disjoint contour', () { const size = Size(64, 64); final path = TestPaths.byName('invader2', size); - - final pathComponent = PathComponent(path: path, addHitboxes: true); + // TODO(adario): add hitboxes + final pathComponent = PathComponent(path: path); expect(pathComponent.children.length, 1); }); @@ -56,10 +56,11 @@ void main() { const size = Size(64, 64); final path = TestPaths.byName('invader2', size); + // TODO(adario): add hitboxes final pathComponent = PathComponent( path: path, - addHitboxes: true, - filterHitboxes: false, + // TODO(adario): disable hitbox filtering + // filterHitboxes: false, ); expect(pathComponent.children.length, 3); @@ -68,8 +69,8 @@ void main() { test('PathComponent alien2 implicitly keeps all disjoint contours', () { const size = Size(64, 64); final path = TestPaths.byName('alien2', size); - - final pathComponent = PathComponent(path: path, addHitboxes: true); + // TODO(adario): add hitboxes + final pathComponent = PathComponent(path: path); expect(pathComponent.children.length, 4); }); @@ -78,8 +79,9 @@ void main() { const size = Size(64, 64); final path = TestPaths.byName('invader3', size); - final path1 = PathComponent(path: path, addHitboxes: true); - final path2 = PathComponent(path: path, addHitboxes: true, tolerance: 1); + // TODO(adario): add hitboxes + final path1 = PathComponent(path: path); + final path2 = PathComponent(path: path, tolerance: 1); final hitbox1 = path1.firstChild(); final hitbox2 = path2.firstChild(); From e51bde37a8819ad059d4940513d5663a73edda71 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sun, 27 Sep 2026 19:13:00 +0200 Subject: [PATCH 13/26] refactor: [WIP] Align PathComponent/Hitbox to PolygonComponent/Hitbox. PathHitbox now uses mixin with ShapeHitbox, with fillParent() throwing like PolygonHitbox; the AABB is computed as an accumulation of the internal PolygonHitbox objects. PathComponent behaves like PathHitbox, creating PolygonComponent objects (not rendered). The shape_intersections module now contains classes for path/path, path/polygon and path/circle intersections: the path ones forward to the internal components. --- examples/lib/commons/paths.dart | 50 +++++++-- .../collision_detection/raytrace_example.dart | 10 +- .../input/gesture_hitboxes_example.dart | 5 +- packages/flame/lib/collisions.dart | 1 + .../src/collisions/hitboxes/path_hitbox.dart | 100 ++++++++++-------- .../lib/src/geometry/path_component.dart | 66 +++++++++++- .../lib/src/geometry/shape_intersections.dart | 69 ++++++++++++ 7 files changed, 236 insertions(+), 65 deletions(-) diff --git a/examples/lib/commons/paths.dart b/examples/lib/commons/paths.dart index 8be95d077cc..5150a3bdb2e 100644 --- a/examples/lib/commons/paths.dart +++ b/examples/lib/commons/paths.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'dart:ui'; +import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; import 'package:flame/palette.dart'; @@ -87,6 +88,7 @@ PathComponent pathComponent( List? paintLayers, Paint? contourPaint, bool? renderHitboxes, + bool? filterHitboxes, Anchor? anchor, }) { // Create a standard test path that fits within our chosen size with its @@ -100,6 +102,7 @@ PathComponent pathComponent( paintLayers: paintLayers, contourPaint: contourPaint, renderHitboxes: renderHitboxes, + filterHitboxes: filterHitboxes, anchor: anchor, ); } @@ -113,6 +116,7 @@ PathComponent pathComponentWith( List? paintLayers, Paint? contourPaint, bool? renderHitboxes, + bool? filterHitboxes, Anchor? anchor, }) { // Adjust the path such that fits within our chosen size with its @@ -121,19 +125,51 @@ PathComponent pathComponentWith( // Create a component that displays the whole path: we filter all hitboxes // that are (approximately) fully enclosed in the largest one. - final component = PathComponent( + return CollidablePathComponent( path: path, priority: shapePriority, position: position ?? Vector2.zero(), anchor: anchor ?? Anchor.center, paint: paint ?? pathStroke, paintLayers: paintLayers, - // TODO(adario): support hitbox painting - // hitboxesPaint: contourPaint, + contourPaint: contourPaint, + renderHitboxes: renderHitboxes, + filterHitboxes: filterHitboxes, ); - // TODO(adario): support hitboxes - if (renderHitboxes ?? false) { - // TODO(adario): support hitboxes +} + +class CollidablePathComponent extends PathComponent + with CollisionCallbacks, CollisionPassthrough { + CollidablePathComponent({ + required super.path, + super.sampling, + super.tolerance, + super.position, + super.scale, + super.angle, + super.anchor, + super.children, + super.priority, + super.key, + super.paint, + super.paintLayers, + Paint? contourPaint, + bool? renderHitboxes, + bool? filterHitboxes, + }) : super() { + final pathHitbox = PathHitbox( + path: path, + filterHitboxes: filterHitboxes ?? true, + sampling: sampling, + tolerance: tolerance, + ); + add(pathHitbox); + if (renderHitboxes ?? false) { + final hitboxPaint = contourPaint ?? hitboxStroke; + for (final hitbox in pathHitbox.polygonHitboxes) { + hitbox.renderShape = true; + hitbox.paint = hitboxPaint; + } + } } - return component; } diff --git a/examples/lib/stories/collision_detection/raytrace_example.dart b/examples/lib/stories/collision_detection/raytrace_example.dart index eff2e413bc6..c89f886595c 100644 --- a/examples/lib/stories/collision_detection/raytrace_example.dart +++ b/examples/lib/stories/collision_detection/raytrace_example.dart @@ -51,13 +51,12 @@ bounce on will appear. children: [CircleHitbox()], ) else - PathComponent( + CollidablePathComponent( path: randomPath(halfCanvas * 2), position: halfCanvas.toVector2(), anchor: .center, paint: boxPaint, - // TODO(adario): disable hitbox filtering - // filterHitboxes: false, + filterHitboxes: false, ), ]); } @@ -96,13 +95,12 @@ bounce on will appear. anchor: Anchor.center, children: [CircleHitbox()], ), - PathComponent( + CollidablePathComponent( path: path, position: Vector2.all(350), anchor: Anchor.center, paint: boxPaint, - // TODO(adario): disable hitbox filtering - // filterHitboxes: false, + filterHitboxes: 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 18ec27baf89..4211de9ab31 100644 --- a/examples/lib/stories/input/gesture_hitboxes_example.dart +++ b/examples/lib/stories/input/gesture_hitboxes_example.dart @@ -82,7 +82,7 @@ class _GestureHitboxesWorld extends World } } -class MyPathComponent extends PathComponent +class MyPathComponent extends CollidablePathComponent with TapCallbacks, HoverCallbacks, GestureHitboxes { late final Color baseColor; @@ -91,8 +91,7 @@ class MyPathComponent extends PathComponent super.position, super.scale, super.angle, - // TODO(adario): support hitbox rendering - }) : super(anchor: .center); + }) : super(anchor: .center, renderHitboxes: true); @override Future onLoad() async { 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/src/collisions/hitboxes/path_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart index bb8381fbdc8..f72ef56f5f1 100644 --- a/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart +++ b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart @@ -1,12 +1,11 @@ -import 'package:collection/collection.dart'; import 'package:flame/collisions.dart'; -import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; +import 'package:flame/geometry.dart'; +import 'package:meta/meta.dart'; /// In this [PathComponent], hitboxes are added to emulate a hitbox /// that is a composition of other hitboxes. -class PathHitbox extends PathComponent - with CollisionCallbacks, CollisionPassthrough { +class PathHitbox extends PathComponent with ShapeHitbox { PathHitbox({ required super.path, this.filterHitboxes = true, @@ -24,12 +23,52 @@ class PathHitbox extends PathComponent super.isSolid = false, }) { // TODO(adario): convenience... - _addHitboxes(); + addHitboxes(); } /// Whether the hitboxes are filtered to include only disjoint ones. final bool filterHitboxes; + /// Our polygon hitboxes. + Iterable get polygonHitboxes => + children.query(); + + /// Returns information about how the ray intersects the shape. + /// + /// If you are only interested in the intersection point use + /// [RaycastResult.intersectionPoint] of the result. + @override + RaycastResult? rayIntersection( + Ray2 ray, { + RaycastResult? out, + }) { + for (final hitbox in polygonHitboxes) { + final result = hitbox.rayIntersection(ray, out: out); + if (result != null) { + return result; + } + } + return null; + } + + /// This determines how the shape should scale if it should try to fill its + /// parents boundaries. + @override + void fillParent() { + // TODO(adario): is this correct? + throw UnsupportedError('PathHitbox already fills its parent'); + } + + @override + @protected + void computeAabb(Aabb2 aabb) { + aabb.min.setValues(aabb.max.x, aabb.max.y); + for (final box in polygonHitboxes) { + aabb.hull(box.aabb); + } + } + + /// Ensure we can perform queries quickly. @override Future onLoad() async { await super.onLoad(); @@ -37,58 +76,25 @@ class PathHitbox extends PathComponent } /// Add all the hitboxes and return them. - List _addHitboxes() { - final boxes = _createHitboxes(path, sampling, tolerance); - addAll(_prepareHitboxes(boxes)); + List addHitboxes() { + final boxes = createHitboxes(this, sampling, tolerance); + addAll(PathComponent.preparePolygons(boxes)); return boxes; } - // Prepare the hitboxes by first sorting them by size; then, (potentially) - // filter them by keeping only the largest and all disjoint ones. - static List _prepareHitboxes( - List hitboxes, { - bool filterHitboxes = true, - }) { - 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; - } - - // Create a hitbox for each path contour with at least three vertices. - static List _createHitboxes( - Path path, + /// Create a hitbox for each path contour with at least three vertices. + @internal + static List createHitboxes( + PathComponent path, double sampling, double? tolerance, ) { - final contours = path.walkContours(sampling, tolerance); + final contours = path.contours; final boxes = []; for (var index = 0; index < contours.length; index++) { final contour = contours[index]; if (contour.length > 2) { - boxes.add( - PolygonHitbox(contour.vertices), - // TODO(adario): support hitboxes paint - // ..priority = hitboxesPriority ?? priority + 1 - // ..paint = hitboxesPaint ?? hitboxStroke - // ..renderShape = renderHitboxes, - ); + boxes.add(PolygonHitbox(contour.vertices)); } } return boxes; diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index d4691c4c047..24da28c426b 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -1,5 +1,7 @@ +import 'package:collection/collection.dart'; import 'package:flame/extensions.dart'; import 'package:flame/geometry.dart'; +import 'package:meta/meta.dart'; /// Renders a [Path]. /// @@ -22,19 +24,25 @@ class PathComponent extends ShapeComponent { super.paintLayers, super.isSolid = false, }) : path = path.toOrigin, - super(size: path.getBounds().size.toVector2()); + super(size: path.getBounds().size.toVector2()) { + // TODO(adario): convenience + addPolygons(); + } /// The path to display, already rooted at the origin. final Path path; /// The step used when sampling the path contours that generate - /// the hitboxes. + /// the polygon components. final double sampling; /// The tolerance used when sampling the path contours; if not specified, /// it defaults to half the [sampling]. final double? tolerance; + /// The actual contours for this path. + late final contours = path.walkContours(sampling, tolerance); + @override void render(Canvas canvas) { if (renderShape) { @@ -53,4 +61,58 @@ class PathComponent extends ShapeComponent { super.renderDebugMode(canvas); canvas.drawPath(path, debugPaint); } + + /// Add all the polygon components and return them. + List addPolygons() { + final polygons = createPolygons(this, sampling, tolerance); + addAll(preparePolygons(polygons)); + return polygons; + } + + /// Create a polygon for each path contour with at least three vertices. + @internal + static List createPolygons( + PathComponent path, + double sampling, + double? tolerance, + ) { + final contours = path.contours; + final polygons = []; + for (var index = 0; index < contours.length; index++) { + final contour = contours[index]; + if (contour.length > 2) { + polygons.add(PolygonComponent(contour.vertices)..renderShape = false); + } + } + return polygons; + } + + /// Prepare the polygons by first sorting them by size; then, (potentially) + /// filter them by keeping only the largest and all disjoint ones. + @internal + static List preparePolygons( + List polygons, { + bool filterPolygons = true, + }) { + if (polygons.length < 2) { + return polygons; + } + // Sort the polygons by size: we will use the largest area in order to + // approximate full inclusion. + polygons.sortBy((hitbox) => hitbox.size.length2); + final largest = polygons.last; + final area = largest.toRect(); + + // We always keep the largest hitbox: the others are discarded if they fit + // entirely within it. + if (filterPolygons) { + polygons.removeWhere((element) { + if (element == largest) { + return false; + } + return area.expandToInclude(element.toRect()) == area; + }); + } + return polygons; + } } diff --git a/packages/flame/lib/src/geometry/shape_intersections.dart b/packages/flame/lib/src/geometry/shape_intersections.dart index 9e1ed46904d..09d80b63a46 100644 --- a/packages/flame/lib/src/geometry/shape_intersections.dart +++ b/packages/flame/lib/src/geometry/shape_intersections.dart @@ -28,6 +28,72 @@ abstract class Intersections< } } +class PathPathIntersections + extends Intersections { + late final _polygons = PolygonPolygonIntersections(); + @override + List intersect( + PathComponent pathA, + PathComponent pathB, { + Rect? overlappingRect, + }) { + // TODO(adario): O(n^2)... + final polygonsA = pathA.children.whereType(); + final polygonsB = pathB.children.whereType(); + for (final polygonA in polygonsA) { + for (final polygonB in polygonsB) { + final intersections = _polygons.intersect(polygonA, polygonB); + if (intersections.isNotEmpty) { + return intersections; + } + } + } + return []; + } +} + +class PathPolygonIntersections + extends Intersections { + late final _polygons = PolygonPolygonIntersections(); + @override + List intersect( + PathComponent pathA, + PolygonComponent polygonB, { + Rect? overlappingRect, + }) { + // TODO(adario): linear, should it accumulate all results? + final polygonsA = pathA.children.whereType(); + for (final polygonA in polygonsA) { + final intersections = _polygons.intersect(polygonA, polygonB); + if (intersections.isNotEmpty) { + return intersections; + } + } + return []; + } +} + +class CirclePathIntersections + extends Intersections { + late final _polygons = CirclePolygonIntersections(); + @override + List intersect( + CircleComponent circleA, + PathComponent pathB, { + Rect? overlappingRect, + }) { + // TODO(adario): linear, should it accumulate all results? + final polygonsB = pathB.children.whereType(); + for (final polygonB in polygonsB) { + final intersections = _polygons.intersect(circleA, polygonB); + if (intersections.isNotEmpty) { + return intersections; + } + } + return []; + } +} + class PolygonPolygonIntersections extends Intersections { /// Returns the intersection points of [polygonA] and [polygonB] @@ -187,6 +253,9 @@ final List _intersectionSystems = [ CircleCircleIntersections(), CirclePolygonIntersections(), PolygonPolygonIntersections(), + CirclePathIntersections(), + PathPolygonIntersections(), + PathPathIntersections(), ]; List intersections( From dbbd134b38aee7a023c64895415cefff30e2f956 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sun, 27 Sep 2026 19:49:51 +0200 Subject: [PATCH 14/26] feat: Add filter parameter in PathComponent/Hitbox constructor. This decides whether the internal PolygonComponent/Hitbox objects are filtered (default true), by keeping only the largest one and only disjoint ones. The path_component_test in geometry now passes. --- doc/flame/components/shape_components.md | 4 ++-- examples/lib/commons/paths.dart | 12 ++++++------ .../collision_detection/raytrace_example.dart | 4 ++-- .../lib/src/collisions/hitboxes/path_hitbox.dart | 7 ++----- packages/flame/lib/src/geometry/path_component.dart | 6 +++++- .../flame/lib/src/geometry/shape_intersections.dart | 2 +- .../flame/test/geometry/path_component_test.dart | 12 +++--------- 7 files changed, 21 insertions(+), 26 deletions(-) diff --git a/doc/flame/components/shape_components.md b/doc/flame/components/shape_components.md index 63cec3907eb..8ad5edee393 100644 --- a/doc/flame/components/shape_components.md +++ b/doc/flame/components/shape_components.md @@ -130,9 +130,9 @@ void main() { ## PathComponent When a whole `Path` is needed (for rendering or collision detection) instead of a single contour, -creating a `PathComponent` automatically walks all contours in the given `Path` and creates +creating a `PathComponent` automatically walks all contours in the given `Path` and creates `PolygonHitbox` objects for each contour; by default, only disjoint contours become hitboxes, -but `PathComponent` supports keeping conjoint contours via the `filterHitboxes` parameter. +but `PathComponent` supports keeping conjoint contours via the `filter` parameter. The component size is derived directly from the given `Path`. Also by default, the `Path` is rendered, whereas the hitboxes are not: this behavior may be diff --git a/examples/lib/commons/paths.dart b/examples/lib/commons/paths.dart index 5150a3bdb2e..ed3961b1641 100644 --- a/examples/lib/commons/paths.dart +++ b/examples/lib/commons/paths.dart @@ -88,7 +88,7 @@ PathComponent pathComponent( List? paintLayers, Paint? contourPaint, bool? renderHitboxes, - bool? filterHitboxes, + bool? filter, Anchor? anchor, }) { // Create a standard test path that fits within our chosen size with its @@ -102,7 +102,7 @@ PathComponent pathComponent( paintLayers: paintLayers, contourPaint: contourPaint, renderHitboxes: renderHitboxes, - filterHitboxes: filterHitboxes, + filter: filter, anchor: anchor, ); } @@ -116,7 +116,7 @@ PathComponent pathComponentWith( List? paintLayers, Paint? contourPaint, bool? renderHitboxes, - bool? filterHitboxes, + bool? filter, Anchor? anchor, }) { // Adjust the path such that fits within our chosen size with its @@ -134,7 +134,7 @@ PathComponent pathComponentWith( paintLayers: paintLayers, contourPaint: contourPaint, renderHitboxes: renderHitboxes, - filterHitboxes: filterHitboxes, + filter: filter, ); } @@ -155,11 +155,11 @@ class CollidablePathComponent extends PathComponent super.paintLayers, Paint? contourPaint, bool? renderHitboxes, - bool? filterHitboxes, + bool? filter, }) : super() { final pathHitbox = PathHitbox( path: path, - filterHitboxes: filterHitboxes ?? true, + filter: filter ?? true, sampling: sampling, tolerance: tolerance, ); diff --git a/examples/lib/stories/collision_detection/raytrace_example.dart b/examples/lib/stories/collision_detection/raytrace_example.dart index c89f886595c..d041a5f918a 100644 --- a/examples/lib/stories/collision_detection/raytrace_example.dart +++ b/examples/lib/stories/collision_detection/raytrace_example.dart @@ -56,7 +56,7 @@ bounce on will appear. position: halfCanvas.toVector2(), anchor: .center, paint: boxPaint, - filterHitboxes: false, + filter: false, ), ]); } @@ -100,7 +100,7 @@ bounce on will appear. position: Vector2.all(350), anchor: Anchor.center, paint: boxPaint, - filterHitboxes: false, + filter: false, ), RectangleComponent( position: Vector2.all(500), diff --git a/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart index f72ef56f5f1..4f2a0a94347 100644 --- a/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart +++ b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart @@ -8,7 +8,7 @@ import 'package:meta/meta.dart'; class PathHitbox extends PathComponent with ShapeHitbox { PathHitbox({ required super.path, - this.filterHitboxes = true, + super.filter, super.sampling, super.tolerance, super.position, @@ -26,9 +26,6 @@ class PathHitbox extends PathComponent with ShapeHitbox { addHitboxes(); } - /// Whether the hitboxes are filtered to include only disjoint ones. - final bool filterHitboxes; - /// Our polygon hitboxes. Iterable get polygonHitboxes => children.query(); @@ -78,7 +75,7 @@ class PathHitbox extends PathComponent with ShapeHitbox { /// Add all the hitboxes and return them. List addHitboxes() { final boxes = createHitboxes(this, sampling, tolerance); - addAll(PathComponent.preparePolygons(boxes)); + addAll(PathComponent.preparePolygons(boxes, filterPolygons: filter)); return boxes; } diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index 24da28c426b..de08f2a7da7 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -11,6 +11,7 @@ import 'package:meta/meta.dart'; class PathComponent extends ShapeComponent { PathComponent({ required Path path, + this.filter = true, this.sampling = 1.0, this.tolerance, super.position, @@ -40,6 +41,9 @@ class PathComponent extends ShapeComponent { /// it defaults to half the [sampling]. final double? tolerance; + /// Whether the components are filtered to include only disjoint ones. + final bool filter; + /// The actual contours for this path. late final contours = path.walkContours(sampling, tolerance); @@ -65,7 +69,7 @@ class PathComponent extends ShapeComponent { /// Add all the polygon components and return them. List addPolygons() { final polygons = createPolygons(this, sampling, tolerance); - addAll(preparePolygons(polygons)); + addAll(preparePolygons(polygons, filterPolygons: filter)); return polygons; } diff --git a/packages/flame/lib/src/geometry/shape_intersections.dart b/packages/flame/lib/src/geometry/shape_intersections.dart index 09d80b63a46..b8c0f6f10cf 100644 --- a/packages/flame/lib/src/geometry/shape_intersections.dart +++ b/packages/flame/lib/src/geometry/shape_intersections.dart @@ -37,7 +37,7 @@ class PathPathIntersections PathComponent pathB, { Rect? overlappingRect, }) { - // TODO(adario): O(n^2)... + // TODO(adario): already quadratic, should it accumulate all results? final polygonsA = pathA.children.whereType(); final polygonsB = pathB.children.whereType(); for (final polygonA in polygonsA) { diff --git a/packages/flame/test/geometry/path_component_test.dart b/packages/flame/test/geometry/path_component_test.dart index bb5501317b1..6dd7935d387 100644 --- a/packages/flame/test/geometry/path_component_test.dart +++ b/packages/flame/test/geometry/path_component_test.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'dart:ui'; import 'package:flame/components.dart'; -import 'package:flame/src/collisions/hitboxes/polygon_hitbox.dart'; import 'package:flame_test/test_paths.dart'; import 'package:test/test.dart'; @@ -46,7 +45,6 @@ void main() { test('PathComponent invader2 keeps only one disjoint contour', () { const size = Size(64, 64); final path = TestPaths.byName('invader2', size); - // TODO(adario): add hitboxes final pathComponent = PathComponent(path: path); expect(pathComponent.children.length, 1); @@ -56,11 +54,9 @@ void main() { const size = Size(64, 64); final path = TestPaths.byName('invader2', size); - // TODO(adario): add hitboxes final pathComponent = PathComponent( path: path, - // TODO(adario): disable hitbox filtering - // filterHitboxes: false, + filter: false, ); expect(pathComponent.children.length, 3); @@ -69,7 +65,6 @@ void main() { test('PathComponent alien2 implicitly keeps all disjoint contours', () { const size = Size(64, 64); final path = TestPaths.byName('alien2', size); - // TODO(adario): add hitboxes final pathComponent = PathComponent(path: path); expect(pathComponent.children.length, 4); @@ -79,11 +74,10 @@ void main() { const size = Size(64, 64); final path = TestPaths.byName('invader3', size); - // TODO(adario): add hitboxes final path1 = PathComponent(path: path); final path2 = PathComponent(path: path, tolerance: 1); - final hitbox1 = path1.firstChild(); - final hitbox2 = path2.firstChild(); + final hitbox1 = path1.firstChild(); + final hitbox2 = path2.firstChild(); expect(hitbox1!.vertices.length, greaterThan(hitbox2!.vertices.length)); }); From 8bc981f64b05c19301e860e18079925cdcd90558 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Sun, 27 Sep 2026 22:17:33 +0200 Subject: [PATCH 15/26] refactor: Move CollidablePathComponent in the examples to its own source file. fix: PathHitbox.computeAabb() was starting from an invalid AABB: it now starts from the last hitbox one. feat: Add PathComponent.polygonComponents query. spell: Add entry in usernames. --- .github/.cspell/people_usernames.txt | 1 + .../commons/collidable_path_component.dart | 43 +++++++++++++++++++ examples/lib/commons/paths.dart | 42 +----------------- .../rays_in_shape_example.dart | 1 + .../collision_detection/raytrace_example.dart | 1 + .../input/gesture_hitboxes_example.dart | 5 ++- .../src/collisions/hitboxes/path_hitbox.dart | 10 +++-- .../lib/src/geometry/path_component.dart | 12 ++++++ 8 files changed, 69 insertions(+), 46 deletions(-) create mode 100644 examples/lib/commons/collidable_path_component.dart diff --git a/.github/.cspell/people_usernames.txt b/.github/.cspell/people_usernames.txt index f17b6d1723c..7eb8885d788 100644 --- a/.github/.cspell/people_usernames.txt +++ b/.github/.cspell/people_usernames.txt @@ -1,4 +1,5 @@ # specific people's names and/or usernames +adario # github.com/adario akida # github.com/akida bdero # github.com/bdero bluefireteam # github.com/bluefireteam diff --git a/examples/lib/commons/collidable_path_component.dart b/examples/lib/commons/collidable_path_component.dart new file mode 100644 index 00000000000..4b332553953 --- /dev/null +++ b/examples/lib/commons/collidable_path_component.dart @@ -0,0 +1,43 @@ +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; + +final Paint hitboxStroke = Paint() + ..color = const Color(0xffffffff) + ..style = .stroke; + +class CollidablePathComponent extends PathComponent + with CollisionCallbacks, CollisionPassthrough { + CollidablePathComponent({ + required super.path, + super.sampling, + super.tolerance, + super.position, + super.scale, + super.angle, + super.anchor, + super.children, + super.priority, + super.key, + super.paint, + super.paintLayers, + Paint? contourPaint, + bool? renderHitboxes, + bool? filter, + }) : super() { + final pathHitbox = PathHitbox( + path: path, + filter: filter ?? true, + sampling: sampling, + tolerance: tolerance, + ); + add(pathHitbox); + if (renderHitboxes ?? false) { + final hitboxPaint = contourPaint ?? hitboxStroke; + for (final hitbox in pathHitbox.polygonHitboxes) { + hitbox.renderShape = true; + hitbox.paint = hitboxPaint; + } + } + } +} diff --git a/examples/lib/commons/paths.dart b/examples/lib/commons/paths.dart index ed3961b1641..635b94dd81b 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:flame/collisions.dart'; +import 'package:examples/commons/collidable_path_component.dart'; import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; import 'package:flame/palette.dart'; @@ -11,10 +11,6 @@ final _rnd = Random(); const shapePriority = 1; -final Paint hitboxStroke = Paint() - ..color = const Color(0xffffffff) - ..style = .stroke; - final pathStroke = Paint() ..color = BasicPalette.blue.color ..style = PaintingStyle.stroke @@ -137,39 +133,3 @@ PathComponent pathComponentWith( filter: filter, ); } - -class CollidablePathComponent extends PathComponent - with CollisionCallbacks, CollisionPassthrough { - CollidablePathComponent({ - required super.path, - super.sampling, - super.tolerance, - super.position, - super.scale, - super.angle, - super.anchor, - super.children, - super.priority, - super.key, - super.paint, - super.paintLayers, - Paint? contourPaint, - bool? renderHitboxes, - bool? filter, - }) : super() { - final pathHitbox = PathHitbox( - path: path, - filter: filter ?? true, - sampling: sampling, - tolerance: tolerance, - ); - add(pathHitbox); - if (renderHitboxes ?? false) { - final hitboxPaint = contourPaint ?? hitboxStroke; - for (final hitbox in pathHitbox.polygonHitboxes) { - hitbox.renderShape = true; - hitbox.paint = hitboxPaint; - } - } - } -} diff --git a/examples/lib/stories/collision_detection/rays_in_shape_example.dart b/examples/lib/stories/collision_detection/rays_in_shape_example.dart index bef8535c039..ad2ce86b87c 100644 --- a/examples/lib/stories/collision_detection/rays_in_shape_example.dart +++ b/examples/lib/stories/collision_detection/rays_in_shape_example.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math'; +import 'package:examples/commons/collidable_path_component.dart'; import 'package:examples/commons/paths.dart'; import 'package:examples/commons/rounded_rect_component.dart'; import 'package:flame/collisions.dart'; diff --git a/examples/lib/stories/collision_detection/raytrace_example.dart b/examples/lib/stories/collision_detection/raytrace_example.dart index d041a5f918a..062d91c2de6 100644 --- a/examples/lib/stories/collision_detection/raytrace_example.dart +++ b/examples/lib/stories/collision_detection/raytrace_example.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:examples/commons/collidable_path_component.dart'; import 'package:examples/commons/paths.dart'; import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; diff --git a/examples/lib/stories/input/gesture_hitboxes_example.dart b/examples/lib/stories/input/gesture_hitboxes_example.dart index 4211de9ab31..9a567243c69 100644 --- a/examples/lib/stories/input/gesture_hitboxes_example.dart +++ b/examples/lib/stories/input/gesture_hitboxes_example.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:examples/commons/collidable_path_component.dart'; import 'package:examples/commons/paths.dart'; import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; @@ -95,7 +96,7 @@ class MyPathComponent extends CollidablePathComponent @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); baseColor = ColorExtension.random(withAlpha: 0.8, base: 100); paint.color = baseColor; } @@ -130,7 +131,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/src/collisions/hitboxes/path_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart index 4f2a0a94347..3c31293ba33 100644 --- a/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart +++ b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart @@ -56,16 +56,19 @@ class PathHitbox extends PathComponent with ShapeHitbox { throw UnsupportedError('PathHitbox already fills its parent'); } + /// Compute the [aabb] as the hitboxes hull. @override @protected void computeAabb(Aabb2 aabb) { - aabb.min.setValues(aabb.max.x, aabb.max.y); - for (final box in polygonHitboxes) { + final boxes = polygonHitboxes.toList(); + final last = boxes.removeLast(); + aabb.hull(last.aabb); + for (final box in boxes) { aabb.hull(box.aabb); } } - /// Ensure we can perform queries quickly. + /// Ensure we can perform [PolygonHitbox] queries quickly. @override Future onLoad() async { await super.onLoad(); @@ -73,6 +76,7 @@ class PathHitbox extends PathComponent with ShapeHitbox { } /// Add all the hitboxes and return them. + @internal List addHitboxes() { final boxes = createHitboxes(this, sampling, tolerance); addAll(PathComponent.preparePolygons(boxes, filterPolygons: filter)); diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index de08f2a7da7..7d2b0b5f625 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -47,6 +47,17 @@ class PathComponent extends ShapeComponent { /// The actual contours for this path. late final contours = path.walkContours(sampling, tolerance); + /// Our polygon hitboxes. + Iterable get polygonComponents => + children.query(); + + /// Ensure we can perform [PolygonComponent] queries quickly. + @override + Future onLoad() async { + await super.onLoad(); + children.register(); + } + @override void render(Canvas canvas) { if (renderShape) { @@ -67,6 +78,7 @@ class PathComponent extends ShapeComponent { } /// Add all the polygon components and return them. + @internal List addPolygons() { final polygons = createPolygons(this, sampling, tolerance); addAll(preparePolygons(polygons, filterPolygons: filter)); From bd0e8d3052d969c15f5c5ed17fa6cd4b86ee6b23 Mon Sep 17 00:00:00 2001 From: Dario Accornero Date: Mon, 28 Sep 2026 00:12:26 +0200 Subject: [PATCH 16/26] fix: Propagate missing overlappingRect parameter in *Path*Intersections. feat: Add pathHitbox accessor in PathComponent. --- .../multiple_shapes_example.dart | 2 +- .../flame/lib/src/geometry/path_component.dart | 8 ++++++-- .../lib/src/geometry/shape_intersections.dart | 18 +++++++++++++++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/examples/lib/stories/collision_detection/multiple_shapes_example.dart b/examples/lib/stories/collision_detection/multiple_shapes_example.dart index fee5c295c09..2bb67e3e762 100644 --- a/examples/lib/stories/collision_detection/multiple_shapes_example.dart +++ b/examples/lib/stories/collision_detection/multiple_shapes_example.dart @@ -197,7 +197,7 @@ class CollidablePolygon extends MyCollidable { } } -class CollidablePath extends MyCollidable with CollisionPassthrough { +class CollidablePath extends MyCollidable { CollidablePath( super.position, super.size, diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index 7d2b0b5f625..4b227b0414f 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:flame/collisions.dart'; import 'package:flame/extensions.dart'; import 'package:flame/geometry.dart'; import 'package:meta/meta.dart'; @@ -47,6 +48,9 @@ class PathComponent extends ShapeComponent { /// The actual contours for this path. late final contours = path.walkContours(sampling, tolerance); + /// The current path hitbox (if any). + PathHitbox? get pathHitbox => firstChild(); + /// Our polygon hitboxes. Iterable get polygonComponents => children.query(); @@ -116,12 +120,12 @@ class PathComponent extends ShapeComponent { // Sort the polygons by size: we will use the largest area in order to // approximate full inclusion. polygons.sortBy((hitbox) => hitbox.size.length2); - final largest = polygons.last; - final area = largest.toRect(); // We always keep the largest hitbox: the others are discarded if they fit // entirely within it. if (filterPolygons) { + final largest = polygons.last; + final area = largest.toRect(); polygons.removeWhere((element) { if (element == largest) { return false; diff --git a/packages/flame/lib/src/geometry/shape_intersections.dart b/packages/flame/lib/src/geometry/shape_intersections.dart index b8c0f6f10cf..3b45cc56003 100644 --- a/packages/flame/lib/src/geometry/shape_intersections.dart +++ b/packages/flame/lib/src/geometry/shape_intersections.dart @@ -42,7 +42,11 @@ class PathPathIntersections final polygonsB = pathB.children.whereType(); for (final polygonA in polygonsA) { for (final polygonB in polygonsB) { - final intersections = _polygons.intersect(polygonA, polygonB); + final intersections = _polygons.intersect( + polygonA, + polygonB, + overlappingRect: overlappingRect, + ); if (intersections.isNotEmpty) { return intersections; } @@ -64,7 +68,11 @@ class PathPolygonIntersections // TODO(adario): linear, should it accumulate all results? final polygonsA = pathA.children.whereType(); for (final polygonA in polygonsA) { - final intersections = _polygons.intersect(polygonA, polygonB); + final intersections = _polygons.intersect( + polygonA, + polygonB, + overlappingRect: overlappingRect, + ); if (intersections.isNotEmpty) { return intersections; } @@ -85,7 +93,11 @@ class CirclePathIntersections // TODO(adario): linear, should it accumulate all results? final polygonsB = pathB.children.whereType(); for (final polygonB in polygonsB) { - final intersections = _polygons.intersect(circleA, polygonB); + final intersections = _polygons.intersect( + circleA, + polygonB, + overlappingRect: overlappingRect, + ); if (intersections.isNotEmpty) { return intersections; } From d019b4d78ba573353b76181185353637e64cfd2a Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 11:46:04 +0200 Subject: [PATCH 17/26] refactor: Share the polygon containment, orientation and ray algorithms PolygonComponent exposes the point containment and the clockwise checks as internal statics, and PolygonRayIntersection exposes the ray intersection against a vertex list, so that shapes made of several polygons can reuse them. --- .../lib/src/geometry/polygon_component.dart | 16 +++++++- .../geometry/polygon_ray_intersection.dart | 38 +++++++++++++++---- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/flame/lib/src/geometry/polygon_component.dart b/packages/flame/lib/src/geometry/polygon_component.dart index 8e8c399130c..97672ebb604 100644 --- a/packages/flame/lib/src/geometry/polygon_component.dart +++ b/packages/flame/lib/src/geometry/polygon_component.dart @@ -201,7 +201,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(); @@ -285,7 +285,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++) { @@ -376,7 +385,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, ); From de0a2ac043703e307fb88baecf4bac7897e0eb58 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 11:51:48 +0200 Subject: [PATCH 18/26] refactor: Keep the PathComponent polygons as vertices and make PathHitbox a single hitbox PathComponent no longer adds a PolygonComponent for each contour: it keeps the vertices of each closed contour, decides containsPoint from them, and exposes them for intersections, in the same way as PolygonComponent does for its single contour. The filter leaves out the polygons whose vertices all lie inside of the largest one, instead of comparing bounding rectangles. PathHitbox no longer adds PolygonHitbox children either, so it is a single hitbox that cannot collide with its own parts, which was what kept every path in MultipleShapesExample permanently colliding. Its aabb starts from the vertices instead of hulling into the stale box, its ray intersection returns the nearest hit over all polygons, and it renders its polygons. The path intersection systems accumulate the intersection points of all the polygons instead of returning the first non-empty result, and the unused sampling and tolerance parameters and the TODO comments are gone. --- .../commons/collidable_path_component.dart | 29 +- examples/lib/commons/paths.dart | 8 +- .../src/collisions/hitboxes/path_hitbox.dart | 130 +++++---- .../lib/src/geometry/path_component.dart | 259 +++++++++++++----- .../lib/src/geometry/shape_intersections.dart | 140 +++++++--- .../test/geometry/path_component_test.dart | 197 ++++++++----- 6 files changed, 506 insertions(+), 257 deletions(-) diff --git a/examples/lib/commons/collidable_path_component.dart b/examples/lib/commons/collidable_path_component.dart index 4b332553953..03d7e3e8098 100644 --- a/examples/lib/commons/collidable_path_component.dart +++ b/examples/lib/commons/collidable_path_component.dart @@ -6,6 +6,11 @@ final Paint hitboxStroke = Paint() ..color = const Color(0xffffffff) ..style = .stroke; +/// A [PathComponent] with a [PathHitbox] that follows its path, so that it +/// collides and reacts to gestures as a whole. +/// +/// The hitbox polygons are rendered with the given contour paint when the +/// hitboxes are rendered, which shows how closely they follow the path. class CollidablePathComponent extends PathComponent with CollisionCallbacks, CollisionPassthrough { CollidablePathComponent({ @@ -22,22 +27,22 @@ class CollidablePathComponent extends PathComponent super.paint, super.paintLayers, Paint? contourPaint, - bool? renderHitboxes, - bool? filter, - }) : super() { - final pathHitbox = PathHitbox( + bool renderHitboxes = false, + bool filter = true, + }) : super(filter: filter) { + hitbox = PathHitbox( path: path, - filter: filter ?? true, + filter: filter, sampling: sampling, tolerance: tolerance, ); - add(pathHitbox); - if (renderHitboxes ?? false) { - final hitboxPaint = contourPaint ?? hitboxStroke; - for (final hitbox in pathHitbox.polygonHitboxes) { - hitbox.renderShape = true; - hitbox.paint = hitboxPaint; - } + if (renderHitboxes) { + hitbox + ..renderShape = true + ..paint = contourPaint ?? hitboxStroke; } + add(hitbox); } + + late final PathHitbox hitbox; } diff --git a/examples/lib/commons/paths.dart b/examples/lib/commons/paths.dart index 635b94dd81b..bebefa7f437 100644 --- a/examples/lib/commons/paths.dart +++ b/examples/lib/commons/paths.dart @@ -119,8 +119,8 @@ PathComponent pathComponentWith( // 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. + // Create a component that displays the whole path: by default, the polygons + // that are fully enclosed in the largest one are left out of the hitbox. return CollidablePathComponent( path: path, priority: shapePriority, @@ -129,7 +129,7 @@ PathComponent pathComponentWith( paint: paint ?? pathStroke, paintLayers: paintLayers, contourPaint: contourPaint, - renderHitboxes: renderHitboxes, - filter: filter, + renderHitboxes: renderHitboxes ?? false, + filter: filter ?? true, ); } diff --git a/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart index 3c31293ba33..6df6ca59d63 100644 --- a/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart +++ b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart @@ -3,8 +3,11 @@ import 'package:flame/extensions.dart'; import 'package:flame/geometry.dart'; import 'package:meta/meta.dart'; -/// In this [PathComponent], hitboxes are added to emulate a hitbox -/// that is a composition of other hitboxes. +/// A [Hitbox] in the shape of all the closed contours of a [Path]. +/// +/// The hitbox is a single hitbox, so 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 { PathHitbox({ required super.path, @@ -21,16 +24,28 @@ class PathHitbox extends PathComponent with ShapeHitbox { super.paint, super.paintLayers, super.isSolid = false, - }) { - // TODO(adario): convenience... - addHitboxes(); - } + }); - /// Our polygon hitboxes. - Iterable get polygonHitboxes => - children.query(); + 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 shape. + /// 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. @@ -39,65 +54,68 @@ class PathHitbox extends PathComponent with ShapeHitbox { Ray2 ray, { RaycastResult? out, }) { - for (final hitbox in polygonHitboxes) { - final result = hitbox.rayIntersection(ray, out: out); - if (result != null) { - return result; + 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!); } } - return null; + if (closestDistance.isInfinite) { + out?.reset(); + return null; + } + return (out ?? RaycastResult())..setFrom(_closestResult); } - /// This determines how the shape should scale if it should try to fill its - /// parents boundaries. @override void fillParent() { - // TODO(adario): is this correct? - throw UnsupportedError('PathHitbox already fills its parent'); + throw UnsupportedError( + 'Use the RectangleHitbox if you want to fill the parent', + ); } - /// Compute the [aabb] as the hitboxes hull. + /// Computes the [aabb] from the vertices of all the polygons. @override @protected void computeAabb(Aabb2 aabb) { - final boxes = polygonHitboxes.toList(); - final last = boxes.removeLast(); - aabb.hull(last.aabb); - for (final box in boxes) { - aabb.hull(box.aabb); + final polygons = globalPolygons(); + if (polygons.isEmpty) { + super.computeAabb(aabb); + return; } - } - - /// Ensure we can perform [PolygonHitbox] queries quickly. - @override - Future onLoad() async { - await super.onLoad(); - children.register(); - } - - /// Add all the hitboxes and return them. - @internal - List addHitboxes() { - final boxes = createHitboxes(this, sampling, tolerance); - addAll(PathComponent.preparePolygons(boxes, filterPolygons: filter)); - return boxes; - } - - /// Create a hitbox for each path contour with at least three vertices. - @internal - static List createHitboxes( - PathComponent path, - double sampling, - double? tolerance, - ) { - final contours = path.contours; - final boxes = []; - for (var index = 0; index < contours.length; index++) { - final contour = contours[index]; - if (contour.length > 2) { - boxes.add(PolygonHitbox(contour.vertices)); + 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; + } } } - return boxes; + // 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/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index 4b227b0414f..bc07bfa0f40 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -1,20 +1,38 @@ import 'package:collection/collection.dart'; -import 'package:flame/collisions.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]. +/// 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. They are kept as vertices, and +/// not as child components. 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.filter = true, this.sampling = 1.0, this.tolerance, + this.filter = true, super.position, super.scale, super.angle, @@ -24,42 +42,102 @@ class PathComponent extends ShapeComponent { super.key, super.paint, super.paintLayers, - super.isSolid = false, + super.isSolid, }) : path = path.toOrigin, super(size: path.getBounds().size.toVector2()) { - // TODO(adario): convenience - addPolygons(); + _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 path contours that generate - /// the polygon components. + /// The step used when sampling the contours of the [path]. final double sampling; - /// The tolerance used when sampling the path contours; if not specified, - /// it defaults to half the [sampling]. + /// The tolerance used when simplifying the sampled contours; if not given, + /// it is half the [sampling]. final double? tolerance; - /// Whether the components are filtered to include only disjoint ones. + /// Whether the polygons that lie inside of the largest one are left out. final bool filter; - /// The actual contours for this path. - late final contours = path.walkContours(sampling, tolerance); + late final List> _polygons; - /// The current path hitbox (if any). - PathHitbox? get pathHitbox => firstChild(); + /// 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), + ]); - /// Our polygon hitboxes. - Iterable get polygonComponents => - children.query(); + // 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(); - /// Ensure we can perform [PolygonComponent] queries quickly. - @override - Future onLoad() async { - await super.onLoad(); - children.register(); + 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 @@ -78,61 +156,108 @@ class PathComponent extends ShapeComponent { @override void renderDebugMode(Canvas canvas) { super.renderDebugMode(canvas); - canvas.drawPath(path, debugPaint); + canvas.drawPath(_polygonsPath, debugPaint); } - /// Add all the polygon components and return them. - @internal - List addPolygons() { - final polygons = createPolygons(this, sampling, tolerance); - addAll(preparePolygons(polygons, filterPolygons: filter)); - return polygons; + /// 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; } - /// Create a polygon for each path contour with at least three vertices. - @internal - static List createPolygons( - PathComponent path, - double sampling, - double? tolerance, - ) { - final contours = path.contours; - final polygons = []; - for (var index = 0; index < contours.length; index++) { - final contour = contours[index]; - if (contour.length > 2) { - polygons.add(PolygonComponent(contour.vertices)..renderShape = 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 polygons; + return rectIntersections; } - /// Prepare the polygons by first sorting them by size; then, (potentially) - /// filter them by keeping only the largest and all disjoint ones. - @internal - static List preparePolygons( - List polygons, { - bool filterPolygons = true, + /// 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, }) { - if (polygons.length < 2) { - return polygons; - } - // Sort the polygons by size: we will use the largest area in order to - // approximate full inclusion. - polygons.sortBy((hitbox) => hitbox.size.length2); - - // We always keep the largest hitbox: the others are discarded if they fit - // entirely within it. - if (filterPolygons) { - final largest = polygons.last; - final area = largest.toRect(); - polygons.removeWhere((element) { - if (element == largest) { - return false; + 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(); } - return area.expandToInclude(element.toRect()) == area; - }); + 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/shape_intersections.dart b/packages/flame/lib/src/geometry/shape_intersections.dart index 3b45cc56003..e3e28dfb4ac 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,81 +29,132 @@ 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 { - late final _polygons = PolygonPolygonIntersections(); + /// Returns the intersection points of the edges of all the polygons of + /// [pathA] and [pathB]. @override List intersect( PathComponent pathA, PathComponent pathB, { Rect? overlappingRect, }) { - // TODO(adario): already quadratic, should it accumulate all results? - final polygonsA = pathA.children.whereType(); - final polygonsB = pathB.children.whereType(); - for (final polygonA in polygonsA) { - for (final polygonB in polygonsB) { - final intersections = _polygons.intersect( - polygonA, - polygonB, - overlappingRect: overlappingRect, - ); - if (intersections.isNotEmpty) { - return intersections; - } - } + final intersectionPoints = _edgeIntersections( + pathA.possibleIntersectionVertices(overlappingRect), + pathB.possibleIntersectionVertices(overlappingRect), + ); + if (intersectionPoints.isEmpty) { + return _enclosedCenter(pathA, _vertexOf(pathA), pathB, _vertexOf(pathB)); } - return []; + return intersectionPoints; } } class PathPolygonIntersections extends Intersections { - late final _polygons = PolygonPolygonIntersections(); + /// Returns the intersection points of the edges of all the polygons of + /// [path] and the edges of [polygon]. @override List intersect( - PathComponent pathA, - PolygonComponent polygonB, { + PathComponent path, + PolygonComponent polygon, { Rect? overlappingRect, }) { - // TODO(adario): linear, should it accumulate all results? - final polygonsA = pathA.children.whereType(); - for (final polygonA in polygonsA) { - final intersections = _polygons.intersect( - polygonA, - polygonB, - overlappingRect: overlappingRect, + final intersectionPoints = _edgeIntersections( + path.possibleIntersectionVertices(overlappingRect), + polygon.possibleIntersectionVertices(overlappingRect), + ); + if (intersectionPoints.isEmpty) { + return _enclosedCenter( + path, + _vertexOf(path), + polygon, + polygon.globalVertices().first, ); - if (intersections.isNotEmpty) { - return intersections; - } } - return []; + return intersectionPoints; } } class CirclePathIntersections extends Intersections { - late final _polygons = CirclePolygonIntersections(); + /// Returns the intersection points of [circle] and the edges of all the + /// polygons of [path]. @override List intersect( - CircleComponent circleA, - PathComponent pathB, { + CircleComponent circle, + PathComponent path, { Rect? overlappingRect, }) { - // TODO(adario): linear, should it accumulate all results? - final polygonsB = pathB.children.whereType(); - for (final polygonB in polygonsB) { - final intersections = _polygons.intersect( - circleA, - polygonB, - overlappingRect: overlappingRect, - ); - if (intersections.isNotEmpty) { - return intersections; + final intersectionPoints = []; + for (final line in path.possibleIntersectionVertices(overlappingRect)) { + for (final intersection in circle.lineSegmentIntersections(line)) { + if (!intersectionPoints.contains(intersection)) { + intersectionPoints.add(intersection); + } } } - return []; + if (intersectionPoints.isEmpty) { + return _enclosedCenter( + circle, + circle.absoluteCenter, + path, + _vertexOf(path), + ); + } + return intersectionPoints; } } diff --git a/packages/flame/test/geometry/path_component_test.dart b/packages/flame/test/geometry/path_component_test.dart index 6dd7935d387..5bb1dc2ac88 100644 --- a/packages/flame/test/geometry/path_component_test.dart +++ b/packages/flame/test/geometry/path_component_test.dart @@ -6,79 +6,128 @@ import 'package:flame_test/test_paths.dart'; import 'package:test/test.dart'; void main() { - test('PathComponent 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('PathComponent 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('PathComponent 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('PathComponent invader2 keeps only one disjoint contour', () { - const size = Size(64, 64); - final path = TestPaths.byName('invader2', size); - final pathComponent = PathComponent(path: path); - - expect(pathComponent.children.length, 1); - }); - - test('PathComponent invader2 explicitly keeps all disjoint contours', () { - const size = Size(64, 64); - final path = TestPaths.byName('invader2', size); - - final pathComponent = PathComponent( - path: path, - filter: false, - ); - - expect(pathComponent.children.length, 3); - }); - - test('PathComponent alien2 implicitly keeps all disjoint contours', () { - const size = Size(64, 64); - final path = TestPaths.byName('alien2', size); - final pathComponent = PathComponent(path: path); - - expect(pathComponent.children.length, 4); - }); - - test('PathComponent 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); - final hitbox1 = path1.firstChild(); - final hitbox2 = path2.firstChild(); - - expect(hitbox1!.vertices.length, greaterThan(hitbox2!.vertices.length)); + 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); + expect(pathComponent.children, isEmpty); + }); + + 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); + }); }); } From 7243ad186ae86627c82b99fff8cf97acffddc9ef Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 11:52:09 +0200 Subject: [PATCH 19/26] feat: Align the PathHitbox constructor with PolygonHitbox PathHitbox takes the collisionType, and only the position, angle, anchor and isSolid of the component, like PolygonHitbox does. The isSolid default comes from ShapeComponent instead of being repeated. --- .../src/collisions/hitboxes/path_hitbox.dart | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart index 6df6ca59d63..7c5c331cc1b 100644 --- a/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart +++ b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart @@ -9,22 +9,25 @@ import 'package:meta/meta.dart'; /// 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.filter, super.sampling, super.tolerance, + super.filter, super.position, - super.scale, super.angle, super.anchor, - super.children, - super.priority, - super.key, - super.paint, - super.paintLayers, - super.isSolid = false, - }); + super.isSolid, + CollisionType collisionType = CollisionType.active, + }) { + this.collisionType = collisionType; + } late final _temporaryNormal = Vector2.zero(); late final _temporaryResult = RaycastResult(); From a3fb3f25f50a173121dacfbf4cbb563b769d3943 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 11:52:53 +0200 Subject: [PATCH 20/26] fix: Give CollidablePath in MultipleShapesExample a PathHitbox like the other collidables The path collidable now adds a PathHitbox as its hitbox, so that it is colored, dragged and thrown exactly like the polygon, rectangle and circle ones, instead of wrapping a path component with its own callbacks. CollidablePathComponent no longer passes collisions through, since its hitbox reports to it directly. --- .../commons/collidable_path_component.dart | 3 +- .../multiple_shapes_example.dart | 56 +++---------------- 2 files changed, 9 insertions(+), 50 deletions(-) diff --git a/examples/lib/commons/collidable_path_component.dart b/examples/lib/commons/collidable_path_component.dart index 03d7e3e8098..24be00cec29 100644 --- a/examples/lib/commons/collidable_path_component.dart +++ b/examples/lib/commons/collidable_path_component.dart @@ -11,8 +11,7 @@ final Paint hitboxStroke = Paint() /// /// The hitbox polygons are rendered with the given contour paint when the /// hitboxes are rendered, which shows how closely they follow the path. -class CollidablePathComponent extends PathComponent - with CollisionCallbacks, CollisionPassthrough { +class CollidablePathComponent extends PathComponent with CollisionCallbacks { CollidablePathComponent({ required super.path, super.sampling, diff --git a/examples/lib/stories/collision_detection/multiple_shapes_example.dart b/examples/lib/stories/collision_detection/multiple_shapes_example.dart index 2bb67e3e762..14411a52ad5 100644 --- a/examples/lib/stories/collision_detection/multiple_shapes_example.dart +++ b/examples/lib/stories/collision_detection/multiple_shapes_example.dart @@ -204,55 +204,15 @@ class CollidablePath extends MyCollidable { 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 { From b361f70f81369448bcdaa188ae4efb3efa0bd26c Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 11:54:04 +0200 Subject: [PATCH 21/26] docs: Describe PathComponent as it is and add the PathHitbox section The PathComponent section no longer talks about PolygonHitbox children and a renderHitboxes parameter, and explains the polygons, the filter and the open contours instead. The collision detection docs get a PathHitbox section next to PolygonHitbox. --- doc/flame/collision_detection.md | 32 ++++++++++++++++++++++++ doc/flame/components/shape_components.md | 30 ++++++++++++++-------- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/doc/flame/collision_detection.md b/doc/flame/collision_detection.md index cb8848508ef..a6c5cc32374 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. It is one hitbox, so 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 8ad5edee393..77d64eac6fc 100644 --- a/doc/flame/components/shape_components.md +++ b/doc/flame/components/shape_components.md @@ -129,17 +129,16 @@ void main() { ## PathComponent -When a whole `Path` is needed (for rendering or collision detection) instead of a single contour, -creating a `PathComponent` automatically walks all contours in the given `Path` and creates -`PolygonHitbox` objects for each contour; by default, only disjoint contours become hitboxes, -but `PathComponent` supports keeping conjoint contours via the `filter` parameter. -The component size is derived directly from the given `Path`. - -Also by default, the `Path` is rendered, whereas the hitboxes are not: this behavior may be -changed via (respectively) the `renderShape` and `renderHitboxes` parameters. - -Using the previous two-contour `Path`, creating a `PathComponent` for both contours works thusly: +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() { @@ -151,6 +150,17 @@ void main() { } ``` +The `sampling` and `tolerance` arguments control how the contours are followed, see +[](#from-a-path). The polygons are kept as vertices in `polygons`, they are not child components. + +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 From 8af641141959796f7c279ce966468f19928f5345 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 11:56:00 +0200 Subject: [PATCH 22/26] test: Cover the PathHitbox aabb, containment, collisions and ray casting The tests run with both broadphases and check that the hitbox does not collide with itself, that it collides with circles, polygons and other paths through any of its polygons, that it reports one collision and all the intersection points when several polygons touch, that solid paths enclose shapes, and that rays hit the nearest polygon. --- .../test/collisions/path_hitbox_test.dart | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 packages/flame/test/collisions/path_hitbox_test.dart 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..7888f6859fe --- /dev/null +++ b/packages/flame/test/collisions/path_hitbox_test.dart @@ -0,0 +1,225 @@ +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 and no children', () { + final hitbox = PathHitbox(path: _twoSquares()); + + expect(hitbox.polygons.length, 2); + expect(hitbox.children, isEmpty); + }); + + 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); + }, + }); + }); +} From 14510ec0b464ef9e2ae2567450302e7eb04487ca Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 13:19:42 +0200 Subject: [PATCH 23/26] chore: Remove the adario entry from the cspell usernames dictionary --- .github/.cspell/people_usernames.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/.cspell/people_usernames.txt b/.github/.cspell/people_usernames.txt index 7eb8885d788..f17b6d1723c 100644 --- a/.github/.cspell/people_usernames.txt +++ b/.github/.cspell/people_usernames.txt @@ -1,5 +1,4 @@ # specific people's names and/or usernames -adario # github.com/adario akida # github.com/akida bdero # github.com/bdero bluefireteam # github.com/bluefireteam From d60c613af81f8b5d90be219ddf646f17c98504ae Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 15:18:41 +0200 Subject: [PATCH 24/26] docs: Say when the CollidablePathComponent hitbox polygons are rendered --- examples/lib/commons/collidable_path_component.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/lib/commons/collidable_path_component.dart b/examples/lib/commons/collidable_path_component.dart index 24be00cec29..a2f3c3846ed 100644 --- a/examples/lib/commons/collidable_path_component.dart +++ b/examples/lib/commons/collidable_path_component.dart @@ -9,8 +9,8 @@ final Paint hitboxStroke = Paint() /// A [PathComponent] with a [PathHitbox] that follows its path, so that it /// collides and reacts to gestures as a whole. /// -/// The hitbox polygons are rendered with the given contour paint when the -/// hitboxes are rendered, which shows how closely they follow the path. +/// The hitbox polygons are rendered with the given contour paint when +/// `renderHitboxes` is true, which shows how closely they follow the path. class CollidablePathComponent extends PathComponent with CollisionCallbacks { CollidablePathComponent({ required super.path, From 4343ce1f1d8ee2f483bb8b308077298586e22166 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 16:36:28 +0200 Subject: [PATCH 25/26] docs: Describe PathComponent and PathHitbox as they are and document the tolerance argument --- doc/flame/collision_detection.md | 8 ++++---- doc/flame/components/shape_components.md | 18 ++++++++++-------- .../src/collisions/hitboxes/path_hitbox.dart | 2 +- .../experimental/geometry/shapes/polygon.dart | 2 +- .../flame/lib/src/geometry/path_component.dart | 4 ++-- .../lib/src/geometry/polygon_component.dart | 2 +- .../test/collisions/path_hitbox_test.dart | 3 +-- .../test/geometry/path_component_test.dart | 1 - 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/doc/flame/collision_detection.md b/doc/flame/collision_detection.md index a6c5cc32374..d47cc0b31e3 100644 --- a/doc/flame/collision_detection.md +++ b/doc/flame/collision_detection.md @@ -301,10 +301,10 @@ 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. It is one hitbox, so 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. +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 diff --git a/doc/flame/components/shape_components.md b/doc/flame/components/shape_components.md index 77d64eac6fc..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() { @@ -151,7 +153,7 @@ void main() { ``` The `sampling` and `tolerance` arguments control how the contours are followed, see -[](#from-a-path). The polygons are kept as vertices in `polygons`, they are not child components. +[](#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, diff --git a/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart index 7c5c331cc1b..bff2b576eb4 100644 --- a/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart +++ b/packages/flame/lib/src/collisions/hitboxes/path_hitbox.dart @@ -5,7 +5,7 @@ import 'package:meta/meta.dart'; /// A [Hitbox] in the shape of all the closed contours of a [Path]. /// -/// The hitbox is a single hitbox, so it collides, contains points and is hit +/// 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 { diff --git a/packages/flame/lib/src/experimental/geometry/shapes/polygon.dart b/packages/flame/lib/src/experimental/geometry/shapes/polygon.dart index 508eb790d92..337862ed682 100644 --- a/packages/flame/lib/src/experimental/geometry/shapes/polygon.dart +++ b/packages/flame/lib/src/experimental/geometry/shapes/polygon.dart @@ -42,7 +42,7 @@ 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. The given [tolerance] decides + /// 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] diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index bc07bfa0f40..d0bb411a4e1 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -13,8 +13,8 @@ import 'package:meta/meta.dart'; /// /// 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. They are kept as vertices, and -/// not as child components. +/// 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]. diff --git a/packages/flame/lib/src/geometry/polygon_component.dart b/packages/flame/lib/src/geometry/polygon_component.dart index ab65b1be7ac..73f79bb56f7 100644 --- a/packages/flame/lib/src/geometry/polygon_component.dart +++ b/packages/flame/lib/src/geometry/polygon_component.dart @@ -102,7 +102,7 @@ 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. The given [tolerance] decides + /// 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] diff --git a/packages/flame/test/collisions/path_hitbox_test.dart b/packages/flame/test/collisions/path_hitbox_test.dart index 7888f6859fe..8c428256bb6 100644 --- a/packages/flame/test/collisions/path_hitbox_test.dart +++ b/packages/flame/test/collisions/path_hitbox_test.dart @@ -22,11 +22,10 @@ TestBlock _pathBlock(Vector2 position, {bool isSolid = false}) { void main() { group('PathHitbox', () { - test('has one polygon per closed contour and no children', () { + test('has one polygon per closed contour', () { final hitbox = PathHitbox(path: _twoSquares()); expect(hitbox.polygons.length, 2); - expect(hitbox.children, isEmpty); }); test('takes the collision type', () { diff --git a/packages/flame/test/geometry/path_component_test.dart b/packages/flame/test/geometry/path_component_test.dart index 5bb1dc2ac88..f18e728a2bd 100644 --- a/packages/flame/test/geometry/path_component_test.dart +++ b/packages/flame/test/geometry/path_component_test.dart @@ -49,7 +49,6 @@ void main() { final pathComponent = PathComponent(path: path); expect(pathComponent.polygons.length, 1); - expect(pathComponent.children, isEmpty); }); test('invader2 explicitly keeps all contours', () { From 1426788071b270cdc862cfca45f7feb33fc81d2c Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 16:45:02 +0200 Subject: [PATCH 26/26] refactor: Build the PathHitbox in each example instead of a shared CollidablePathComponent --- .../commons/collidable_path_component.dart | 47 ------------------- examples/lib/commons/paths.dart | 22 ++++++--- .../rays_in_shape_example.dart | 7 ++- .../collision_detection/raytrace_example.dart | 17 ++++--- .../input/gesture_hitboxes_example.dart | 13 +++-- 5 files changed, 39 insertions(+), 67 deletions(-) delete mode 100644 examples/lib/commons/collidable_path_component.dart diff --git a/examples/lib/commons/collidable_path_component.dart b/examples/lib/commons/collidable_path_component.dart deleted file mode 100644 index a2f3c3846ed..00000000000 --- a/examples/lib/commons/collidable_path_component.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flame/collisions.dart'; -import 'package:flame/components.dart'; -import 'package:flutter/material.dart'; - -final Paint hitboxStroke = Paint() - ..color = const Color(0xffffffff) - ..style = .stroke; - -/// A [PathComponent] with a [PathHitbox] that follows its path, so that it -/// collides and reacts to gestures as a whole. -/// -/// The hitbox polygons are rendered with the given contour paint when -/// `renderHitboxes` is true, which shows how closely they follow the path. -class CollidablePathComponent extends PathComponent with CollisionCallbacks { - CollidablePathComponent({ - required super.path, - super.sampling, - super.tolerance, - super.position, - super.scale, - super.angle, - super.anchor, - super.children, - super.priority, - super.key, - super.paint, - super.paintLayers, - Paint? contourPaint, - bool renderHitboxes = false, - bool filter = true, - }) : super(filter: filter) { - hitbox = PathHitbox( - path: path, - filter: filter, - sampling: sampling, - tolerance: tolerance, - ); - if (renderHitboxes) { - hitbox - ..renderShape = true - ..paint = contourPaint ?? hitboxStroke; - } - add(hitbox); - } - - late final PathHitbox hitbox; -} diff --git a/examples/lib/commons/paths.dart b/examples/lib/commons/paths.dart index bebefa7f437..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/collidable_path_component.dart'; +import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; import 'package:flame/palette.dart'; @@ -11,6 +11,10 @@ final _rnd = Random(); const shapePriority = 1; +final whiteStroke = Paint() + ..color = const Color(0xffffffff) + ..style = PaintingStyle.stroke; + final pathStroke = Paint() ..color = BasicPalette.blue.color ..style = PaintingStyle.stroke @@ -119,17 +123,23 @@ PathComponent pathComponentWith( // original aspect ratio. final path = resize ? srcPath.resizeTo(size, keepRatio: true) : srcPath; - // Create a component that displays the whole path: by default, the polygons - // that are fully enclosed in the largest one are left out of the hitbox. - return CollidablePathComponent( + // 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, position: position ?? Vector2.zero(), anchor: anchor ?? Anchor.center, paint: paint ?? pathStroke, paintLayers: paintLayers, - contourPaint: contourPaint, - renderHitboxes: renderHitboxes ?? false, filter: filter ?? true, + children: [hitbox], ); } diff --git a/examples/lib/stories/collision_detection/rays_in_shape_example.dart b/examples/lib/stories/collision_detection/rays_in_shape_example.dart index ad2ce86b87c..2dfb1f7f71e 100644 --- a/examples/lib/stories/collision_detection/rays_in_shape_example.dart +++ b/examples/lib/stories/collision_detection/rays_in_shape_example.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:math'; -import 'package:examples/commons/collidable_path_component.dart'; import 'package:examples/commons/paths.dart'; import 'package:examples/commons/rounded_rect_component.dart'; import 'package:flame/collisions.dart'; @@ -401,7 +400,7 @@ class RaysInShapeWorld extends World radius: _componentSize.x * 0.6, anchor: Anchor.center, position: Vector2.zero(), - paint: hitboxStroke, + paint: whiteStroke, children: [CircleHitbox()], ), RectangleComponent( @@ -409,7 +408,7 @@ class RaysInShapeWorld extends World size: _componentSize, anchor: Anchor.center, position: Vector2.zero(), - paint: hitboxStroke, + paint: whiteStroke, children: [RectangleHitbox()], ), PositionComponent( @@ -427,7 +426,7 @@ class RaysInShapeWorld extends World anchor: Anchor.center, position: Vector2.zero(), ) - ..paint = hitboxStroke + ..paint = whiteStroke ..renderShape = true, ], ), diff --git a/examples/lib/stories/collision_detection/raytrace_example.dart b/examples/lib/stories/collision_detection/raytrace_example.dart index 062d91c2de6..116e89bc0e3 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/collidable_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 - CollidablePathComponent( - path: randomPath(halfCanvas * 2), + // The rays should bounce on the inner contours too, so the hitbox + // keeps every polygon of the path. + PathComponent( + path: canvasPath, position: halfCanvas.toVector2(), anchor: .center, paint: boxPaint, - filter: 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( [ @@ -96,12 +99,12 @@ bounce on will appear. anchor: Anchor.center, children: [CircleHitbox()], ), - CollidablePathComponent( - path: path, + PathComponent( + path: extraPath, position: Vector2.all(350), anchor: Anchor.center, paint: boxPaint, - filter: 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 9a567243c69..17eec033011 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/collidable_path_component.dart'; import 'package:examples/commons/paths.dart'; import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; @@ -83,7 +82,7 @@ class _GestureHitboxesWorld extends World } } -class MyPathComponent extends CollidablePathComponent +class MyPathComponent extends PathComponent with TapCallbacks, HoverCallbacks, GestureHitboxes { late final Color baseColor; @@ -92,7 +91,15 @@ class MyPathComponent extends CollidablePathComponent 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 {