diff --git a/.github/.cspell/people_usernames.txt b/.github/.cspell/people_usernames.txt index bdf170b2099..5b011b00b5d 100644 --- a/.github/.cspell/people_usernames.txt +++ b/.github/.cspell/people_usernames.txt @@ -7,6 +7,7 @@ erickzanardo # github.com/erickzanardo feroult # github.com/feroult fröber # github.com/Brixto gnarhard # github.com/gnarhard +Hertel # Co-author of triangulation algorithm Hoodead # github.com/kornellapu kenney # kenney.nl Klingsbo # github.com/spydon @@ -17,6 +18,7 @@ Lousberg # kaylousberg.itch.io luan # github.com/luanpotter luanpotter # github.com/luanpotter Lukas # github.com/spydon +Mehlhorn # Co-author of triangulation algorithm pgainullin # github.com/pgainullin Schnurber # github.com/schnurber spydon # github.com/spydon diff --git a/doc/flame/collision_detection.md b/doc/flame/collision_detection.md index 89017b64a20..67cc871ee2d 100644 --- a/doc/flame/collision_detection.md +++ b/doc/flame/collision_detection.md @@ -330,6 +330,46 @@ costs accordingly. Keep the `filter` on unless the inner contours matter, and us `sampling` that still follows the outline closely enough. +### Hitboxes from the outline of a sprite + +When the outline of a sprite is not available as a `Path`, it can be traced from the sprite itself +with `Sprite.contour`, which gives a closed contour for each of the separate parts of the sprite +that are not transparent, leaving out the holes in them. The same is available for a region of an +`Image` with `ImageExtension.contour`, and for raw pixels with `ImageExtension.contourFromPixels`. + +A pixel belongs to the outlined parts when its alpha is at least the `alphaThreshold`, which is +`0.5` by default, and the contours pass between the pixels where the alpha reaches the threshold, +so that they follow the anti-aliased edges of the sprite more closely than a pixel. Use a low +threshold, like `1 / 255`, to include every visible pixel. + +The contours are relative to the top left corner of the sprite, and they are scaled to the given +`size`, so that they line up with the sprite rendered by a `SpriteComponent` of that size. Since a +`PathHitbox` moves its path to its own origin, place it at the top left corner of the contours: + +```dart +class Spaceship extends SpriteComponent with CollisionCallbacks { + Spaceship(Sprite sprite) : super(sprite: sprite, size: Vector2(64, 48)); + + @override + Future onLoad() async { + await super.onLoad(); + final outline = await sprite!.contour(size: size); + add( + PathHitbox( + path: outline, + position: outline.getBounds().topLeft.toVector2(), + sampling: 2, + ), + ); + } +} +``` + +A `PolygonHitbox.fromPath(outline, sampling: 2)` places itself and is enough when the sprite has a +single part. Tracing the outline reads back the pixels of the image, so do it while loading, and +only once for all the components that share the same sprite. + + ### 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 dd7698e8d21..e4e3f4a98cc 100644 --- a/doc/flame/components/shape_components.md +++ b/doc/flame/components/shape_components.md @@ -153,9 +153,11 @@ void main() { ``` The `sampling` and `tolerance` arguments control how the contours are followed, see -[](#from-a-path). The vertices of each polygon are available in `polygons`. +[](#from-a-path). The vertices of each polygon are available in `polygons`, and `path.toPolygons()` +gives the same polygons, in the coordinates of the path, without making a component. -Contours with fewer than three vertices, like open lines, are rendered but do not become polygons. +Open contours and degenerate ones, with fewer than three vertices or no area, 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. @@ -164,6 +166,57 @@ The `PathHitbox` is the hitbox counterpart of the `PathComponent`, see [](../collision_detection.md#pathhitbox). +### Convex pieces + +Some consumers of polygons only accept convex ones with a limited number of vertices, like the +shapes of physics engines such as Box2D, which take at most 8 vertices. The `convexPieces` function +splits a simple polygon, convex or concave, into such pieces: it first splits the polygon into +triangles by ear clipping, and then merges neighboring triangles as long as they stay convex and +within `maxVertices`, which defaults to `8` (the Hertel-Mehlhorn algorithm). The pieces are made of +copies of the vertices of the polygon and go in the same direction. + +A polygon that touches itself at a vertex, like the outline of two shapes that meet at a corner, is +split there first, and a hole that touches the outline at a vertex is filled. The parts of a +polygon that crosses itself may be left out. + +Engines also reject the polygons that are too small for their tolerances, so two more arguments +clean up the result: + +- `minDistance`: Of the consecutive vertices closer than this, only the first one is kept, and the + vertices closer than half of it to the line through their neighbors are left out. +- `minWidth`: The pieces narrower than this are left out. + +Both default to `0`, which keeps every vertex and every piece. For Box2D, `minDistance` is 4 times +its linear slop and `minWidth` twice it. + +The cost grows about with the square of the number of vertices, so split the polygons when loading +and not in every tick. The polygons from `Path.toPolygons` are a convenient input, since they follow +each contour of a path with straight edges and are already simplified according to their +`sampling`: + +```dart +void main() { + // A U shape, which is concave. + final path = Path() + ..addPolygon(const [ + Offset(0, 0), + Offset(40, 0), + Offset(40, 40), + Offset(30, 40), + Offset(30, 10), + Offset(10, 10), + Offset(10, 40), + Offset(0, 40), + ], true); + + final pieces = [ + for (final polygon in path.toPolygons()) + ...convexPieces(polygon), + ]; +} +``` + + ## RectangleComponent A `RectangleComponent` is created very similarly to how a `PositionComponent` is created, since it diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart index e51224e1e29..7c6cfc50668 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart @@ -20,6 +20,7 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/sprite_body_exam import 'package:examples/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/widget_example.dart'; import 'package:flame/game.dart'; +import 'package:flutter/widgets.dart'; import 'package:widgetbook/widgetbook.dart'; String link(String example) => @@ -55,7 +56,9 @@ WidgetbookComponent forge2DStories() { ), ExampleUseCase( name: 'Sprite Bodies', - builder: (_) => GameWidget(game: SpriteBodyExample()), + builder: (context) => _SpriteBodyStory( + showPieces: context.knobs.boolean(label: 'Show pieces'), + ), codeLink: link('sprite_body_example.dart'), info: SpriteBodyExample.description, ), @@ -154,3 +157,26 @@ WidgetbookComponent jointsStories() { ], ); } + +/// Hosts a single [SpriteBodyExample] and applies the [showPieces] knob to it, +/// so that changing the knob doesn't restart the game. +class const _SpriteBodyStory({required final bool showPieces}) + extends StatefulWidget { + @override + State<_SpriteBodyStory> createState() => _SpriteBodyStoryState(); +} + +class _SpriteBodyStoryState() extends State<_SpriteBodyStory> { + late final _game = SpriteBodyExample(showPieces: widget.showPieces); + + @override + void didUpdateWidget(_SpriteBodyStory oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.showPieces != oldWidget.showPieces) { + (_game.world as SpriteBodyWorld).showPieces = widget.showPieces; + } + } + + @override + Widget build(BuildContext context) => GameWidget(game: _game); +} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index 04e9ecd708f..54cd4dcdef6 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -1,38 +1,183 @@ import 'dart:math'; +import 'dart:ui'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/geometry.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class SpriteBodyExample() extends Forge2DExampleGame { +class SpriteBodyExample({bool showPieces = false}) + extends Forge2DExampleGame + with HasGameRef { static const String description = ''' - In this example we show how to add a sprite on top of a `BodyComponent`. - Tap the screen to add more pizzas. + In this example we show how to add a sprite on top of a `BodyComponent` + whose shape follows the outline of the sprite. + + The outline is traced from the pixels of the image that are not + transparent, with `Sprite.contour`, and the body collides as its convex + pieces, which the Show pieces knob draws. + + Tap the screen to add more bodies, which alternate between flames and + pizzas. '''; this : super( gravity: Vector2(0, 10.0), - world: SpriteBodyWorld(), + world: SpriteBodyWorld(showPieces: showPieces), ); } -class SpriteBodyWorld() +/// A sprite, with its size in meters, with the aspect ratio of the sprite, +/// and the convex pieces of its outline, which all the bodies of that sprite +/// share, since tracing it reads back the pixels of the image. +typedef _Shape = ({Sprite sprite, Vector2 size, List> pieces}); + +class SpriteBodyWorld({bool showPieces = false}) extends Forge2DWorld with TapCallbacks, HasGameRef { + /// The images of the bodies, which the taps go through in turn, with their + /// widths in meters. + static const images = {'flame.png': 8.0, 'pizza.png': 6.0, 'zap.png': 9.0}; + + /// The shape of each of the [images]. + late final List<_Shape> _shapes; + + /// The number of bodies added by tapping, which picks the next shape. + int _taps = 0; + + bool _showPieces = showPieces; + + /// Whether the convex pieces of the bodies are drawn, which applies to the + /// bodies already added too. + bool get showPieces => _showPieces; + set showPieces(bool value) { + _showPieces = value; + for (final body in children.whereType()) { + body.renderBody = value; + } + } + @override Future onLoad() async { await super.onLoad(); + gameRef.camera.viewport.add(FpsTextComponent(position: Vector2(8, 4))); addAll(createBoundaries(gameRef)); + _shapes = [ + for (final MapEntry(key: image, value: width) in images.entries) + await _loadShape(image, width), + ]; + } + + /// The shape of the [image], scaled to the [width] in meters. + Future<_Shape> _loadShape(String image, double width) async { + final sprite = await gameRef.loadSprite('assets/images/$image'); + final size = sprite.srcSize..scale(width / sprite.srcSize.x); + final pieces = await ContourBody.piecesOf( + sprite, + size, + gameRef.metersToPixels, + ); + return (sprite: sprite, size: size, pieces: pieces); } @override void onTapDown(TapDownEvent info) { super.onTapDown(info); - final position = info.localPosition; - add(Pizza(position, size: Vector2(10, 15))); + final shape = _shapes[_taps++ % _shapes.length]; + add( + ContourBody( + info.localPosition, + sprite: shape.sprite, + pieces: shape.pieces, + size: shape.size, + )..renderBody = showPieces, + ); + } +} + +/// A body that is drawn by a [sprite] of the given [size], in meters, and +/// collides as the convex [pieces] of its outline, which are drawn on top of +/// the [sprite] when [renderBody] is true. +class ContourBody( + final Vector2 initialPosition, { + required final Sprite sprite, + required final List> pieces, + required final Vector2 size, +}) extends BodyComponent with GlowingBody { + this + : super( + paint: Paint()..color = ExampleColors.sky, + renderBody: false, + ); + + /// The linear slop of Box2D in meters, as `Tolerances.linearSlop` with the + /// default length units, which is not used here as it needs the native + /// library: the points of a polygon closer than 4 times it are welded, and + /// the ones closer than twice it to an edge are dropped, see + /// `b2ComputeHull`. + static const linearSlop = 0.005; + + /// The convex pieces of the outline of the [sprite], drawn with the given + /// [size] in meters, relative to its center. + /// + /// The outline is traced in pixels rather than in meters, with [pixels] per + /// meter, so that the default sampling of the polygons of a + /// [PathComponent] follows it closely. The parts of the sprite that are + /// apart give separate polygons, and so separate pieces. + static Future>> piecesOf( + Sprite sprite, + Vector2 size, + double pixels, + ) async { + final pixelSize = size * pixels; + final outline = await sprite.contour(size: pixelSize); + // The outline is relative to the top left corner of the sprite, while the + // pieces are relative to its center. + final center = pixelSize / 2; + return [ + for (final polygon in outline.toPolygons()) + ...convexPieces( + [for (final vertex in polygon) (vertex - center) / pixels], + minDistance: 4 * linearSlop, + minWidth: 2 * linearSlop, + ), + ]; + } + + @override + double get outlineWidth => 0.05; + + // The sprite is drawn here rather than by a child, since the children are + // drawn after the body, and the sprite would hide the pieces. + @override + void render(Canvas canvas) { + sprite.render(canvas, size: size, anchor: Anchor.center); + super.render(canvas); + } + + @override + Body createBody() { + final shapeDef = ShapeDef( + userData: this, // To be able to determine object in collision + material: SurfaceMaterial(restitution: 0.4, friction: 0.5), + ); + + final angle = (initialPosition.x + initialPosition.y) / 2 * pi; + final bodyDef = BodyDef( + position: initialPosition, + rotation: Rot.fromAngle(angle), + angularVelocity: angle / 8, + type: BodyType.dynamic, + ); + final body = world.createBody(bodyDef); + for (final piece in pieces) { + body.createShape(Polygon(piece), shapeDef); + } + return body; } } diff --git a/examples/lib/stories/collision_detection/collidable_sprites_example.dart b/examples/lib/stories/collision_detection/collidable_sprites_example.dart new file mode 100644 index 00000000000..d933d9bf234 --- /dev/null +++ b/examples/lib/stories/collision_detection/collidable_sprites_example.dart @@ -0,0 +1,150 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/game.dart'; +import 'package:flame/geometry.dart'; +import 'package:flame/palette.dart'; + +class CollidableSpritesExample() extends FlameGame with HasCollisionDetection { + static const description = ''' + In this example you can see four spinning sprites which are moving + straight along the same route as the birds of the animation example, until + they hit either another sprite or the wall, which makes them turn back. + The hitboxes of the sprites follow their outlines, which are traced from + the pixels of their images that are not transparent, and are marked with + the white lines. + '''; + + @override + Future onLoad() async { + camera.viewport.add(FpsTextComponent(position: Vector2(8, 4))); + + add(ScreenHitbox()); + const componentWidth = 150.0; + // Top left component + add( + CollidableSpriteComponent( + 'assets/images/flame.png', + Vector2.all(200), + componentWidth * 2 / 3, + position: Vector2.all(100), + ), + ); + // Bottom right component + add( + CollidableSpriteComponent( + 'assets/images/layers/player.png', + Vector2(-100, -100), + componentWidth / 2, + position: size.clone()..sub(Vector2.all(200)), + ), + ); + // Bottom left component + add( + CollidableSpriteComponent( + 'assets/images/zap.png', + Vector2(100, -100), + componentWidth, + position: Vector2(100, size.y - 100), + angle: pi / 4, + ), + ); + // Top right component + add( + CollidableSpriteComponent( + 'assets/images/layers/enemy.png', + Vector2(-300, 300), + componentWidth / 3, + position: Vector2(size.x - 100, 100), + angle: pi / 4, + ), + ); + } +} + +/// A sprite of the image at [path] that is [spriteWidth] wide, with the +/// aspect ratio of the image, and that moves with the [velocity] while it +/// keeps turning around its center, a full circle every 3 seconds. Its hitbox +/// follows the outline of the sprite, and a collision turns it back. +class CollidableSpriteComponent( + final String path, + final Vector2 velocity, + final double spriteWidth, { + required Vector2 position, + double angle = -pi / 4, +}) extends SpriteComponent with CollisionCallbacks, HasGameRef { + this + : super( + position: position, + angle: angle, + anchor: Anchor.center, + ); + + @override + Future onLoad() async { + final sprite = this.sprite = await gameRef.loadSprite(path); + size = sprite.srcSize..scale(spriteWidth / sprite.srcSize.x); + // The outline is relative to the top left corner of the sprite, while the + // hitbox moves it to its own origin, so the hitbox is placed where the + // outline starts. + final outline = await sprite.contour(size: size); + final hitboxPaint = BasicPalette.white.paint() + ..style = PaintingStyle.stroke; + add( + PathHitbox( + path: outline, + position: outline.getBounds().topLeft.toVector2(), + ) + ..paint = hitboxPaint + ..renderShape = true, + ); + add(RotateEffect.by(tau, EffectController(duration: 3, infinite: true))); + } + + @override + void update(double dt) { + super.update(dt); + position += velocity * dt; + } + + @override + void onCollisionStart( + List intersectionPoints, + PositionComponent other, + ) { + super.onCollisionStart(intersectionPoints, other); + // Only a sprite that moves towards what it hit turns back, so that two + // collisions that start at the same time do not cancel each other out, + // and a sprite that is caught up by a faster one keeps going. + if (velocity.dot(_towards(other, intersectionPoints)) > 0) { + velocity.negate(); + } + } + + /// The direction of the [other] component that this one hit at the + /// [intersectionPoints]: that of the walls that the points are on, or of + /// the center of the other sprite. + Vector2 _towards(PositionComponent other, List intersectionPoints) { + if (other is! ScreenHitbox) { + return other.absoluteCenter - absoluteCenter; + } + final towards = Vector2.zero(); + for (final point in intersectionPoints) { + if (point.x <= 1) { + towards.x = -1; + } else if (point.x >= other.size.x - 1) { + towards.x = 1; + } + if (point.y <= 1) { + towards.y = -1; + } else if (point.y >= other.size.y - 1) { + towards.y = 1; + } + } + return towards; + } +} diff --git a/examples/lib/stories/collision_detection/collision_detection.dart b/examples/lib/stories/collision_detection/collision_detection.dart index 5802a13ca2d..ea42d7c3e9a 100644 --- a/examples/lib/stories/collision_detection/collision_detection.dart +++ b/examples/lib/stories/collision_detection/collision_detection.dart @@ -3,6 +3,7 @@ import 'package:examples/commons/example_use_case.dart'; import 'package:examples/stories/collision_detection/bouncing_ball_example.dart'; import 'package:examples/stories/collision_detection/circles_example.dart'; import 'package:examples/stories/collision_detection/collidable_animation_example.dart'; +import 'package:examples/stories/collision_detection/collidable_sprites_example.dart'; import 'package:examples/stories/collision_detection/multiple_shapes_example.dart'; import 'package:examples/stories/collision_detection/multiple_worlds_example.dart'; import 'package:examples/stories/collision_detection/quadtree_example.dart'; @@ -27,6 +28,14 @@ WidgetbookComponent collisionDetectionStories() { ), info: CollidableAnimationExample.description, ), + ExampleUseCase( + name: 'Collidable SpriteComponent', + builder: (_) => GameWidget(game: CollidableSpritesExample()), + codeLink: baseLink( + 'collision_detection/collidable_sprites_example.dart', + ), + info: CollidableSpritesExample.description, + ), ExampleUseCase( name: 'Circles', builder: (_) => GameWidget(game: CirclesExample()), diff --git a/packages/flame/benchmark/README.md b/packages/flame/benchmark/README.md index f2f3454a07e..eeb8aabdf31 100644 --- a/packages/flame/benchmark/README.md +++ b/packages/flame/benchmark/README.md @@ -100,6 +100,11 @@ which takes minutes. To compare a change, run it on both branches. shapes, the effect of the simplification tolerance of `walkContours` next to the samples before they are simplified, and polygon-polygon intersection cost. +- `image_contour_benchmark.dart`: a standalone suite, not part of + `main.dart`, that measures tracing the outlines of images with + `ImageExtension.contourFromPixels`, including a transparent image for the + scan of the pixels alone, splitting stars of growing size with + `convexPieces`, and the whole way from the pixels to the convex pieces. - `ray_intersection_benchmark.dart`: `rayIntersection` on polygon hitboxes that are sampled from a concave and from a convex `Path` contour, with one precomputed ray for each hitbox in every tick. diff --git a/packages/flame/benchmark/image_contour_benchmark.dart b/packages/flame/benchmark/image_contour_benchmark.dart new file mode 100644 index 00000000000..e73358d21ce --- /dev/null +++ b/packages/flame/benchmark/image_contour_benchmark.dart @@ -0,0 +1,119 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/geometry.dart'; + +/// Measures tracing the outlines of images with +/// [ImageExtension.contourFromPixels] and splitting polygons into +/// [convexPieces], from the pixels to the convex pieces of a body. +/// +/// This is a standalone suite, not part of `main.dart`. Run it with +/// `flutter test benchmark/image_contour_benchmark.dart`. +Future main() async { + for (final size in [128, 512, 2048]) { + TraceBenchmark(size).report(); + } + TraceBenchmark(2048, transparent: true).report(); + for (final vertices in [50, 200, 800, 2000]) { + ConvexPiecesBenchmark(vertices).report(); + } + for (final size in [512, 2048]) { + PipelineBenchmark(size).report(); + } +} + +/// A benchmark whose every sample is a single [run], since a run can take +/// tens of milliseconds, while the harness runs it 10 times per sample by +/// default. +abstract class _SingleRunBenchmark(super.name) extends BenchmarkBase { + @override + void exercise() => run(); +} + +/// Tracing the outlines of a [size] by [size] image, which is a blob with +/// wavy anti-aliased edges, or fully [transparent] to measure the scan of the +/// pixels alone. +class TraceBenchmark(final int size, {final bool transparent = false}) + extends _SingleRunBenchmark { + this + : super( + 'Trace contours of ${transparent ? 'transparent' : 'blob'} ' + '${size}x$size', + ); + + late final Uint8List _pixels; + + @override + void setup() { + _pixels = transparent ? Uint8List(size * size * 4) : _blob(size); + } + + @override + void run() { + ImageExtension.contourFromPixels(_pixels, size, size); + } +} + +/// Splitting a star with [vertices] vertices, which is concave, into convex +/// pieces of at most 8 vertices. +class ConvexPiecesBenchmark(final int vertices) extends _SingleRunBenchmark { + this : super('Convex pieces of a star with $vertices vertices'); + + late final List _star; + + @override + void setup() { + _star = [ + for (var i = 0; i < vertices; i++) + Vector2(cos(2 * pi * i / vertices), sin(2 * pi * i / vertices)) + ..scale(i.isEven ? 10 : 6), + ]; + } + + @override + void run() { + convexPieces(_star); + } +} + +/// The whole way from the pixels of a [size] by [size] blob to the convex +/// pieces of its outline, through the polygons that a [PathComponent] makes +/// of it with the default sampling. +class PipelineBenchmark(final int size) extends _SingleRunBenchmark { + this : super('Pixels to convex pieces of blob ${size}x$size'); + + late final Uint8List _pixels; + + @override + void setup() { + _pixels = _blob(size); + } + + @override + void run() { + final outline = ImageExtension.contourFromPixels(_pixels, size, size); + for (final polygon in outline.toPolygons()) { + convexPieces(polygon); + } + } +} + +/// A [size] by [size] image of a blob with 7 waves along its edge, which is +/// opaque inside and anti-aliased over a pixel along the edge. +Uint8List _blob(int size) { + final pixels = Uint8List(size * size * 4); + final center = size / 2; + for (var y = 0; y < size; y++) { + for (var x = 0; x < size; x++) { + final dx = x + 0.5 - center; + final dy = y + 0.5 - center; + final radius = center * (0.7 + 0.2 * sin(7 * atan2(dy, dx))); + final inside = radius - sqrt(dx * dx + dy * dy); + pixels[(y * size + x) * 4 + 3] = (inside.clamp(0, 1) * 255).round(); + } + } + return pixels; +} diff --git a/packages/flame/lib/geometry.dart b/packages/flame/lib/geometry.dart index 7d60a1b2048..c5e81759b1a 100644 --- a/packages/flame/lib/geometry.dart +++ b/packages/flame/lib/geometry.dart @@ -1,5 +1,6 @@ export 'src/geometry/circle_component.dart'; export 'src/geometry/constants.dart'; +export 'src/geometry/convex_pieces.dart'; export 'src/geometry/line.dart'; export 'src/geometry/line_segment.dart'; export 'src/geometry/path_component.dart'; diff --git a/packages/flame/lib/src/extensions/image.dart b/packages/flame/lib/src/extensions/image.dart index 89fca5461a2..e82ee8e1bb5 100644 --- a/packages/flame/lib/src/extensions/image.dart +++ b/packages/flame/lib/src/extensions/image.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import 'package:flame/extensions.dart'; import 'package:flame/palette.dart'; +import 'package:flame/src/geometry/alpha_contours.dart'; export 'dart:ui' show Image; @@ -76,6 +77,93 @@ extension ImageExtension on Image { return await fromPixels(newPixelData, width, height); } + /// Returns the outlines of the parts of this image that are not transparent, + /// as the closed contours of a [Path]. + /// + /// Only the pixels in the [region] are considered, the whole image by + /// default, and the contours are relative to its top left corner. They are + /// scaled from the size of the [region] to the given [size], when there is + /// one. The [region] has to be within the image, and its sides are rounded + /// to whole pixels. + /// + /// A pixel is part of the outlined area when its alpha is at least the + /// [alphaThreshold], which is between zero, excluded, and one. The outlines + /// pass between the pixels, at the point where the alpha would reach the + /// [alphaThreshold] if it changed linearly between their centers, so that the + /// anti-aliased edges of the image are followed more closely than a pixel, + /// and they stay within the [region]. Only the outer outlines are kept, not + /// the ones of the holes in the areas, and the separate areas give separate + /// contours. + /// + /// The contours are meant to make hitboxes that follow the outline of the + /// image, like a `PathHitbox`, or a `PolygonHitbox` from one of them, which + /// simplify them according to their sampling. + /// + /// Keep in mind that this reads back the pixels of the whole image, even + /// when the [region] is only a part of it, so it is an expensive operation + /// that should be done when loading, not in the game loop. To trace several + /// regions of the same image, like the sprites of a sprite sheet, read the + /// pixels once with [pixelsInUint8] and pass them to [contourFromPixels] for + /// each region. + Future contour({ + Rect? region, + Vector2? size, + double alphaThreshold = 0.5, + }) async { + return contourFromPixels( + await pixelsInUint8(), + width, + height, + region: region, + size: size, + alphaThreshold: alphaThreshold, + ); + } + + /// Returns the outlines of the parts of a raw list of [pixels] that are not + /// transparent, as the closed contours of a [Path]. + /// + /// The [pixels] are in the RGBA format, as in [fromPixels]. See [contour] + /// for the [region], [size] and [alphaThreshold] parameters. + static Path contourFromPixels( + Uint8List pixels, + int width, + int height, { + Rect? region, + Vector2? size, + double alphaThreshold = 0.5, + }) { + assert(pixels.length == width * height * 4); + assert( + alphaThreshold > 0 && alphaThreshold <= 1, + 'The alpha threshold has to be in (0, 1]: $alphaThreshold', + ); + final left = region?.left.round() ?? 0; + final top = region?.top.round() ?? 0; + final right = region?.right.round() ?? width; + final bottom = region?.bottom.round() ?? height; + // A region that is clamped to the image would move and stretch the + // contours, relative to the region that was asked for. + assert( + left >= 0 && top >= 0 && right <= width && bottom <= height, + 'The region $region is not within the image of $width x $height', + ); + assert(right >= left && bottom >= top, 'The region $region is inverted'); + final columns = right - left; + final rows = bottom - top; + return traceAlphaContours( + pixels, + width: width, + left: left, + top: top, + columns: columns, + rows: rows, + threshold: alphaThreshold, + scaleX: size == null || columns == 0 ? 1 : size.x / columns, + scaleY: size == null || rows == 0 ? 1 : size.y / rows, + ); + } + /// Returns the bounding [Rect] of the image. Rect getBoundingRect() => Vector2.zero() & size; diff --git a/packages/flame/lib/src/extensions/path.dart b/packages/flame/lib/src/extensions/path.dart index 90b906be5cf..0c329d1abff 100644 --- a/packages/flame/lib/src/extensions/path.dart +++ b/packages/flame/lib/src/extensions/path.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import 'package:flame/extensions.dart'; import 'package:flame/src/cache/matrix_pool.dart' show pathTransform; +import 'package:flame/src/geometry/signed_area.dart'; export 'dart:ui' show Path; @@ -88,6 +89,58 @@ extension PathExtension on Path { ]; } + /// Returns a polygon for each closed contour of the path, with vertices that + /// go counterclockwise in the screen coordinate system. + /// + /// Unlike [walkContours], open contours and degenerate ones, with fewer than + /// three vertices or no area, are dropped. When [filter] is true, so are the + /// polygons that lie inside of the largest one. + /// + /// See [PathMetricExtension.walkContour] for the [sampling] and the + /// [tolerance] parameters. + List> toPolygons({ + double sampling = 1.0, + double? tolerance, + bool filter = true, + }) { + final polygons = >[]; + for (final metric in computeMetrics()) { + if (!metric.isClosed) { + continue; + } + final vertices = metric.walkContour(sampling, tolerance).vertices; + final area = vertices.length > 2 ? signedArea(vertices) : 0.0; + if (area == 0) { + continue; + } + if (area > 0) { + vertices.reverse(); + } + polygons.add(vertices); + } + if (filter && polygons.length > 1) { + final largest = polygons.reduce( + (a, b) => _boundsArea(a) >= _boundsArea(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; + } + + static double _boundsArea(List vertices) { + final bounds = RectExtension.getBounds(vertices); + return bounds.width * bounds.height; + } + /// Walk only the contour at [index], without sampling the other contours of /// the path. /// diff --git a/packages/flame/lib/src/geometry/alpha_contours.dart b/packages/flame/lib/src/geometry/alpha_contours.dart new file mode 100644 index 00000000000..f48cb857cb4 --- /dev/null +++ b/packages/flame/lib/src/geometry/alpha_contours.dart @@ -0,0 +1,223 @@ +import 'dart:typed_data'; + +import 'package:flame/extensions.dart'; +import 'package:flame/src/geometry/signed_area.dart'; +import 'package:meta/meta.dart'; + +/// Traces the outlines of the areas of a region of [pixels] whose alpha is at +/// least the [threshold], and returns them as the closed contours of a [Path]. +/// +/// The [pixels] are in the RGBA format, row by row, and the image is [width] +/// pixels wide. The region starts at [left] and [top] and it is [columns] +/// pixels wide and [rows] pixels high. The contours are in the coordinates of +/// the region, where each pixel is a square of side one, multiplied by the +/// [scaleX] and the [scaleY]. +/// +/// The outlines are found with marching squares on the alpha of the pixel +/// centers, so they cross between two pixels where the alpha would reach the +/// [threshold] if it changed linearly between them, and they stay within the +/// region. Only the outer outlines are kept, not the ones of the holes, and +/// each of them goes clockwise in the screen coordinate system. +@internal +Path traceAlphaContours( + Uint8List pixels, { + required int width, + required int left, + required int top, + required int columns, + required int rows, + required double threshold, + double scaleX = 1.0, + double scaleY = 1.0, +}) { + final path = Path(); + if (columns <= 0 || rows <= 0) { + return path; + } + // The samples are the pixels of the region with a transparent border around + // them, so that every outline is closed. + final gridWidth = columns + 2; + final gridHeight = rows + 2; + + /// The alpha of the pixel at the [sample], which is zero on the border. + double alphaAt(int sample) { + final x = sample % gridWidth - 1; + final y = sample ~/ gridWidth - 1; + if (x < 0 || y < 0 || x >= columns || y >= rows) { + return 0; + } + return pixels[((top + y) * width + left + x) * 4 + 3] / 255; + } + + // Whether each sample is inside, which is all that the scan of the cells + // needs, while the alpha itself is only read along the outlines. The + // smallest alpha that is inside is found with the same comparison that the + // alpha of a sample would go through. + var minAlpha = 0; + while (minAlpha < 256 && minAlpha / 255 < threshold) { + minAlpha++; + } + final inside = Uint8List(gridWidth * gridHeight); + for (var y = 0; y < rows; y++) { + final pixel = ((top + y) * width + left) * 4 + 3; + final sample = (y + 1) * gridWidth + 1; + for (var x = 0; x < columns; x++) { + if (pixels[pixel + x * 4] >= minAlpha) { + inside[sample + x] = 1; + } + } + } + + // An edge joins a sample with the one at its right (2 * sample) or with the + // one below it (2 * sample + 1). Each crossed edge starts one segment of an + // outline, which ends at the edge stored here, and it ends another one. Only + // the edges along the outlines are stored, in the order they are found in, + // which is also the order of the [starts]. + final next = {}; + final starts = []; + // The edges of a cell in clockwise order, where the edge at an index goes + // from the corner at that index to the following one, and the corners are + // the bits of the cell in the same order. + final edges = Int32List(4); + final crossed = Int32List(4); + for (var y = 0; y < gridHeight - 1; y++) { + final row = y * gridWidth; + // The left corners of a cell are the right corners of the previous one. + var topLeftBit = inside[row]; + var bottomLeftBit = inside[row + gridWidth]; + for (var x = 0; x < gridWidth - 1; x++) { + final topLeft = row + x; + final topRightBit = inside[topLeft + 1]; + final bottomRightBit = inside[topLeft + gridWidth + 1]; + final cell = + topLeftBit | + topRightBit << 1 | + bottomRightBit << 2 | + bottomLeftBit << 3; + topLeftBit = topRightBit; + bottomLeftBit = bottomRightBit; + if (cell == 0 || cell == 15) { + continue; + } + edges[0] = 2 * topLeft; + edges[1] = 2 * (topLeft + 1) + 1; + edges[2] = 2 * (topLeft + gridWidth); + edges[3] = 2 * topLeft + 1; + var count = 0; + for (var i = 0; i < 4; i++) { + if ((cell >> i & 1) != (cell >> (i + 1) % 4 & 1)) { + crossed[count++] = i; + } + } + // A segment goes from where the clockwise walk around the cell leaves + // the inside to where it enters it again, so that the inside is on its + // right. When the inside corners are opposite, the alpha at the center + // decides whether they are joined, by cutting off the outside corners, + // or apart: the segments then end at the next or at the previous + // crossing, which are the same one in the other cases. + final joined = + count == 4 && + (alphaAt(topLeft) + + alphaAt(topLeft + 1) + + alphaAt(topLeft + gridWidth + 1) + + alphaAt(topLeft + gridWidth)) / + 4 >= + threshold; + for (var i = 0; i < count; i++) { + final edge = crossed[i]; + if (cell >> edge & 1 == 1) { + final end = joined + ? crossed[(i + 1) % count] + : crossed[(i + count - 1) % count]; + next[edges[edge]] = edges[end]; + starts.add(edges[edge]); + } + } + } + } + + Offset crossing(int edge) { + final sample = edge >> 1; + final other = edge.isEven ? sample + 1 : sample + gridWidth; + final alpha = alphaAt(sample); + final t = (threshold - alpha) / (alphaAt(other) - alpha); + // The sample at (1, 1) is the center of the first pixel of the region, + // while the samples of the border are half a pixel outside of it, so the + // crossings on their edges are kept within the region. + final x = sample % gridWidth - 0.5 + (edge.isEven ? t : 0); + final y = sample ~/ gridWidth - 0.5 + (edge.isEven ? 0 : t); + return Offset( + x.clamp(0, columns).toDouble() * scaleX, + y.clamp(0, rows).toDouble() * scaleY, + ); + } + + for (final start in starts) { + if (!next.containsKey(start)) { + continue; + } + final points = []; + int? edge = start; + do { + points.add(crossing(edge!)); + edge = next.remove(edge); + } while (edge != start && edge != null); + final outline = _withoutCollinear(_withoutDuplicates(points)); + // The outer outlines have the inside on their right, so they go clockwise + // on the screen, while the outlines of the holes go counterclockwise. + if (outline.length >= 3 && + signedArea([for (final point in outline) point.toVector2()]) > 0) { + path.addPolygon(outline, true); + } + } + return path; +} + +/// Removes the points of the closed outline that are the same as the previous +/// one, which happens where the alpha of a pixel is exactly the threshold: +/// the outline then passes through the center of the pixel, where all the +/// edges of the pixel are crossed. +List _withoutDuplicates(List points) { + final result = []; + for (final point in points) { + if (result.isEmpty || result.last != point) { + result.add(point); + } + } + while (result.length > 1 && result.last == result.first) { + result.removeLast(); + } + return result; +} + +/// Removes the points of the closed outline that are in the middle of a +/// straight stretch, like the ones along the sides of the pixels. The outline +/// must not have consecutive duplicate points, since both of them would be +/// removed. +List _withoutCollinear(List points) { + final count = points.length; + return [ + for (var i = 0; i < count; i++) + if (!_isCollinear( + points[(i + count - 1) % count], + points[i], + points[(i + 1) % count], + )) + points[i], + ]; +} + +/// Whether the [point] is on the line through the [previous] and the [next] +/// ones, within an angle of about 1e-9 radians, which doesn't depend on the +/// scale of the outline, unlike its area. +bool _isCollinear(Offset previous, Offset point, Offset next) { + final firstDx = point.dx - previous.dx; + final firstDy = point.dy - previous.dy; + final secondDx = next.dx - point.dx; + final secondDy = next.dy - point.dy; + final cross = firstDx * secondDy - firstDy * secondDx; + final squaredLengths = + (firstDx * firstDx + firstDy * firstDy) * + (secondDx * secondDx + secondDy * secondDy); + return cross * cross <= 1e-18 * squaredLengths; +} diff --git a/packages/flame/lib/src/geometry/convex_pieces.dart b/packages/flame/lib/src/geometry/convex_pieces.dart new file mode 100644 index 00000000000..02ec9c3be43 --- /dev/null +++ b/packages/flame/lib/src/geometry/convex_pieces.dart @@ -0,0 +1,316 @@ +import 'dart:math'; + +import 'package:flame/extensions.dart'; +import 'package:flame/src/geometry/signed_area.dart'; + +/// Splits a simple [polygon] into convex polygons of at most [maxVertices] +/// vertices each, like the ones that physics engines such as Box2D need. +/// +/// The polygon is split into triangles by ear clipping first, which are then +/// merged with their neighbors as long as they stay convex and within +/// [maxVertices] (the Hertel-Mehlhorn algorithm). +/// +/// Physics engines reject the polygons that are too small for their +/// tolerances, so the [polygon] is cleaned up first: of the consecutive +/// vertices closer than [minDistance], only the first one is kept, and the +/// vertices closer than half of it to the line through their neighbors are +/// left out. Then, the pieces narrower than [minWidth] are left out, as well +/// as the ones with fewer than three vertices that are [minDistance] apart. +/// For Box2D, [minDistance] is 4 times its linear slop and [minWidth] twice +/// it, see `b2ComputeHull`. +/// +/// The pieces are made of copies of the vertices of the [polygon], going in +/// the same direction, so they can be handed to the consumers that change +/// their vertices in place, like a `PolygonHitbox`. +/// +/// The [polygon] may touch itself at its vertices, like the outline of two +/// shapes that meet at a corner, which is split there into simple polygons: +/// a hole that touches the outline at a vertex is filled, though. The parts +/// of a [polygon] that crosses itself may be left out. +/// +/// The cost grows about with the square of the number of vertices, so it is +/// meant to be done when loading, not in the game loop. The polygons of a +/// `PathComponent` are already simplified according to their sampling, while +/// a [polygon] made by hand should not have a vertex for each pixel or each +/// tiny step along a curve. +List> convexPieces( + List polygon, { + int maxVertices = 8, + double minDistance = 0, + double minWidth = 0, +}) { + assert(maxVertices >= 3, 'A piece needs at least three vertices'); + final vertices = _clean(polygon, minDistance); + final n = vertices.length; + if (n < 3) { + return const []; + } + // The pieces are worked out on indices of vertices going clockwise on the + // screen, that is with a positive signed area, and turned back into + // vertices at the end. + final isReversed = signedArea(vertices) < 0; + final indices = List.generate(n, (i) => isReversed ? n - 1 - i : i); + final pieces = [ + for (final loop in _splitAtTouches(vertices, indices, minDistance)) + // The loops that go the other way are holes that touch the outline, + // which the other loops already cover. + if (signedArea([for (final i in loop) vertices[i]]) > 0) + ..._triangulate(vertices, loop), + ]; + _merge(vertices, pieces, maxVertices); + return [ + for (final piece in pieces) + if (_isWideEnough(vertices, piece, minDistance, minWidth)) + [ + for (final i in isReversed ? piece.reversed : piece) + vertices[i].clone(), + ], + ]; +} + +/// Splits the polygon given by the [indices] of [vertices] where it +/// touches itself, that is where two of its vertices that are not +/// consecutive are welded, into loops that do not touch themselves. +/// +/// Ear clipping can get stuck on a polygon that touches itself, which would +/// leave the rest of it out. +/// +/// The last vertices of a loop that are welded to its first one are left out, +/// like consecutive vertices are, since a loop that was split off ends next to +/// the vertex where the polygon touches itself. +List> _splitAtTouches( + List vertices, + List indices, + double minDistance, +) { + var loop = indices; + while (loop.length > 1 && + _isWelded(vertices[loop.last], vertices[loop.first], minDistance)) { + loop = loop.sublist(0, loop.length - 1); + } + for (var a = 0; a < loop.length; a++) { + for (var b = a + 2; b < loop.length; b++) { + if (_isWelded(vertices[loop[a]], vertices[loop[b]], minDistance)) { + return [ + ..._splitAtTouches(vertices, loop.sublist(a, b), minDistance), + ..._splitAtTouches( + vertices, + [...loop.sublist(b), ...loop.sublist(0, a)], + minDistance, + ), + ]; + } + } + } + return [loop]; +} + +/// The vertices of the [polygon] without the ones closer than [minDistance] +/// to the previous one, nor the ones closer than half of it to the line +/// through their neighbors. +List _clean(List polygon, double minDistance) { + final vertices = []; + for (final vertex in polygon) { + if (vertices.isEmpty || !_isWelded(vertex, vertices.last, minDistance)) { + vertices.add(vertex); + } + } + while (vertices.length > 1 && + _isWelded(vertices.last, vertices.first, minDistance)) { + vertices.removeLast(); + } + var hasRemoved = true; + while (hasRemoved && vertices.length > 3) { + hasRemoved = false; + for (var i = 0; i < vertices.length; i++) { + final m = vertices.length; + final a = vertices[(i - 1 + m) % m]; + final b = vertices[i]; + final c = vertices[(i + 1) % m]; + final chord = a.distanceTo(c); + // A vertex whose neighbors coincide is the tip of a spike. + final distance = chord == 0 + ? a.distanceTo(b) + : _cross(a, b, c).abs() / chord; + if (distance < minDistance / 2) { + vertices.removeAt(i); + hasRemoved = true; + break; + } + } + } + return vertices; +} + +/// Whether the vertices [a] and [b] are the same, or closer than +/// [minDistance]. +bool _isWelded(Vector2 a, Vector2 b, double minDistance) { + final distance = a.distanceTo(b); + return distance == 0 || distance < minDistance; +} + +/// Twice the signed area of the triangle [a], [b], [c], positive if it goes +/// clockwise on the screen, that is if [b] is a convex corner of a polygon +/// that goes clockwise on the screen. +double _cross(Vector2 a, Vector2 b, Vector2 c) { + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); +} + +/// Splits the polygon given by the [indices] of [vertices], which goes +/// clockwise on the screen, into triangles, by clipping its ears. +List> _triangulate(List vertices, List indices) { + final remaining = List.of(indices); + final triangles = >[]; + while (remaining.length >= 3) { + final ear = _findEar(vertices, remaining); + if (ear == -1) { + // A polygon that crosses itself may have no ears left, while a simple + // one always has; what is left of it is left out. + break; + } + final m = remaining.length; + final triangle = [ + remaining[(ear - 1 + m) % m], + remaining[ear], + remaining[(ear + 1) % m], + ]; + remaining.removeAt(ear); + final [a, b, c] = [for (final i in triangle) vertices[i]]; + // Clipping a collinear vertex leaves no triangle. + if (_cross(a, b, c) > 0) { + triangles.add(triangle); + } + } + return triangles; +} + +/// The position in [remaining] of a vertex that can be clipped, or -1 if +/// there is none. +/// +/// A vertex can be clipped if its corner is not reflex and no other vertex +/// lies within the triangle that it makes with its neighbors. Collinear +/// vertices can be clipped too, as they only make a degenerate triangle. +int _findEar(List vertices, List remaining) { + final m = remaining.length; + for (var i = 0; i < m; i++) { + final a = vertices[remaining[(i - 1 + m) % m]]; + final b = vertices[remaining[i]]; + final c = vertices[remaining[(i + 1) % m]]; + if (_cross(a, b, c) < 0) { + continue; + } + var isEar = true; + for (final j in remaining) { + final p = vertices[j]; + if (p == a || p == b || p == c) { + continue; + } + if (_cross(a, b, p) >= 0 && + _cross(b, c, p) >= 0 && + _cross(c, a, p) >= 0) { + isEar = false; + break; + } + } + if (isEar) { + return i; + } + } + return -1; +} + +/// Merges the [pieces] that share an edge, as long as the result is convex +/// and has at most [maxVertices] vertices. +void _merge(List vertices, List> pieces, int maxVertices) { + var hasMerged = true; + while (hasMerged) { + hasMerged = false; + for (var i = 0; i < pieces.length; i++) { + for (var j = i + 1; j < pieces.length; j++) { + final merged = _union(pieces[i], pieces[j]); + if (merged != null && + merged.length <= maxVertices && + _isConvex(vertices, merged)) { + pieces[i] = merged; + pieces.removeAt(j); + hasMerged = true; + // The grown piece may now be merged with the ones already checked. + j = i; + } + } + } + } +} + +/// The union of the pieces [a] and [b], which go clockwise on the screen, if +/// they share an edge, or null otherwise. +List? _union(List a, List b) { + for (var k = 0; k < a.length; k++) { + final from = a[k]; + final to = a[(k + 1) % a.length]; + // The shared edge goes the other way around in [b]. + final l = b.indexOf(to); + if (l == -1 || b[(l + 1) % b.length] != from) { + continue; + } + return [ + // All of [a], starting after the shared edge and ending before it. + for (var i = 1; i <= a.length; i++) a[(k + i) % a.length], + // The vertices of [b] that are not on the shared edge. + for (var i = 2; i < b.length; i++) b[(l + i) % b.length], + ]; + } + return null; +} + +/// Whether the [piece], which goes clockwise on the screen, has no reflex +/// corners. +bool _isConvex(List vertices, List piece) { + final m = piece.length; + for (var i = 0; i < m; i++) { + final a = vertices[piece[(i - 1 + m) % m]]; + final b = vertices[piece[i]]; + final c = vertices[piece[(i + 1) % m]]; + if (_cross(a, b, c) < 0) { + return false; + } + } + return true; +} + +/// Whether the convex [piece] keeps at least three vertices when the ones +/// closer than [minDistance] to a previous one are left out, and is at least +/// [minWidth] wide. +/// +/// The width of a convex polygon is the smallest distance between one of its +/// edges and the vertex farthest from it. +bool _isWideEnough( + List vertices, + List piece, + double minDistance, + double minWidth, +) { + final points = []; + for (final i in piece) { + final vertex = vertices[i]; + if (!points.any((point) => _isWelded(vertex, point, minDistance))) { + points.add(vertex); + } + } + if (points.length < 3) { + return false; + } + for (var i = 0; i < points.length; i++) { + final a = points[i]; + final b = points[(i + 1) % points.length]; + final length = a.distanceTo(b); + var farthest = 0.0; + for (final point in points) { + farthest = max(farthest, _cross(a, b, point).abs() / length); + } + if (farthest < minWidth) { + return false; + } + } + return true; +} diff --git a/packages/flame/lib/src/geometry/path_component.dart b/packages/flame/lib/src/geometry/path_component.dart index 3bb67ec430f..470f081bc30 100644 --- a/packages/flame/lib/src/geometry/path_component.dart +++ b/packages/flame/lib/src/geometry/path_component.dart @@ -46,13 +46,13 @@ class PathComponent({ /// 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. + /// Open and degenerate contours do not become polygons, see + /// [PathExtension.toPolygons]. 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. this : super(size: path.getBounds().size.toVector2()) { - _polygons = _polygonsOf( - this.path, + _polygons = this.path.toPolygons( sampling: sampling, tolerance: tolerance, filter: filter, @@ -212,48 +212,4 @@ class PathComponent({ } return rectIntersections; } - - /// Returns the polygon of each closed contour of the [path] with at least - /// three vertices, with the vertices going counterclockwise. - static List> _polygonsOf( - Path path, { - required double sampling, - required double? tolerance, - required bool filter, - }) { - final polygons = >[]; - for (final metric in path.computeMetrics()) { - if (!metric.isClosed) { - continue; - } - final contour = metric.walkContour(sampling, tolerance); - if (contour.length > 2) { - final vertices = contour.vertices; - if (PolygonComponent.isClockwise(vertices)) { - vertices.reverse(); - } - polygons.add(vertices); - } - } - if (filter && polygons.length > 1) { - final largest = polygons.reduce((a, b) => _area(a) >= _area(b) ? a : b); - final largestPath = Path() - ..addPolygon( - largest.map((vertex) => vertex.toOffset()).toList(growable: false), - true, - ); - polygons.removeWhere( - (polygon) => - polygon != largest && - polygon.every((vertex) => largestPath.contains(vertex.toOffset())), - ); - } - return polygons; - } - - /// The area of the bounds of the polygon with the given [vertices]. - static double _area(List vertices) { - final bounds = RectExtension.getBounds(vertices); - return bounds.width * bounds.height; - } } diff --git a/packages/flame/lib/src/geometry/polygon_component.dart b/packages/flame/lib/src/geometry/polygon_component.dart index 0a46acfb3e9..fc7b914aa26 100644 --- a/packages/flame/lib/src/geometry/polygon_component.dart +++ b/packages/flame/lib/src/geometry/polygon_component.dart @@ -6,6 +6,7 @@ import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; import 'package:flame/geometry.dart'; import 'package:flame/src/geometry/absolute_transform.dart'; +import 'package:flame/src/geometry/signed_area.dart'; import 'package:meta/meta.dart'; class PolygonComponent( @@ -376,11 +377,6 @@ class PolygonComponent( /// 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; - area += vertices[i].x * vertices[j].y - vertices[j].x * vertices[i].y; - } - return area >= 0; + return signedArea(vertices) >= 0; } } diff --git a/packages/flame/lib/src/geometry/signed_area.dart b/packages/flame/lib/src/geometry/signed_area.dart new file mode 100644 index 00000000000..382aab0ac2e --- /dev/null +++ b/packages/flame/lib/src/geometry/signed_area.dart @@ -0,0 +1,16 @@ +import 'package:flame/extensions.dart'; +import 'package:meta/meta.dart'; + +/// The signed area of the [polygon], which is positive when its vertices go +/// clockwise in the screen coordinate system, where the y axis points down, +/// and negative when they go counterclockwise. +@internal +double signedArea(List polygon) { + var area = 0.0; + for (var i = 0; i < polygon.length; i++) { + final a = polygon[i]; + final b = polygon[(i + 1) % polygon.length]; + area += a.x * b.y - b.x * a.y; + } + return area / 2; +} diff --git a/packages/flame/lib/src/sprite.dart b/packages/flame/lib/src/sprite.dart index da994efcb47..248fbd78c96 100644 --- a/packages/flame/lib/src/sprite.dart +++ b/packages/flame/lib/src/sprite.dart @@ -99,6 +99,30 @@ class Sprite( ); } + /// Returns the outlines of the parts of this sprite that are not + /// transparent, as the closed contours of a [Path]. + /// + /// The contours are relative to the top left corner of the sprite, and they + /// are scaled from the [srcSize] to the given [size], when there is one, so + /// that they line up with the sprite when it is rendered with that size. + /// The sides of the [src] are rounded to whole pixels, so when they are not + /// whole the contours can be off by up to half a pixel of the [image]. + /// + /// See [ImageExtension.contour] for the [alphaThreshold] and for the + /// details of the contours. Keep in mind that this reads back the pixels of + /// the whole [image], not only the ones of the sprite, so it should be done + /// when loading, not in the game loop. To trace several sprites of the same + /// sprite sheet, read its pixels once with [ImageExtension.pixelsInUint8] + /// and pass them to [ImageExtension.contourFromPixels] with the [src] of + /// each sprite. + Future contour({Vector2? size, double alphaThreshold = 0.5}) { + return image.contour( + region: src, + size: size, + alphaThreshold: alphaThreshold, + ); + } + /// Same as [render], but takes both the position and the size as a single /// [Rect]. /// diff --git a/packages/flame/test/extensions/image_contour_test.dart b/packages/flame/test/extensions/image_contour_test.dart new file mode 100644 index 00000000000..b990c8045e8 --- /dev/null +++ b/packages/flame/test/extensions/image_contour_test.dart @@ -0,0 +1,299 @@ +import 'dart:typed_data'; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/src/geometry/signed_area.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The pixels of the [rows], where each character is a pixel: '#' is opaque, +/// '+' has half alpha and anything else is transparent. +Uint8List _pixels(List rows) { + final width = rows.first.length; + final pixels = Uint8List(width * rows.length * 4); + for (var y = 0; y < rows.length; y++) { + for (var x = 0; x < width; x++) { + final alpha = switch (rows[y][x]) { + '#' => 255, + '+' => 128, + _ => 0, + }; + pixels[(y * width + x) * 4 + 3] = alpha; + } + } + return pixels; +} + +Path _contour( + List rows, { + Rect? region, + Vector2? size, + double alphaThreshold = 0.5, +}) { + return ImageExtension.contourFromPixels( + _pixels(rows), + rows.first.length, + rows.length, + region: region, + size: size, + alphaThreshold: alphaThreshold, + ); +} + +Future _image(List rows) { + return ImageExtension.fromPixels( + _pixels(rows), + rows.first.length, + rows.length, + ); +} + +/// The vertices of each contour of the [path], without the samples along its +/// edges. +List> _vertices(Path path) => path.walkContours(1, 0.001); + +void main() { + group('ImageExtension.contourFromPixels', () { + test('is empty for a transparent image', () { + final path = _contour(['...', '...']); + expect(path.contours, isEmpty); + }); + + test('is empty for an empty region', () { + final path = _contour(['##', '##'], region: Rect.zero); + expect(path.contours, isEmpty); + }); + + test('cuts the corners of a single pixel', () { + final path = _contour(['...', '.#.', '...']); + final vertices = _vertices(path); + expect(vertices, hasLength(1)); + expect( + vertices.single, + unorderedEquals(const [ + Offset(1.5, 1), + Offset(2, 1.5), + Offset(1.5, 2), + Offset(1, 1.5), + ]), + ); + }); + + test('follows the sides of the pixels with straight edges', () { + final path = _contour(['....', '.##.', '.##.', '....']); + final vertices = _vertices(path); + expect(vertices.single, hasLength(8)); + expect(path.getBounds(), const Rect.fromLTRB(1, 1, 3, 3)); + }); + + test('reaches the borders of a fully opaque image', () { + final path = _contour(['###', '###']); + expect(path.contours, hasLength(1)); + expect(path.getBounds(), const Rect.fromLTRB(0, 0, 3, 2)); + }); + + test('goes clockwise on the screen', () { + final vertices = _vertices(_contour(['##', '##'])).single; + expect( + signedArea([for (final vertex in vertices) vertex.toVector2()]), + greaterThan(0), + ); + }); + + test('follows concave outlines', () { + final path = _contour([ + '#..', + '#..', + '###', + ]); + expect(path.contours, hasLength(1)); + expect(path.contains(const Offset(0.5, 0.5)), isTrue); + expect(path.contains(const Offset(2.5, 2.5)), isTrue); + expect(path.contains(const Offset(2, 1)), isFalse); + }); + + test('leaves out the holes', () { + final path = _contour([ + '###', + '#.#', + '###', + ]); + expect(path.contours, hasLength(1)); + expect(path.getBounds(), const Rect.fromLTRB(0, 0, 3, 3)); + expect(path.contains(const Offset(1.5, 1.5)), isTrue); + }); + + test('gives a contour for each separate area', () { + final path = _contour([ + '##..#', + '##...', + '....#', + ]); + expect(path.contours, hasLength(3)); + }); + + test('joins diagonal pixels when the center reaches the threshold', () { + expect(_contour(['#.', '.#']).contours, hasLength(1)); + expect( + _contour(['#.', '.#'], alphaThreshold: 0.6).contours, + hasLength(2), + ); + }); + + test('places the edges where the alpha reaches the threshold', () { + final half = _contour(['#+']).getBounds(); + expect(half.right, closeTo(1.5 + 0.004, 0.001)); + final low = _contour(['#+.'], alphaThreshold: 0.25).getBounds(); + expect(low.right, closeTo(1.5 + 1 - 0.25 / (128 / 255), 0.001)); + final high = _contour(['#+'], alphaThreshold: 0.75).getBounds(); + expect(high.right, closeTo(0.5 + 0.25 / (127 / 255), 0.001)); + }); + + test('stays within the region at a low threshold', () { + final low = _contour(['##', '##'], alphaThreshold: 1 / 255); + expect(low.getBounds(), const Rect.fromLTRB(0, 0, 2, 2)); + final region = _contour( + ['....', '.##.', '....'], + region: const Rect.fromLTWH(1, 1, 2, 1), + alphaThreshold: 0.25, + ); + expect(region.getBounds(), const Rect.fromLTRB(0, 0, 2, 1)); + }); + + test('keeps the corners where the alpha is exactly the threshold', () { + // The outline passes through the centers of the opaque pixels, where + // all the edges of the pixels are crossed at the same point. + final path = _contour([ + '.....', + '.###.', + '.###.', + '.###.', + '.....', + ], alphaThreshold: 1); + expect(_vertices(path).single, hasLength(4)); + expect(path.getBounds(), const Rect.fromLTRB(1.5, 1.5, 3.5, 3.5)); + }); + + test('asserts that the region is within the image', () { + expect( + () => _contour(['##', '##'], region: const Rect.fromLTWH(-1, 0, 2, 2)), + throwsAssertionError, + ); + expect( + () => _contour(['##', '##'], region: const Rect.fromLTWH(1, 1, 2, 1)), + throwsAssertionError, + ); + }); + + test('counts only the pixels in the region, relative to it', () { + final path = _contour([ + '#....', + '...#.', + '.....', + ], region: const Rect.fromLTWH(2, 0, 3, 3)); + expect(path.contours, hasLength(1)); + expect(path.getBounds(), const Rect.fromLTRB(1, 1, 2, 2)); + }); + + test('scales the region to the size', () { + final path = _contour( + ['....', '..#.'], + region: const Rect.fromLTWH(2, 0, 2, 2), + size: Vector2(20, 10), + ); + expect(path.getBounds(), const Rect.fromLTRB(0, 5, 10, 10)); + }); + + test('keeps the same outline at any size', () { + final rows = [ + '......', + '.##+..', + '.####.', + '.+##..', + '......', + ]; + final expected = _contour(rows); + for (final size in [ + Vector2(6e-6, 5e-6), + Vector2(6e-2, 5e-2), + Vector2(6e3, 5e3), + Vector2(6e-6, 5), + Vector2(6e3, 5e-2), + ]) { + final actual = _contour(rows, size: size); + expect(actual.contours, hasLength(1), reason: 'size $size'); + // Points every quarter of a pixel, which tell apart the outlines + // that miss a corner, since the corners cut off half a pixel. They + // are shifted so that none of them is on an edge. + for (var y = 0.2; y < 5; y += 0.25) { + for (var x = 0.1; x < 6; x += 0.25) { + expect( + actual.contains(Offset(x * size.x / 6, y * size.y / 5)), + expected.contains(Offset(x, y)), + reason: '($x, $y) at size $size', + ); + } + } + } + }); + }); + + group('ImageExtension.contour', () { + test('reads the pixels of the image', () async { + final rows = ['....', '.##.', '..#.']; + final image = await _image(rows); + final path = await image.contour(size: Vector2(8, 6)); + expect( + _vertices(path), + _vertices(_contour(rows, size: Vector2(8, 6))), + ); + }); + }); + + group('Sprite.contour', () { + test('follows the region of the sprite', () async { + final image = await _image(['##..', '##.#']); + final sprite = Sprite( + image, + srcPosition: Vector2(2, 0), + srcSize: Vector2(2, 2), + ); + final path = await sprite.contour(size: Vector2(4, 4)); + expect(path.contours, hasLength(1)); + expect(path.getBounds(), const Rect.fromLTRB(2, 2, 4, 4)); + }); + }); + + group('hitboxes from the contour', () { + test('a PolygonHitbox follows a concave outline', () { + final path = _contour([ + '....', + '.#..', + '.#..', + '.###', + ]); + final hitbox = PolygonHitbox.fromPath(path, sampling: 0.5); + expect(hitbox.position, Vector2(1, 1)); + expect(hitbox.size, Vector2(3, 3)); + expect(hitbox.containsLocalPoint(Vector2(0.5, 0.5)), isTrue); + expect(hitbox.containsLocalPoint(Vector2(2.5, 2.5)), isTrue); + expect(hitbox.containsLocalPoint(Vector2(2, 1)), isFalse); + }); + + test('a PathHitbox covers every area', () { + final path = _contour([ + '#..', + '...', + '..#', + ]); + final hitbox = PathHitbox( + path: path, + position: path.getBounds().topLeft.toVector2(), + ); + expect(hitbox.polygons, hasLength(2)); + expect(hitbox.containsPoint(Vector2(0.5, 0.5)), isTrue); + expect(hitbox.containsPoint(Vector2(2.5, 2.5)), isTrue); + expect(hitbox.containsPoint(Vector2(1.5, 1.5)), isFalse); + }); + }); +} diff --git a/packages/flame/test/extensions/path_test.dart b/packages/flame/test/extensions/path_test.dart index 6f263b11ca4..6635dae4962 100644 --- a/packages/flame/test/extensions/path_test.dart +++ b/packages/flame/test/extensions/path_test.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'dart:ui'; import 'package:flame/extensions.dart'; +import 'package:flame/src/geometry/signed_area.dart'; import 'package:test/test.dart'; double _area(List polygon) { @@ -227,6 +228,56 @@ void main() { }); }); + group('toPolygons', () { + test('gives a counterclockwise polygon for each closed contour', () { + final path = Path() + ..addRect(const Rect.fromLTWH(0, 0, 10, 10)) + ..addPolygon(const [ + Offset(20, 0), + Offset(20, 10), + Offset(30, 10), + ], true); + final polygons = path.toPolygons(); + expect(polygons, hasLength(2)); + expect(polygons.every((polygon) => signedArea(polygon) < 0), isTrue); + expect( + RectExtension.getBounds(polygons.first), + const Rect.fromLTWH(0, 0, 10, 10), + ); + }); + + test('drops the open and the degenerate contours', () { + final path = Path() + ..moveTo(0, 0) + ..lineTo(10, 0) + ..lineTo(10, 10) + ..moveTo(0, 20) + ..lineTo(10, 20) + ..close() + ..moveTo(0, 30) + ..close() + ..addRect(const Rect.fromLTWH(20, 20, 10, 10)); + expect(path.toPolygons(), hasLength(1)); + expect(Path().toPolygons(), isEmpty); + }); + + test('drops the polygons inside of the largest one unless asked', () { + final path = Path() + ..addRect(const Rect.fromLTWH(5, 5, 10, 10)) + ..addRect(const Rect.fromLTWH(7, 7, 2, 2)); + expect(path.toPolygons(), hasLength(1)); + expect(path.toPolygons(filter: false), hasLength(2)); + }); + + test('follows the contours like walkContours', () { + final path = Path()..addOval(const Rect.fromLTWH(0, 0, 100, 60)); + expect( + path.toPolygons(sampling: 2, tolerance: 0.3).single, + hasLength(path.walkContours(2, 0.3).single.length), + ); + }); + }); + group('contours', () { test('can be read more than once', () { final path = Path() diff --git a/packages/flame/test/geometry/convex_pieces_test.dart b/packages/flame/test/geometry/convex_pieces_test.dart new file mode 100644 index 00000000000..f0e733de959 --- /dev/null +++ b/packages/flame/test/geometry/convex_pieces_test.dart @@ -0,0 +1,234 @@ +import 'dart:math' as math; + +import 'package:flame/extensions.dart'; +import 'package:flame/geometry.dart'; +import 'package:flame/src/geometry/signed_area.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('convexPieces', () { + test('keeps a convex polygon whole', () { + final square = [ + Vector2(0, 0), + Vector2(1, 0), + Vector2(1, 1), + Vector2(0, 1), + ]; + final pieces = convexPieces(square); + expect(pieces, hasLength(1)); + expect(pieces.first, hasLength(4)); + }); + + test('splits a concave polygon into convex pieces of the same area', () { + final l = [ + Vector2(0, 0), + Vector2(2, 0), + Vector2(2, 1), + Vector2(1, 1), + Vector2(1, 2), + Vector2(0, 2), + ]; + final pieces = convexPieces(l); + expect(pieces.length, greaterThan(1)); + expect(pieces.every(_isConvex), isTrue); + expect(_totalArea(pieces), closeTo(3, 1e-9)); + }); + + test('keeps the direction of the polygon', () { + final clockwise = [ + Vector2(0, 0), + Vector2(0, 2), + Vector2(1, 2), + Vector2(1, 1), + Vector2(2, 1), + Vector2(2, 0), + ]; + final pieces = convexPieces(clockwise); + expect(pieces.every((piece) => signedArea(piece) < 0), isTrue); + }); + + test('copies the vertices of the polygon', () { + final square = [ + Vector2(0, 0), + Vector2(1, 0), + Vector2(1, 1), + Vector2(0, 1), + ]; + final pieces = convexPieces(square); + expect(pieces.single, unorderedEquals(square)); + for (final vertex in pieces.single) { + vertex.setZero(); + } + expect(square, [ + Vector2(0, 0), + Vector2(1, 0), + Vector2(1, 1), + Vector2(0, 1), + ]); + }); + + test('limits the number of vertices of the pieces', () { + final circle = [ + for (var i = 0; i < 64; i++) + Vector2(math.cos(i * math.pi / 32), math.sin(i * math.pi / 32)), + ]; + final pieces = convexPieces(circle, maxVertices: 5); + expect(pieces.every((piece) => piece.length <= 5), isTrue); + expect(_totalArea(pieces), closeTo(signedArea(circle), 1e-9)); + }); + + test('welds the vertices closer than minDistance', () { + // A triangle with a corner split in two vertices 0.01 apart, which + // would make a degenerate piece. + final polygon = [ + Vector2(0, 0), + Vector2(1, 0), + Vector2(1, 0.01), + Vector2(0.5, 1), + ]; + final pieces = convexPieces(polygon, minDistance: 0.02); + expect(pieces, hasLength(1)); + expect(pieces.first, hasLength(3)); + }); + + test('leaves out the pieces narrower than minWidth', () { + final sliver = [Vector2(0, 0), Vector2(1, 0), Vector2(0.5, 0.005)]; + expect(convexPieces(sliver, minWidth: 0.01), isEmpty); + expect(convexPieces(sliver), hasLength(1)); + }); + + test('keeps the pieces convex with a minWidth', () { + // A square with a shallow dent in its top edge, shallower than minWidth. + final dented = [ + Vector2(0, 0), + Vector2(5, 0.3), + Vector2(10, 0), + Vector2(10, 10), + Vector2(0, 10), + ]; + final pieces = convexPieces(dented, minWidth: 1); + expect(pieces, hasLength(2)); + expect(pieces.every(_isConvex), isTrue); + expect(_totalArea(pieces), closeTo(signedArea(dented).abs(), 1e-9)); + }); + + test('splits a polygon where it touches itself', () { + // Two squares that meet at the corner (1, 1), which is visited twice. + final touching = [ + Vector2(0, 0), + Vector2(1, 0), + Vector2(1, 1), + Vector2(2, 1), + Vector2(2, 2), + Vector2(1, 2), + Vector2(1, 1), + Vector2(0, 1), + ]; + final pieces = convexPieces(touching); + expect(pieces, hasLength(2)); + expect(pieces.every(_isConvex), isTrue); + expect(_totalArea(pieces), closeTo(2, 1e-9)); + }); + + test('welds the vertices next to where the polygon touches itself', () { + // Two squares that meet near the corner (1, 1), where the second visit + // is within minDistance of the first one, and the last vertex is within + // minDistance of the second visit but not of the first one. + final touching = [ + Vector2(1, 1), + Vector2(2, 1), + Vector2(2, 2), + Vector2(1, 2), + Vector2(1.05, 1), + Vector2(0, 1), + Vector2(0, 0), + Vector2(1, 0), + Vector2(1.1, 0.95), + ]; + final pieces = convexPieces(touching, minDistance: 0.1); + expect(pieces, hasLength(2)); + expect(pieces.every(_isConvex), isTrue); + expect(_totalArea(pieces), closeTo(2.025, 1e-6)); + }); + + test('fills a hole that touches the outline at a vertex', () { + // A square, and a triangular hole that goes the other way, which both + // start at (0, 0). + final withHole = [ + Vector2(0, 0), + Vector2(4, 0), + Vector2(4, 4), + Vector2(0, 4), + Vector2(0, 0), + Vector2(1, 2), + Vector2(2, 1), + ]; + final pieces = convexPieces(withHole); + expect(pieces.every(_isConvex), isTrue); + expect(_totalArea(pieces), closeTo(16, 1e-9)); + }); + + test('gives no pieces for fewer than three vertices', () { + expect(convexPieces([]), isEmpty); + expect(convexPieces([Vector2(0, 0), Vector2(1, 0)]), isEmpty); + }); + + testRandom('covers random concave polygons with convex pieces', (r) { + // A star with points at random distances from its center, which is + // concave and simple. + final count = 3 + r.nextInt(30); + final star = [ + for (var i = 0; i < 2 * count; i++) + Vector2(math.cos(i * math.pi / count), math.sin(i * math.pi / count)) + ..scale(i.isEven ? 1 + r.nextDouble() * 9 : 0.2 + r.nextDouble()), + ]; + final maxVertices = 3 + r.nextInt(6); + final pieces = convexPieces(star, maxVertices: maxVertices); + expect(pieces.every(_isConvex), isTrue); + expect( + pieces.every( + (piece) => piece.length >= 3 && piece.length <= maxVertices, + ), + isTrue, + ); + expect(pieces.every((piece) => signedArea(piece) > 0), isTrue); + expect(_totalArea(pieces), closeTo(signedArea(star), 1e-6)); + }, repeatCount: 100); + + test('covers the polygons of a path', () { + final path = Path() + ..moveTo(0, 0) + ..lineTo(40, 0) + ..lineTo(40, 40) + ..lineTo(30, 40) + ..lineTo(30, 10) + ..lineTo(10, 10) + ..lineTo(10, 40) + ..lineTo(0, 40) + ..close(); + final polygon = path.toPolygons().single; + final pieces = convexPieces(polygon); + expect(pieces.length, greaterThan(1)); + expect(pieces.every(_isConvex), isTrue); + expect(_totalArea(pieces), closeTo(40 * 40 - 20 * 30, 1e-6)); + }); + }); +} + +double _totalArea(List> pieces) { + return pieces.fold(0, (sum, piece) => sum + signedArea(piece).abs()); +} + +bool _isConvex(List piece) { + final sign = signedArea(piece).sign; + for (var i = 0; i < piece.length; i++) { + final a = piece[i]; + final b = piece[(i + 1) % piece.length]; + final c = piece[(i + 2) % piece.length]; + if ((b - a).cross(c - b) * sign < -1e-12) { + return false; + } + } + return true; +} diff --git a/packages/flame/test/geometry/path_component_test.dart b/packages/flame/test/geometry/path_component_test.dart index f18e728a2bd..0cfb5c3b52b 100644 --- a/packages/flame/test/geometry/path_component_test.dart +++ b/packages/flame/test/geometry/path_component_test.dart @@ -1,7 +1,7 @@ import 'dart:math'; -import 'dart:ui'; import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; import 'package:flame_test/test_paths.dart'; import 'package:test/test.dart'; @@ -102,6 +102,20 @@ void main() { ); }); + test('the polygons are the ones of the path moved to the origin', () { + final path = Path() + ..addRect(const Rect.fromLTWH(5, 5, 10, 10)) + ..addRect(const Rect.fromLTWH(7, 7, 2, 2)); + final polygons = path.toPolygons(); + final component = PathComponent(path: path); + + expect(polygons, hasLength(1)); + expect( + polygons.single.map((vertex) => vertex - Vector2.all(5)), + component.polygons.single, + ); + }); + test('contains the points inside of any polygon', () { final path = Path() ..addRect(const Rect.fromLTWH(0, 0, 10, 10))