Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions doc/flame/components/shape_components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 0 additions & 5 deletions examples/lib/commons/paths.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,15 +400,15 @@ class RaysInShapeWorld extends World
radius: _componentSize.x * 0.6,
anchor: Anchor.center,
position: Vector2.zero(),
paint: whiteStroke,
paint: PathComponent.hitboxStroke,
children: [CircleHitbox()],
),
RectangleComponent(
priority: shapePriority,
size: _componentSize,
anchor: Anchor.center,
position: Vector2.zero(),
paint: whiteStroke,
paint: PathComponent.hitboxStroke,
children: [RectangleHitbox()],
),
PositionComponent(
Expand All @@ -426,7 +426,7 @@ class RaysInShapeWorld extends World
anchor: Anchor.center,
position: Vector2.zero(),
)
..paint = whiteStroke
..paint = PathComponent.hitboxStroke
..renderShape = true,
],
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
1 change: 0 additions & 1 deletion examples/lib/stories/input/gesture_hitboxes_example.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/flame/lib/components.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/flame/lib/geometry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 3 additions & 3 deletions packages/flame/lib/src/extensions/path.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,6 +15,9 @@ class PathComponent extends ShapeComponent
with CollisionCallbacks, CollisionPassthrough {
PathComponent({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow PolygonComponent to see which arguments that should exist on here, remember this is no longer an example helper when moved in here, so it needs to be consistent with the rest of the code.

required Path path,
this.sampling = 1.0,
this.tolerance,
this.hitboxesPriority,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not live here, the hitboxes should handle their own priority.

this.addHitboxes = false,
this.loadHitboxes = true,
this.renderHitboxes = false,
Expand All @@ -30,16 +32,35 @@ class PathComponent extends ShapeComponent
super.key,
super.paint,
super.paintLayers,
bool renderShape = true,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be here, since it doesn't exist on PolygonComponent.

}) : 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;
Comment on lines +44 to +47

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should follow the same standard for painting this as the other shape components, like PolygonComponent.


/// 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;

Expand All @@ -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<void> onLoad() async {
Expand Down Expand Up @@ -93,6 +114,7 @@ class PathComponent extends ShapeComponent
_hitboxesAdded = true;
}

// Filter the hitboxes by keeping only the largest and all disjoint ones.
List<PolygonHitbox> _filterHitboxes(List<PolygonHitbox> hitboxes) {
if (hitboxes.length < 2) {
return hitboxes;
Expand All @@ -116,14 +138,21 @@ class PathComponent extends ShapeComponent
return hitboxes;
}

List<PolygonHitbox> _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<PolygonHitbox> _createHitboxes() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The component should not create hitboxes for itself, we probably need a PathHitbox that can fill this up when added to it.

final contours = path.walkContours(sampling, tolerance);
final boxes = <PolygonHitbox>[];
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;
}
}
88 changes: 88 additions & 0 deletions packages/flame/test/geometry/path_component_test.dart
Original file line number Diff line number Diff line change
@@ -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<PolygonHitbox>();
final hitbox2 = path2.firstChild<PolygonHitbox>();

expect(hitbox1!.vertices.length, greaterThan(hitbox2!.vertices.length));
});
}
Loading