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 diff --git a/examples/lib/commons/paths.dart b/examples/lib/commons/paths.dart index 1f48d85941b..8d0ac923530 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'; @@ -11,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/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/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, ], ), 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/packages/flame/lib/geometry.dart b/packages/flame/lib/geometry.dart index cc9d7f4d204..7d60a1b2048 100644 --- a/packages/flame/lib/geometry.dart +++ b/packages/flame/lib/geometry.dart @@ -2,6 +2,7 @@ export 'src/geometry/circle_component.dart'; export 'src/geometry/constants.dart'; export 'src/geometry/line.dart'; export 'src/geometry/line_segment.dart'; +export 'src/geometry/path_component.dart'; export 'src/geometry/polygon_component.dart'; export 'src/geometry/polygon_ray_intersection.dart'; export 'src/geometry/ray2.dart'; diff --git a/packages/flame/lib/src/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/examples/lib/commons/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart similarity index 68% rename from examples/lib/commons/path_component.dart rename to packages/flame/lib/src/geometry/path_component.dart index 7ef0c5126a0..50e7646592d 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'; @@ -16,6 +15,9 @@ class PathComponent extends ShapeComponent with CollisionCallbacks, CollisionPassthrough { PathComponent({ required Path path, + this.sampling = 1.0, + this.tolerance, + this.hitboxesPriority, this.addHitboxes = false, this.loadHitboxes = true, this.renderHitboxes = false, @@ -30,16 +32,35 @@ 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(); } } + /// The default paint used to render hitboxes. + static Paint hitboxStroke = Paint() + ..color = const Color(0xffffffff) + ..style = .stroke; + /// The path to display, already rooted at the origin. final Path path; + /// 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; + /// Whether the hitboxes are added right away, in the constructor. final bool addHitboxes; @@ -56,7 +77,7 @@ class PathComponent extends ShapeComponent final Paint? hitboxesPaint; var _hitboxesAdded = false; - late final _hitboxes = _hitboxesFor(path); + late final _hitboxes = _createHitboxes(); @override FutureOr onLoad() async { @@ -93,6 +114,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; @@ -116,14 +138,21 @@ class PathComponent extends ShapeComponent return hitboxes; } - List _hitboxesFor(Path path) { - final count = path.contours.length; - return [ - for (var contour = 0; contour < count; contour++) - PolygonHitbox.fromPath(path, contour: contour) - ..priority = priority + 1 - ..paint = hitboxesPaint ?? whiteStroke - ..renderShape = renderHitboxes, - ]; + // 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 new file mode 100644 index 00000000000..4f65263fef2 --- /dev/null +++ b/packages/flame/test/geometry/path_component_test.dart @@ -0,0 +1,88 @@ +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'; + +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); + }); + + 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)); + }); +}