ShapeComponent and Hitbox to take transform of parents full ancestor tree into consideration (#1076)

* `ShapeComponent` changes size, position and angle of underlying Shape

* Added description to ShapeComponent

* Fix test

* Update packages/flame/lib/src/components/shape_component.dart

Co-authored-by: Erick <erickzanardoo@gmail.com>

* Add absoluteScale and absoluteAngle to PositionComponent

* Refactor ShapeComponent

* Should be scaled by total scale, not scaled size

* Premature optimization for creation for objects in Polygon

* Use path for default Polygon constructor

* Do not sync component and hitbox shape

* Fix analyze issue

* Add example for flipping with collision detection

* Don't use absoluteScale

* Fix examples

* Fix examples

* Doesn't need super.render

* Fix Circle dartdoc

* Update changelog

* Update names of vertices caches in Polygon

* Update text docs

* Revert "Update text docs"

This reverts commit 73a68a465d76eb0eb50bb3753e57b2f4e3b5a7f4.

* Fix examples

* ShapeComponents docs

* Move example games to the top

* Fix dartdoc comment about polygon vertex relation

* Fix order of polygon vertices in dartdoc

* Fix anchor for PolygonComponent.fromPoints

* Add test with ancestors

* Update doc/components.md

Co-authored-by: Pasha Stetsenko <stpasha@google.com>

* Update doc/components.md

Co-authored-by: Erick <erickzanardoo@gmail.com>

* Rename example classes

* Fix linting issues in examples

* Don't use px

* Use isTrue and isFalse

* Update doc/components.md

Co-authored-by: Erick <erickzanardoo@gmail.com>

* Fixed comments on PR

Co-authored-by: Erick <erickzanardoo@gmail.com>
Co-authored-by: Pasha Stetsenko <stpasha@google.com>
This commit is contained in:
Lukas Klingsbo
2021-11-13 16:00:24 +01:00
committed by GitHub
parent d53ac50859
commit cd7a0bbb65
28 changed files with 1224 additions and 390 deletions

View File

@ -30,6 +30,9 @@ that overshoot each other into account, this could happen when they either move
being called with a large delta time (for example if your app is not in the foreground). This being called with a large delta time (for example if your app is not in the foreground). This
behaviour is called tunneling, if you want to read more about it. behaviour is called tunneling, if you want to read more about it.
Also note that the collision detection system doesn't work properly if you scale ancestors of the
component that is `Collidable`.
## Mixins ## Mixins
### HasHitboxes ### HasHitboxes
The `HasHitboxes` mixin is mainly used for two things; to make detection of collisions with other The `HasHitboxes` mixin is mainly used for two things; to make detection of collisions with other

View File

@ -422,6 +422,100 @@ It is also possible to create custom renderers by extending the `ParallaxRendere
Three example implementations can be found in the Three example implementations can be found in the
[examples directory](https://github.com/flame-engine/flame/tree/main/examples/lib/stories/parallax). [examples directory](https://github.com/flame-engine/flame/tree/main/examples/lib/stories/parallax).
## ShapeComponents
The `ShapeComponent` is a basic component that can be used if you want to draw geometrical shapes as
components on the screen. Since the `ShapeComponent` is a `PositionComponent`s you can use effects
on it. All `ShapeComponent`s take a `Paint` as an argument and then arguments to define
the shape of the specific component, it also takes all the arguments that can be passed to the
`PositionComponent`.
There are three implementations of `ShapeComponent`, which are the following:
### CircleComponent
A `CircleComponent` can be created only by defining its `radius`, but you most likely want to pass it
a `position` and maybe `paint` (the default is white) too.
Example:
```dart
final paint = BasicPalette.red.paint()..style = PaintingStyle.stroke;
final circle = CircleComponent(radius: 200.0, position: Vector2(100, 200), paint: paint);
```
### RectangleComponent
A `RectangleComponent` can be created in two ways, depending on if it's a square or not.
To create a `RectangleComponent` that is 300 in width and 200 in height you can do the following:
Example:
```dart
final paint = BasicPalette.red.paint()..style = PaintingStyle.stroke;
final rectangle = RectangleComponent(
size: Vector2(300.0, 200.0),
position: Vector2(100, 200),
paint: paint,
);
```
To create a square you can instead use the slightly simpler named constructor
`RectangleComponent.square`. This is an example of how to create a red square with width and height
200:
```dart
final paint = BasicPalette.red.paint()..style = PaintingStyle.stroke;
final square = RectangleComponent.square(
size: 200.0,
position: Vector2(100, 200),
paint: paint,
);
```
### PolygonComponent
The `PolygonComponent` is the most complicated of the `ShapeComponent`s since you'll have to define
all the "corners" of your polygon. You can create the `PolygonComponent` in two different ways,
either you use the default constructor which takes a list of `Vector2` where each of them should be
between -1.0 and 1.0 that describes the ration of the length from the center to the edge of the size
of the component. So
`[Vector2(1.0, 1.0), Vector2(1.0, -1.0), Vector2(-1.0, -1.0), Vector2(-1.0, 1.0)]`
would describe a rectangle that fills the full size of the component. Remember to define the list in
a counter clockwise manner (if you think in the screen coordinate system where the y-axis is
flipped, otherwise it is clockwise).
So to create a diamond shaped `PolygonComponent` which is slightly smaller than the defined size you
would do this:
```dart
final vertices = ([
Vector2(0.0, 0.9), // Middle of top wall
Vector2(-0.9, 0.0), // Middle of left wall
Vector2(0.0, -0.9), // Middle of bottom wall
Vector2(0.9, 0.0), // Middle of right wall
]);
final diamond = PolygonComponent(
normalizedVertices: vertices,
size: Vector2(200, 300),
position: Vector2.all(500),
)
```
If you instead want to define your polygon from absolute points you can do that too with the
`PolygonComponent.fromPoints` factory. When using that one you don't have to define a `size` or a
`position` either since it will be calculated for you, but if you decide to add those arguments
anyways they will override what has been calculated from your list of vertices.
Example (diamond shape again):
```dart
final vertices = ([
Vector2(100, 100), // Middle of top wall
Vector2(50, 150), // Middle of left wall
Vector2(100, 200), // Middle of bottom wall
Vector2(200, 150), // Middle of right wall
]);
final diamond = PolygonComponent.fromPoints(vertices);
)
```
## SpriteBodyComponent ## SpriteBodyComponent
See [SpriteBodyComponent](forge2d.md#SpriteBodyComponent) in the Forge2D documentation. See [SpriteBodyComponent](forge2d.md#SpriteBodyComponent) in the Forge2D documentation.

View File

@ -10,8 +10,8 @@ class SquareComponent extends RectangleComponent with EffectsHelper {
Paint? paint, Paint? paint,
int priority = 0, int priority = 0,
}) : super( }) : super(
Vector2.all(size),
position: position, position: position,
size: Vector2.all(size),
paint: paint, paint: paint,
priority: priority, priority: priority,
); );

View File

@ -2,7 +2,6 @@ import 'dart:math';
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:flame/geometry.dart';
import 'package:flame/input.dart'; import 'package:flame/input.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -12,11 +11,7 @@ import '../../commons/square_component.dart';
final R = Random(); final R = Random();
class MovableSquare extends SquareComponent class MovableSquare extends SquareComponent
with with Collidable, HasGameRef<CameraAndViewportGame>, KeyboardHandler {
HasHitboxes,
Collidable,
HasGameRef<CameraAndViewportGame>,
KeyboardHandler {
static const double speed = 300; static const double speed = 300;
static final TextPaint textRenderer = TextPaint( static final TextPaint textRenderer = TextPaint(
config: const TextPaintConfig( config: const TextPaintConfig(
@ -27,8 +22,11 @@ class MovableSquare extends SquareComponent
final Vector2 velocity = Vector2.zero(); final Vector2 velocity = Vector2.zero();
late Timer timer; late Timer timer;
MovableSquare() : super(priority: 1) { MovableSquare() : super(priority: 1);
addHitbox(HitboxRectangle());
@override
Future<void> onLoad() async {
await super.onLoad();
timer = Timer(3.0) timer = Timer(3.0)
..stop() ..stop()
..callback = () { ..callback = () {
@ -104,7 +102,7 @@ class Map extends Component {
} }
} }
class Rock extends SquareComponent with HasHitboxes, Collidable, Tappable { class Rock extends SquareComponent with Collidable, Tappable {
static final unpressedPaint = Paint()..color = const Color(0xFF2222FF); static final unpressedPaint = Paint()..color = const Color(0xFF2222FF);
static final pressedPaint = Paint()..color = const Color(0xFF414175); static final pressedPaint = Paint()..color = const Color(0xFF414175);
@ -114,9 +112,7 @@ class Rock extends SquareComponent with HasHitboxes, Collidable, Tappable {
size: 50, size: 50,
priority: 2, priority: 2,
paint: unpressedPaint, paint: unpressedPaint,
) { );
addHitbox(HitboxRectangle());
}
@override @override
bool onTapDown(_) { bool onTapDown(_) {

View File

@ -7,14 +7,27 @@ import 'package:flame/geometry.dart';
import 'package:flame/input.dart'; import 'package:flame/input.dart';
import 'package:flutter/material.dart' hide Image, Draggable; import 'package:flutter/material.dart' hide Image, Draggable;
const circlesInfo = ''' class CirclesExample extends FlameGame with HasCollidables, TapDetector {
This example will create a circle every time you tap on the screen. It will have static const description = '''
the initial velocity towards the center of the screen and if it touches another This example will create a circle every time you tap on the screen. It will
circle both of them will change color. have the initial velocity towards the center of the screen and if it touches
'''; another circle both of them will change color.
''';
@override
Future<void> onLoad() async {
super.onLoad();
add(ScreenCollidable());
}
@override
void onTapDown(TapDownInfo info) {
add(MyCollidable(info.eventPosition.game));
}
}
class MyCollidable extends PositionComponent class MyCollidable extends PositionComponent
with HasGameRef<Circles>, HasHitboxes, Collidable { with HasGameRef<CirclesExample>, HasHitboxes, Collidable {
late Vector2 velocity; late Vector2 velocity;
final _collisionColor = Colors.amber; final _collisionColor = Colors.amber;
final _defaultColor = Colors.cyan; final _defaultColor = Colors.cyan;
@ -63,16 +76,3 @@ class MyCollidable extends PositionComponent
_isCollision = true; _isCollision = true;
} }
} }
class Circles extends FlameGame with HasCollidables, TapDetector {
@override
Future<void> onLoad() async {
super.onLoad();
add(ScreenCollidable());
}
@override
void onTapDown(TapDownInfo info) {
add(MyCollidable(info.eventPosition.game));
}
}

View File

@ -0,0 +1,123 @@
import 'dart:math';
import 'dart:ui';
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/input.dart';
import 'package:flame/palette.dart';
class CollidableAnimationExample extends FlameGame with HasCollidables {
static const description = '''
In this example you can see four animated birds which are flying straight
along the same route until they hit either another bird or the wall, which
makes them turn. The birds have PolygonHitboxes which are marked with the
green lines and dots.
''';
@override
Future<void> onLoad() async {
await super.onLoad();
add(ScreenCollidable());
// Top left component
add(
AnimatedComponent(Vector2.all(200), Vector2.all(100))..flipVertically(),
);
// Bottom right component
add(
AnimatedComponent(
Vector2(-100, -100),
size.clone()..sub(Vector2.all(200)),
),
);
// Bottom left component
add(
AnimatedComponent(
Vector2(100, -100),
Vector2(100, size.y - 100),
angle: pi / 4,
),
);
// Top right component
add(
AnimatedComponent(
Vector2(-300, 300),
Vector2(size.x - 100, 100),
angle: pi / 4,
)..flipVertically(),
);
}
}
class AnimatedComponent extends SpriteAnimationComponent
with HasHitboxes, Collidable, HasGameRef {
final Vector2 velocity;
final List<Collidable> activeCollisions = [];
AnimatedComponent(this.velocity, Vector2 position, {double angle = -pi / 4})
: super(
position: position,
size: Vector2(150, 100),
angle: angle,
anchor: Anchor.center,
);
late HitboxPolygon hitbox;
@override
Future<void> onLoad() async {
await super.onLoad();
animation = await gameRef.loadSpriteAnimation(
'bomb_ptero.png',
SpriteAnimationData.sequenced(
amount: 4,
stepTime: 0.2,
textureSize: Vector2.all(48),
),
);
hitbox = HitboxPolygon([
Vector2(0.0, -1.0),
Vector2(-1.0, -0.1),
Vector2(-0.2, 0.4),
Vector2(0.2, 0.4),
Vector2(1.0, -0.1),
]);
addHitbox(hitbox);
}
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
final Paint hitboxPaint = BasicPalette.green.paint()
..style = PaintingStyle.stroke;
final Paint dotPaint = BasicPalette.red.paint()..style = PaintingStyle.stroke;
@override
void render(Canvas canvas) {
super.render(canvas);
// This is just to clearly see the vertices in the hitboxes
hitbox.render(canvas, hitboxPaint);
hitbox
.localVertices()
.forEach((p) => canvas.drawCircle(p.toOffset(), 4, dotPaint));
}
@override
void onCollision(Set<Vector2> intersectionPoints, Collidable other) {
if (!activeCollisions.contains(other)) {
velocity.negate();
flipVertically();
activeCollisions.add(other);
}
}
@override
void onCollisionEnd(Collidable other) {
activeCollisions.remove(other);
}
}

View File

@ -3,27 +3,34 @@ import 'package:flame/game.dart';
import '../../commons/commons.dart'; import '../../commons/commons.dart';
import 'circles.dart'; import 'circles.dart';
import 'collidable_animation.dart';
import 'multiple_shapes.dart'; import 'multiple_shapes.dart';
import 'only_shapes.dart'; import 'simple_shapes.dart';
void addCollisionDetectionStories(Dashbook dashbook) { void addCollisionDetectionStories(Dashbook dashbook) {
dashbook.storiesOf('Collision Detection') dashbook.storiesOf('Collision Detection')
..add(
'Collidable AnimationComponent',
(_) => GameWidget(game: CollidableAnimationExample()),
codeLink: baseLink('collision_detection/collidable_animation.dart'),
info: CollidableAnimationExample.description,
)
..add( ..add(
'Circles', 'Circles',
(_) => GameWidget(game: Circles()), (_) => GameWidget(game: CirclesExample()),
codeLink: baseLink('collision_detection/circles.dart'), codeLink: baseLink('collision_detection/circles.dart'),
info: circlesInfo, info: CirclesExample.description,
) )
..add( ..add(
'Multiple shapes', 'Multiple shapes',
(_) => GameWidget(game: MultipleShapes()), (_) => GameWidget(game: MultipleShapesExample()),
codeLink: baseLink('collision_detection/multiple_shapes.dart'), codeLink: baseLink('collision_detection/multiple_shapes.dart'),
info: multipleShapesInfo, info: MultipleShapesExample.description,
) )
..add( ..add(
'Simple Shapes', 'Simple Shapes',
(_) => GameWidget(game: OnlyShapes()), (_) => GameWidget(game: SimpleShapesExample()),
codeLink: baseLink('collision_detection/only_shapes.dart'), codeLink: baseLink('collision_detection/simple_shapes.dart'),
info: onlyShapesInfo, info: SimpleShapesExample.description,
); );
} }

View File

@ -9,21 +9,95 @@ import 'package:flame/input.dart';
import 'package:flame/palette.dart'; import 'package:flame/palette.dart';
import 'package:flutter/material.dart' hide Image, Draggable; import 'package:flutter/material.dart' hide Image, Draggable;
const multipleShapesInfo = '''
An example with many hitboxes that move around on the screen and during
collisions they change color depending on what it is that they have collided
with.
The snowman, the component built with three circles on top of each other, works
a little bit differently than the other components to show that you can have
multiple hitboxes within one component.
On this example, you can "throw" the components by dragging them quickly in any
direction.
''';
enum Shapes { circle, rectangle, polygon } enum Shapes { circle, rectangle, polygon }
class MultipleShapesExample extends FlameGame
with HasCollidables, HasDraggableComponents, FPSCounter {
static const description = '''
An example with many hitboxes that move around on the screen and during
collisions they change color depending on what it is that they have collided
with.
The snowman, the component built with three circles on top of each other,
works a little bit differently than the other components to show that you
can have multiple hitboxes within one component.
On this example, you can "throw" the components by dragging them quickly in
any direction.
''';
final TextPaint fpsTextPaint = TextPaint(
config: TextPaintConfig(
color: BasicPalette.white.color,
),
);
@override
Future<void> onLoad() async {
await super.onLoad();
final screenCollidable = ScreenCollidable();
final snowman = CollidableSnowman(
Vector2.all(150),
Vector2(100, 200),
Vector2(-100, 100),
screenCollidable,
);
MyCollidable lastToAdd = snowman;
add(screenCollidable);
add(snowman);
var totalAdded = 1;
while (totalAdded < 20) {
lastToAdd = nextRandomCollidable(lastToAdd, screenCollidable);
final lastBottomRight =
lastToAdd.toAbsoluteRect().bottomRight.toVector2();
if (lastBottomRight.x < size.x && lastBottomRight.y < size.y) {
add(lastToAdd);
totalAdded++;
} else {
break;
}
}
}
final _rng = Random();
final _distance = Vector2(100, 0);
MyCollidable nextRandomCollidable(
MyCollidable lastCollidable,
ScreenCollidable screenCollidable,
) {
final collidableSize = Vector2.all(50) + Vector2.random(_rng) * 100;
final isXOverflow = lastCollidable.position.x +
lastCollidable.size.x / 2 +
_distance.x +
collidableSize.x >
size.x;
var position = _distance + Vector2(0, lastCollidable.position.y + 200);
if (!isXOverflow) {
position = (lastCollidable.position + _distance)
..x += collidableSize.x / 2;
}
final velocity = (Vector2.random(_rng) - Vector2.random(_rng)) * 400;
return randomCollidable(
position,
collidableSize,
velocity,
screenCollidable,
rng: _rng,
);
}
@override
void render(Canvas canvas) {
super.render(canvas);
fpsTextPaint.render(
canvas,
'${fps(120).toStringAsFixed(2)}fps',
Vector2(0, size.y - 24),
);
}
}
abstract class MyCollidable extends PositionComponent abstract class MyCollidable extends PositionComponent
with Draggable, HasHitboxes, Collidable { with Draggable, HasHitboxes, Collidable {
double rotationSpeed = 0.0; double rotationSpeed = 0.0;
@ -41,11 +115,7 @@ abstract class MyCollidable extends PositionComponent
Vector2 size, Vector2 size,
this.velocity, this.velocity,
this.screenCollidable, this.screenCollidable,
) { ) : super(position: position, size: size, anchor: Anchor.center);
this.position = position;
this.size = size;
anchor = Anchor.center;
}
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
@ -137,7 +207,7 @@ class CollidablePolygon extends MyCollidable {
Vector2 velocity, Vector2 velocity,
ScreenCollidable screenCollidable, ScreenCollidable screenCollidable,
) : super(position, size, velocity, screenCollidable) { ) : super(position, size, velocity, screenCollidable) {
final shape = HitboxPolygon([ final hitbox = HitboxPolygon([
Vector2(-1.0, 0.0), Vector2(-1.0, 0.0),
Vector2(-0.8, 0.6), Vector2(-0.8, 0.6),
Vector2(0.0, 1.0), Vector2(0.0, 1.0),
@ -147,7 +217,7 @@ class CollidablePolygon extends MyCollidable {
Vector2(0, -1.0), Vector2(0, -1.0),
Vector2(-0.8, -0.8), Vector2(-0.8, -0.8),
]); ]);
addHitbox(shape); addHitbox(hitbox);
} }
} }
@ -169,8 +239,7 @@ class CollidableCircle extends MyCollidable {
Vector2 velocity, Vector2 velocity,
ScreenCollidable screenCollidable, ScreenCollidable screenCollidable,
) : super(position, size, velocity, screenCollidable) { ) : super(position, size, velocity, screenCollidable) {
final shape = HitboxCircle(); addHitbox(HitboxCircle());
addHitbox(shape);
} }
} }
@ -179,7 +248,7 @@ class SnowmanPart extends HitboxCircle {
final hitPaint = Paint(); final hitPaint = Paint();
SnowmanPart(double definition, Vector2 relativeOffset, Color hitColor) SnowmanPart(double definition, Vector2 relativeOffset, Color hitColor)
: super(definition: definition) { : super(normalizedRadius: definition) {
this.relativeOffset.setFrom(relativeOffset); this.relativeOffset.setFrom(relativeOffset);
hitPaint..color = startColor; hitPaint..color = startColor;
onCollision = (Set<Vector2> intersectionPoints, HitboxShape other) { onCollision = (Set<Vector2> intersectionPoints, HitboxShape other) {
@ -245,77 +314,3 @@ MyCollidable randomCollidable(
..rotationSpeed = rotationSpeed; ..rotationSpeed = rotationSpeed;
} }
} }
class MultipleShapes extends FlameGame
with HasCollidables, HasDraggableComponents, FPSCounter {
final TextPaint fpsTextPaint = TextPaint(
config: TextPaintConfig(
color: BasicPalette.white.color,
),
);
@override
Future<void> onLoad() async {
await super.onLoad();
final screenCollidable = ScreenCollidable();
final snowman = CollidableSnowman(
Vector2.all(150),
Vector2(100, 200),
Vector2(-100, 100),
screenCollidable,
);
MyCollidable lastToAdd = snowman;
add(screenCollidable);
add(snowman);
var totalAdded = 1;
while (totalAdded < 20) {
lastToAdd = nextRandomCollidable(lastToAdd, screenCollidable);
final lastBottomRight =
lastToAdd.toAbsoluteRect().bottomRight.toVector2();
if (lastBottomRight.x < size.x && lastBottomRight.y < size.y) {
add(lastToAdd);
totalAdded++;
} else {
break;
}
}
}
final _rng = Random();
final _distance = Vector2(100, 0);
MyCollidable nextRandomCollidable(
MyCollidable lastCollidable,
ScreenCollidable screenCollidable,
) {
final collidableSize = Vector2.all(50) + Vector2.random(_rng) * 100;
final isXOverflow = lastCollidable.position.x +
lastCollidable.size.x / 2 +
_distance.x +
collidableSize.x >
size.x;
var position = _distance + Vector2(0, lastCollidable.position.y + 200);
if (!isXOverflow) {
position = (lastCollidable.position + _distance)
..x += collidableSize.x / 2;
}
final velocity = (Vector2.random(_rng) - Vector2.random(_rng)) * 400;
return randomCollidable(
position,
collidableSize,
velocity,
screenCollidable,
rng: _rng,
);
}
@override
void render(Canvas canvas) {
super.render(canvas);
fpsTextPaint.render(
canvas,
'${fps(120).toStringAsFixed(2)}fps',
Vector2(0, size.y - 24),
);
}
}

View File

@ -1,70 +0,0 @@
import 'dart:math';
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flame/geometry.dart';
import 'package:flame/input.dart';
import 'package:flame/palette.dart';
import 'package:flutter/material.dart' hide Image, Draggable;
const onlyShapesInfo = '''
An example which adds random shapes on the screen when you tap it, if you tap on
an already existing shape it will remove that shape and replace it with a new
one.
''';
enum Shapes { circle, rectangle, polygon }
class OnlyShapes extends FlameGame with HasTappableComponents {
final shapePaint = BasicPalette.red.paint()..style = PaintingStyle.stroke;
final _rng = Random();
Shape randomShape(Vector2 position) {
final shapeType = Shapes.values[_rng.nextInt(Shapes.values.length)];
const size = 50.0;
switch (shapeType) {
case Shapes.circle:
return Circle(radius: size / 2, position: position);
case Shapes.rectangle:
return Rectangle(
position: position,
size: Vector2.all(size),
angle: _rng.nextDouble() * 6,
);
case Shapes.polygon:
final points = [
Vector2.random(_rng),
Vector2.random(_rng)..y *= -1,
-Vector2.random(_rng),
Vector2.random(_rng)..x *= -1,
];
return Polygon.fromDefinition(
points,
position: position,
size: Vector2.all(size),
angle: _rng.nextDouble() * 6,
);
}
}
@override
void onTapDown(int pointerId, TapDownInfo info) {
super.onTapDown(pointerId, info);
final tapDownPoint = info.eventPosition.game;
final component = MyShapeComponent(randomShape(tapDownPoint), shapePaint);
add(component);
}
}
class MyShapeComponent extends ShapeComponent with Tappable {
MyShapeComponent(Shape shape, Paint shapePaint)
: super(shape, paint: shapePaint);
@override
bool onTapDown(TapDownInfo info) {
removeFromParent();
return true;
}
}

View File

@ -0,0 +1,105 @@
import 'dart:math';
import 'dart:ui';
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/input.dart';
import 'package:flame/palette.dart';
enum Shapes { circle, rectangle, polygon }
class SimpleShapesExample extends FlameGame with HasTappableComponents {
static const description = '''
An example which adds random shapes on the screen when you tap it, if you
tap on an already existing shape it will remove that shape and replace it
with a new one.
''';
final _rng = Random();
MyShapeComponent randomShape(Vector2 position) {
final shapeType = Shapes.values[_rng.nextInt(Shapes.values.length)];
final shapeSize =
Vector2.all(100) + Vector2.all(50.0).scaled(_rng.nextDouble());
final shapeAngle = _rng.nextDouble() * 6;
switch (shapeType) {
case Shapes.circle:
return MyShapeComponent(
HitboxCircle(),
position: position,
size: shapeSize,
angle: shapeAngle,
);
case Shapes.rectangle:
return MyShapeComponent(
HitboxRectangle(),
position: position,
size: shapeSize,
angle: shapeAngle,
);
case Shapes.polygon:
final points = [
Vector2.random(_rng),
Vector2.random(_rng)..y *= -1,
-Vector2.random(_rng),
Vector2.random(_rng)..x *= -1,
];
return MyShapeComponent(
HitboxPolygon(points),
position: position,
size: shapeSize,
angle: shapeAngle,
);
}
}
@override
void onTapDown(int pointerId, TapDownInfo info) {
super.onTapDown(pointerId, info);
final tapPosition = info.eventPosition.game;
final component = randomShape(tapPosition);
add(component);
component.add(
MoveEffect(
path: [size / 2],
speed: 30,
isAlternating: true,
isInfinite: true,
),
);
component.add(
RotateEffect(
angle: 3,
speed: 0.4,
isAlternating: true,
isInfinite: true,
),
);
}
}
class MyShapeComponent extends ShapeComponent with Tappable {
@override
final Paint paint = BasicPalette.red.paint()..style = PaintingStyle.stroke;
MyShapeComponent(
HitboxShape shape, {
Vector2? position,
Vector2? size,
double? angle,
}) : super(
shape,
position: position,
size: size,
angle: angle,
anchor: Anchor.center,
);
@override
bool onTapDown(TapDownInfo _) {
removeFromParent();
return true;
}
}

View File

@ -22,7 +22,8 @@ class MoveEffectGame extends FlameGame with TapDetector {
await super.onLoad(); await super.onLoad();
square = SquareComponent(size: 50, position: Vector2(200, 150)); square = SquareComponent(size: 50, position: Vector2(200, 150));
add(square); add(square);
final pathMarkers = path.map((p) => CircleComponent(3, position: p)); final pathMarkers =
path.map((p) => CircleComponent(radius: 3, position: p));
addAll(pathMarkers); addAll(pathMarkers);
} }

View File

@ -84,7 +84,7 @@ class MoveEffectExample extends FlameGame {
} }
for (var i = 0; i < 40; i++) { for (var i = 0; i < 40; i++) {
add( add(
CircleComponent(5) CircleComponent(radius: 5)
..add( ..add(
MoveEffect.along( MoveEffect.along(
path1, path1,

View File

@ -25,7 +25,7 @@ class RemoveEffectExample extends FlameGame with HasTappableComponents {
class _RandomCircle extends CircleComponent with Tappable { class _RandomCircle extends CircleComponent with Tappable {
_RandomCircle(double radius, {Vector2? position, Paint? paint}) _RandomCircle(double radius, {Vector2? position, Paint? paint})
: super(radius, position: position, paint: paint); : super(radius: radius, position: position, paint: paint);
factory _RandomCircle.random(Random rng) { factory _RandomCircle.random(Random rng) {
final radius = rng.nextDouble() * 30 + 10; final radius = rng.nextDouble() * 30 + 10;

View File

@ -1,6 +1,5 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:flame/geometry.dart';
import 'package:flame/input.dart'; import 'package:flame/input.dart';
import 'package:flame/palette.dart'; import 'package:flame/palette.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
@ -17,8 +16,8 @@ class JoystickGame extends FlameGame with HasDraggableComponents {
final knobPaint = BasicPalette.blue.withAlpha(200).paint(); final knobPaint = BasicPalette.blue.withAlpha(200).paint();
final backgroundPaint = BasicPalette.blue.withAlpha(100).paint(); final backgroundPaint = BasicPalette.blue.withAlpha(100).paint();
joystick = JoystickComponent( joystick = JoystickComponent(
knob: Circle(radius: 30).toComponent(paint: knobPaint), knob: CircleComponent(radius: 30, paint: knobPaint),
background: Circle(radius: 100).toComponent(paint: backgroundPaint), background: CircleComponent(radius: 100, paint: backgroundPaint),
margin: const EdgeInsets.only(left: 40, bottom: 40), margin: const EdgeInsets.only(left: 40, bottom: 40),
); );
player = JoystickPlayer(joystick); player = JoystickPlayer(joystick);

View File

@ -4,7 +4,6 @@ import 'package:flame/components.dart';
import 'package:flame/effects.dart'; import 'package:flame/effects.dart';
import 'package:flame/extensions.dart'; import 'package:flame/extensions.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:flame/geometry.dart';
import 'package:flame/input.dart'; import 'package:flame/input.dart';
import 'package:flame/palette.dart'; import 'package:flame/palette.dart';
import 'package:flame/sprite.dart'; import 'package:flame/sprite.dart';
@ -89,9 +88,11 @@ class JoystickAdvancedGame extends FlameGame
// A button, created from a shape, that adds a rotation effect to the player // A button, created from a shape, that adds a rotation effect to the player
// when it is pressed. // when it is pressed.
final shapeButton = HudButtonComponent( final shapeButton = HudButtonComponent(
button: Circle(radius: 35).toComponent(paint: BasicPalette.white.paint()), button: CircleComponent(radius: 35),
buttonDown: Rectangle(size: buttonSize) buttonDown: RectangleComponent(
.toComponent(paint: BasicPalette.blue.paint()), size: buttonSize,
paint: BasicPalette.blue.paint(),
),
margin: const EdgeInsets.only( margin: const EdgeInsets.only(
right: 85, right: 85,
bottom: 150, bottom: 150,

View File

@ -22,6 +22,8 @@
- Components that manipulate canvas state are now responsible for saving/restoring that state - Components that manipulate canvas state are now responsible for saving/restoring that state
- Remove `super.render` calls that are no longer needed - Remove `super.render` calls that are no longer needed
- Fixed typo in error message - Fixed typo in error message
- Underlying `Shape`s in `ShapeComponent` transform with components position, size and angle
- `HitboxShape` takes parents ancestors transformations into consideration (not scaling)
## [1.0.0-releasecandidate.16] ## [1.0.0-releasecandidate.16]
- `changePriority` no longer breaks game loop iteration - `changePriority` no longer breaks game loop iteration

View File

@ -176,6 +176,20 @@ class Component with Loadable {
nextParent = component; nextParent = component;
} }
final List<Component> _ancestors = [];
/// A list containing the current parent and its parent, and so on, until it
/// reaches a component without a parent.
List<Component> ancestors() {
_ancestors.clear();
for (var currentParent = parent;
currentParent != null;
currentParent = currentParent.parent) {
_ancestors.add(currentParent);
}
return _ancestors;
}
/// It receives the new game size. /// It receives the new game size.
/// Executed right after the component is attached to a game and right before /// Executed right after the component is attached to a game and right before
/// [onLoad] is called. /// [onLoad] is called.
@ -321,7 +335,7 @@ class Component with Loadable {
parentGame.hasLayout, parentGame.hasLayout,
'"prepare/add" called before the game is ready. ' '"prepare/add" called before the game is ready. '
'Did you try to access it on the Game constructor? ' 'Did you try to access it on the Game constructor? '
'Use the "onLoad" or "onParentMethod" method instead.', 'Use the "onLoad" or "onMount" method instead.',
); );
if (parentGame is FlameGame) { if (parentGame is FlameGame) {
parentGame.prepareComponent(this); parentGame.prepareComponent(this);

View File

@ -163,8 +163,17 @@ class PositionComponent extends Component {
/// component as seen from the parent's perspective, and it is equal to /// component as seen from the parent's perspective, and it is equal to
/// [size] * [scale]. This is a computed property and cannot be /// [size] * [scale]. This is a computed property and cannot be
/// modified by the user. /// modified by the user.
Vector2 get scaledSize => Vector2 get scaledSize {
Vector2(width * scale.x.abs(), height * scale.y.abs()); return Vector2(width * scale.x.abs(), height * scale.y.abs());
}
/// The resulting angle after all the ancestors and the components own angle
/// has been applied.
double get absoluteAngle {
return ancestors()
.whereType<PositionComponent>()
.fold<double>(angle, (totalAngle, c) => totalAngle + c.angle);
}
/// Measure the distance (in parent's coordinate space) between this /// Measure the distance (in parent's coordinate space) between this
/// component's anchor and the [other] component's anchor. /// component's anchor and the [other] component's anchor.

View File

@ -8,96 +8,155 @@ import '../../palette.dart';
import '../anchor.dart'; import '../anchor.dart';
import '../extensions/vector2.dart'; import '../extensions/vector2.dart';
class ShapeComponent extends PositionComponent { /// A [ShapeComponent] is a [Shape] wrapped in a [PositionComponent] so that it
final Shape shape; /// can be added to a component tree and take the camera and viewport into
/// consideration when rendering.
class ShapeComponent extends PositionComponent with HasHitboxes {
final HitboxShape shape;
Paint paint; Paint paint;
/// Currently the [anchor] can only be center for [ShapeComponent], since
/// shape doesn't take any anchor into consideration.
@override
final Anchor anchor = Anchor.center;
ShapeComponent( ShapeComponent(
this.shape, { this.shape, {
Paint? paint, Paint? paint,
Vector2? position,
Vector2? size,
Vector2? scale,
double? angle,
Anchor? anchor,
int? priority, int? priority,
}) : paint = paint ?? BasicPalette.white.paint(), }) : paint = paint ?? BasicPalette.white.paint(),
super( super(
position: shape.position, position: position,
size: shape.size, size: size,
angle: shape.angle, scale: scale,
anchor: Anchor.center, angle: angle,
anchor: anchor,
priority: priority, priority: priority,
) { ) {
shape.isCanvasPrepared = true; shape.isCanvasPrepared = true;
addHitbox(shape);
} }
@override @override
void render(Canvas canvas) { void render(Canvas canvas) {
shape.render(canvas, paint); shape.render(canvas, paint);
} }
@override
bool containsPoint(Vector2 point) => shape.containsPoint(point);
} }
class CircleComponent extends ShapeComponent { class CircleComponent extends ShapeComponent {
CircleComponent( CircleComponent({
double radius, { required double radius,
Vector2? position,
Paint? paint, Paint? paint,
Vector2? position,
Vector2? scale,
double? angle,
Anchor? anchor,
int? priority, int? priority,
}) : super( }) : super(
Circle(radius: radius, position: position), HitboxCircle(),
paint: paint, paint: paint,
position: position,
size: Vector2.all(radius * 2),
scale: scale,
angle: angle,
anchor: anchor,
priority: priority, priority: priority,
); );
} }
class RectangleComponent extends ShapeComponent { class RectangleComponent extends ShapeComponent {
RectangleComponent( RectangleComponent({
Vector2 size, { required Vector2 size,
Vector2? position,
Paint? paint, Paint? paint,
Vector2? position,
Vector2? scale,
double? angle,
Anchor? anchor,
int? priority, int? priority,
}) : super( }) : super(
Rectangle(size: size, position: position), HitboxRectangle(),
paint: paint, paint: paint,
position: position,
size: size,
scale: scale,
angle: angle,
anchor: anchor,
priority: priority, priority: priority,
); );
RectangleComponent.square( RectangleComponent.square({
double size, { required double size,
Vector2? position,
Paint? paint, Paint? paint,
Vector2? position,
Vector2? scale,
double? angle,
Anchor? anchor,
int? priority, int? priority,
}) : super( }) : super(
Rectangle(size: Vector2.all(size), position: position), HitboxRectangle(),
paint: paint, paint: paint,
position: position,
size: Vector2.all(size),
scale: scale,
angle: angle,
anchor: anchor,
priority: priority, priority: priority,
); );
} }
class PolygonComponent extends ShapeComponent { class PolygonComponent extends ShapeComponent {
PolygonComponent( /// The [normalizedVertices] should be a list of points that range between
List<Vector2> points, { /// [-1.0, 1.0] which defines the relation of the vertices in the polygon
/// from the center of the component to the size of the component.
PolygonComponent({
required List<Vector2> normalizedVertices,
Paint? paint, Paint? paint,
int? priority,
}) : super(Polygon(points), paint: paint, priority: priority);
PolygonComponent.fromDefinition(
List<Vector2> normalizedVertices, {
Vector2? size,
Vector2? position, Vector2? position,
Paint? paint, Vector2? size,
Vector2? scale,
double? angle,
Anchor? anchor,
int? priority, int? priority,
}) : super( }) : super(
Polygon.fromDefinition( HitboxPolygon(normalizedVertices),
normalizedVertices,
position: position,
size: size,
),
paint: paint, paint: paint,
position: position,
size: size,
scale: scale,
angle: angle,
anchor: anchor,
priority: priority, priority: priority,
); );
/// Instead of using vertices that are in relation to the size of the
/// component you can use this factory with absolute points which will set the
/// position and size of the component and calculate the normalized vertices.
factory PolygonComponent.fromPoints(
List<Vector2> points, {
Paint? paint,
Vector2? position,
Vector2? size,
Vector2? scale,
double? angle,
Anchor? anchor,
int? priority,
}) {
final polygon = Polygon(points);
final anchorPosition = position ??
Anchor.center.toOtherAnchorPosition(
polygon.position,
anchor ?? Anchor.topLeft,
size ?? polygon.size,
);
return PolygonComponent(
normalizedVertices: polygon.normalizedVertices,
paint: paint,
position: anchorPosition,
size: size ?? polygon.size,
scale: scale,
angle: angle,
anchor: anchor,
priority: priority,
);
}
} }

View File

@ -37,6 +37,9 @@ extension Vector2Extension on Vector2 {
/// Whether the [Vector2] is the zero vector or not /// Whether the [Vector2] is the zero vector or not
bool isZero() => x == 0 && y == 0; bool isZero() => x == 0 && y == 0;
/// Whether the [Vector2] is the identity vector or not
bool isIdentity() => x == 1 && y == 1;
/// Rotates the [Vector2] with [angle] in radians /// Rotates the [Vector2] with [angle] in radians
/// rotates around [center] if it is defined /// rotates around [center] if it is defined
/// In a screen coordinate system (where the y-axis is flipped) it rotates in /// In a screen coordinate system (where the y-axis is flipped) it rotates in
@ -99,7 +102,7 @@ extension Vector2Extension on Vector2 {
/// ///
/// Since on a canvas/screen y is smaller the further up you go, instead of /// Since on a canvas/screen y is smaller the further up you go, instead of
/// larger like on a normal coordinate system, to get an angle that is in that /// larger like on a normal coordinate system, to get an angle that is in that
/// coordinate system we have to flip the Y-axis of the [Vector]. /// coordinate system we have to flip the Y-axis of the [Vector2].
/// ///
/// Example: /// Example:
/// Up: Vector(0.0, -1.0).screenAngle == 0 /// Up: Vector(0.0, -1.0).screenAngle == 0
@ -119,4 +122,7 @@ extension Vector2Extension on Vector2 {
/// Creates a heading [Vector2] with the given angle in degrees. /// Creates a heading [Vector2] with the given angle in degrees.
static Vector2 fromDegrees(double d) => fromRadians(d * degrees2Radians); static Vector2 fromDegrees(double d) => fromRadians(d * degrees2Radians);
/// Creates a new identity [Vector2] (1.0, 1.0).
static Vector2 identity() => Vector2.all(1.0);
} }

View File

@ -7,9 +7,9 @@ import '../extensions/vector2.dart';
import 'shape.dart'; import 'shape.dart';
class Circle extends Shape { class Circle extends Shape {
/// The [normalizedRadius] is how many percentages of the shortest edge of /// The [normalizedRadius] is what ratio (0.0, 1.0] of the shortest edge of
/// [size] that the circle should cover. /// [size]/2 that the circle should cover.
double normalizedRadius = 1; double normalizedRadius = 1.0;
/// With this constructor you can create your [Circle] from a radius and /// With this constructor you can create your [Circle] from a radius and
/// a position. It will also calculate the bounding rectangle [size] for the /// a position. It will also calculate the bounding rectangle [size] for the
@ -25,16 +25,26 @@ class Circle extends Shape {
); );
/// This constructor is used by [HitboxCircle] /// This constructor is used by [HitboxCircle]
/// definition is the percentages of the shortest edge of [size] that the /// [relation] is the relation [0.0, 1.0] of the shortest edge of [size] that
/// circle should fill. /// the circle should fill.
Circle.fromDefinition({ Circle.fromDefinition({
this.normalizedRadius = 1.0, double? relation,
Vector2? position, Vector2? position,
Vector2? size, Vector2? size,
double? angle, double? angle,
}) : super(position: position, size: size, angle: angle ?? 0); }) : normalizedRadius = relation ?? 1.0,
super(position: position, size: size, angle: angle ?? 0);
double get radius => (min(size.x, size.y) / 2) * normalizedRadius; // Used to not create new Vector2 objects every time radius is called.
final Vector2 _scaledSize = Vector2.zero();
/// Get the radius of the circle after it has been sized and scaled.
double get radius {
_scaledSize
..setFrom(size)
..multiply(scale);
return (min(_scaledSize.x, _scaledSize.y) / 2) * normalizedRadius;
}
/// This render method doesn't rotate the canvas according to angle since a /// This render method doesn't rotate the canvas according to angle since a
/// circle will look the same rotated as not rotated. /// circle will look the same rotated as not rotated.
@ -100,9 +110,15 @@ class Circle extends Shape {
} }
class HitboxCircle extends Circle with HitboxShape { class HitboxCircle extends Circle with HitboxShape {
@override HitboxCircle({
HitboxCircle({double definition = 1}) double? normalizedRadius,
: super.fromDefinition( Vector2? position,
normalizedRadius: definition, Vector2? size,
double? angle,
}) : super.fromDefinition(
relation: normalizedRadius,
position: position,
size: size,
angle: angle ?? 0,
); );
} }

View File

@ -9,6 +9,8 @@ class LineSegment {
LineSegment(this.from, this.to); LineSegment(this.from, this.to);
factory LineSegment.zero() => LineSegment(Vector2.zero(), Vector2.zero());
/// Returns an empty list if there are no intersections between the segments /// Returns an empty list if there are no intersections between the segments
/// If the segments are concurrent, the intersecting point is returned as a /// If the segments are concurrent, the intersecting point is returned as a
/// list with a single point /// list with a single point

View File

@ -1,20 +1,28 @@
import 'dart:math';
import 'dart:ui' hide Canvas; import 'dart:ui' hide Canvas;
import '../../game.dart'; import '../../game.dart';
import '../../geometry.dart'; import '../../geometry.dart';
import '../components/cache/value_cache.dart'; import '../components/cache/value_cache.dart';
import '../extensions/canvas.dart'; import '../extensions/canvas.dart';
import '../extensions/offset.dart';
import '../extensions/rect.dart'; import '../extensions/rect.dart';
import '../extensions/vector2.dart'; import '../extensions/vector2.dart';
import 'shape.dart'; import 'shape.dart';
class Polygon extends Shape { class Polygon extends Shape {
final List<Vector2> normalizedVertices; final List<Vector2> normalizedVertices;
// These lists are used to minimize the amount of [Vector2] objects that are // These lists are used to minimize the amount of objects that are created,
// created, only change them if the cache is deemed invalid // and only change the contained object if the corresponding `ValueCache` is
late final List<Vector2> _sizedVertices; // deemed outdated.
late final List<Vector2> _hitboxVertices; late final List<Vector2> _localVertices;
late final List<Vector2> _globalVertices;
late final List<Offset> _renderVertices;
late final List<LineSegment> _lineSegments;
final _path = Path();
final _cachedLocalVertices = ValueCache<Iterable<Vector2>>();
final _cachedGlobalVertices = ValueCache<List<Vector2>>();
final _cachedRenderPath = ValueCache<Path>();
/// With this constructor you create your [Polygon] from positions in your /// With this constructor you create your [Polygon] from positions in your
/// intended space. It will automatically calculate the [size] and center /// intended space. It will automatically calculate the [size] and center
@ -23,21 +31,19 @@ class Polygon extends Shape {
List<Vector2> points, { List<Vector2> points, {
double angle = 0, double angle = 0,
}) { }) {
final center = points.fold<Vector2>( assert(
Vector2.zero(), points.length > 2,
(sum, v) => sum + v, 'List of points is too short to create a polygon',
) /
points.length.toDouble();
final bottomRight = points.fold<Vector2>(
Vector2.zero(),
(bottomRight, v) {
return Vector2(
max(bottomRight.x, v.x),
max(bottomRight.y, v.y),
);
},
); );
final halfSize = bottomRight - center; final path = Path()
..addPolygon(
points.map((p) => p.toOffset()).toList(growable: false),
true,
);
final boundingRect = path.getBounds();
final centerOffset = boundingRect.center;
final center = centerOffset.toVector2();
final halfSize = (boundingRect.bottomRight - centerOffset).toVector2();
final definition = final definition =
points.map<Vector2>((v) => (v - center)..divide(halfSize)).toList(); points.map<Vector2>((v) => (v - center)..divide(halfSize)).toList();
return Polygon.fromDefinition( return Polygon.fromDefinition(
@ -50,9 +56,10 @@ class Polygon extends Shape {
/// With this constructor you define the [Polygon] from the center of and with /// With this constructor you define the [Polygon] from the center of and with
/// percentages of the size of the shape. /// percentages of the size of the shape.
/// Example: [[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0], [0.0, -1.0]] /// Example: [[1.0, 0.0], [0.0, -1.0], [-1.0, 0.0], [0.0, 1.0]]
/// This will form a diamond shape within the bounding size box. /// This will form a diamond shape within the bounding size box.
/// NOTE: Always define your shape in a clockwise fashion /// NOTE: Always define your shape in a counter-clockwise fashion (in the
/// screen coordinate system).
Polygon.fromDefinition( Polygon.fromDefinition(
this.normalizedVertices, { this.normalizedVertices, {
Vector2? position, Vector2? position,
@ -63,51 +70,113 @@ class Polygon extends Shape {
size: size, size: size,
angle: angle ?? 0, angle: angle ?? 0,
) { ) {
_sizedVertices = List<Vector2> generateList() {
normalizedVertices.map((_) => Vector2.zero()).toList(growable: false); return List.generate(
_hitboxVertices = normalizedVertices.length,
normalizedVertices.map((_) => Vector2.zero()).toList(growable: false); (_) => Vector2.zero(),
} growable: false,
);
}
final _cachedScaledShape = ValueCache<Iterable<Vector2>>(); _localVertices = generateList();
_globalVertices = generateList();
_renderVertices = List.filled(
normalizedVertices.length,
Offset.zero,
growable: false,
);
_lineSegments = List.generate(
normalizedVertices.length,
(_) => LineSegment.zero(),
growable: false,
);
}
/// Gives back the shape vectors multiplied by the size /// Gives back the shape vectors multiplied by the size
Iterable<Vector2> scaled() { Iterable<Vector2> localVertices() {
if (!_cachedScaledShape.isCacheValid([size])) { final center = localCenter;
for (var i = 0; i < _sizedVertices.length; i++) { if (!_cachedLocalVertices.isCacheValid([size, center])) {
final halfSize = this.halfSize;
for (var i = 0; i < _localVertices.length; i++) {
final point = normalizedVertices[i]; final point = normalizedVertices[i];
(_sizedVertices[i]..setFrom(point)).multiply(halfSize); (_localVertices[i]..setFrom(point))
..multiply(halfSize)
..add(center)
..rotate(angle, center: center);
} }
_cachedScaledShape.updateCache(_sizedVertices, [size.clone()]); _cachedLocalVertices.updateCache(_localVertices, [
size.clone(),
center.clone(),
]);
} }
return _cachedScaledShape.value!; return _cachedLocalVertices.value!;
} }
final _cachedRenderPath = ValueCache<Path>(); /// Gives back the shape vectors multiplied by the size and scale
List<Vector2> globalVertices() {
final scale = this.scale;
if (!_cachedGlobalVertices.isCacheValid([
position,
offsetPosition,
relativeOffset,
size,
scale,
parentAngle,
angle,
])) {
var i = 0;
final center = absoluteCenter;
final halfSize = this.halfSize;
for (final normalizedPoint in normalizedVertices) {
_globalVertices[i]
..setFrom(normalizedPoint)
..multiply(halfSize)
..multiply(scale)
..add(center)
..rotate(parentAngle + angle, center: center);
i++;
}
if (scale.y.isNegative || scale.x.isNegative) {
// Since the list will be clockwise we have to reverse it for it to
// become counterclockwise.
_reverseList(_globalVertices);
}
_cachedGlobalVertices.updateCache(_globalVertices, [
position.clone(),
offsetPosition.clone(),
relativeOffset.clone(),
size.clone(),
scale.clone(),
parentAngle,
angle,
]);
}
return _cachedGlobalVertices.value!;
}
@override @override
void render(Canvas canvas, Paint paint) { void render(Canvas canvas, Paint paint) {
if (!_cachedRenderPath if (!_cachedRenderPath.isCacheValid([
.isCacheValid([offsetPosition, relativeOffset, size, angle])) { offsetPosition,
final center = localCenter; relativeOffset,
size,
parentAngle,
angle,
])) {
var i = 0;
(isCanvasPrepared ? localVertices() : globalVertices()).forEach((point) {
_renderVertices[i] = point.toOffset();
i++;
});
_cachedRenderPath.updateCache( _cachedRenderPath.updateCache(
Path() _path
..addPolygon( ..reset()
scaled().map( ..addPolygon(_renderVertices, true),
(point) {
final pathPoint = center + point;
if (!isCanvasPrepared) {
pathPoint.rotate(angle, center: center);
}
return pathPoint.toOffset();
},
).toList(),
true,
),
[ [
offsetPosition.clone(), offsetPosition.clone(),
relativeOffset.clone(), relativeOffset.clone(),
size.clone(), size.clone(),
parentAngle,
angle, angle,
], ],
); );
@ -115,32 +184,13 @@ class Polygon extends Shape {
canvas.drawPath(_cachedRenderPath.value!, paint); canvas.drawPath(_cachedRenderPath.value!, paint);
} }
final _cachedHitbox = ValueCache<List<Vector2>>();
/// Gives back the vertices represented as a list of points which /// Gives back the vertices represented as a list of points which
/// are the "corners" of the hitbox rotated with [angle]. /// are the "corners" of the hitbox rotated with [angle].
List<Vector2> hitbox() { /// These are in the global hitbox coordinate space since all hitboxes are
// Use cached bounding vertices if state of the component hasn't changed /// compared towards each other.
if (!_cachedHitbox
.isCacheValid([absoluteCenter, size, parentAngle, angle])) {
final scaledVertices = scaled().toList(growable: false);
final center = absoluteCenter;
for (var i = 0; i < _hitboxVertices.length; i++) {
_hitboxVertices[i]
..setFrom(center)
..add(scaledVertices[i])
..rotate(parentAngle + angle, center: center);
}
_cachedHitbox.updateCache(
_hitboxVertices,
[absoluteCenter, size.clone(), parentAngle, angle],
);
}
return _cachedHitbox.value!;
}
/// Checks whether the polygon represented by the list of [Vector2] contains /// Checks whether the polygon contains the [point].
/// the [point]. /// Note: The polygon needs to be convex for this to work.
@override @override
bool containsPoint(Vector2 point) { bool containsPoint(Vector2 point) {
// If the size is 0 then it can't contain any points // If the size is 0 then it can't contain any points
@ -148,7 +198,7 @@ class Polygon extends Shape {
return false; return false;
} }
final vertices = hitbox(); final vertices = globalVertices();
for (var i = 0; i < vertices.length; i++) { for (var i = 0; i < vertices.length; i++) {
final edge = getEdge(i, vertices: vertices); final edge = getEdge(i, vertices: vertices);
final isOutside = (edge.to.x - edge.from.x) * (point.y - edge.from.y) - final isOutside = (edge.to.x - edge.from.x) * (point.y - edge.from.y) -
@ -166,7 +216,7 @@ class Polygon extends Shape {
/// is null return all vertices as [LineSegment]s. /// is null return all vertices as [LineSegment]s.
List<LineSegment> possibleIntersectionVertices(Rect? rect) { List<LineSegment> possibleIntersectionVertices(Rect? rect) {
final rectIntersections = <LineSegment>[]; final rectIntersections = <LineSegment>[];
final vertices = hitbox(); final vertices = globalVertices();
for (var i = 0; i < vertices.length; i++) { for (var i = 0; i < vertices.length; i++) {
final edge = getEdge(i, vertices: vertices); final edge = getEdge(i, vertices: vertices);
if (rect?.intersectsSegment(edge.from, edge.to) ?? true) { if (rect?.intersectsSegment(edge.from, edge.to) ?? true) {
@ -177,21 +227,28 @@ class Polygon extends Shape {
} }
LineSegment getEdge(int i, {required List<Vector2> vertices}) { LineSegment getEdge(int i, {required List<Vector2> vertices}) {
return LineSegment( _lineSegments[i].from.setFrom(getVertex(i, vertices: vertices));
getVertex(i, vertices: vertices), _lineSegments[i].to.setFrom(getVertex(i + 1, vertices: vertices));
getVertex( return _lineSegments[i];
i + 1,
vertices: vertices,
),
);
} }
Vector2 getVertex(int i, {List<Vector2>? vertices}) { Vector2 getVertex(int i, {List<Vector2>? vertices}) {
vertices ??= hitbox(); vertices ??= globalVertices();
return vertices[i % vertices.length]; return vertices[i % vertices.length];
} }
void _reverseList(List<Object> list) {
for (var i = 0; i < list.length / 2; i++) {
final temp = list[i];
list[i] = list[list.length - 1 - i];
list[list.length - 1 - i] = temp;
}
}
} }
class HitboxPolygon extends Polygon with HitboxShape { class HitboxPolygon extends Polygon with HitboxShape {
HitboxPolygon(List<Vector2> definition) : super.fromDefinition(definition); HitboxPolygon(List<Vector2> definition) : super.fromDefinition(definition);
factory HitboxPolygon.fromPolygon(Polygon polygon) =>
HitboxPolygon(polygon.normalizedVertices);
} }

View File

@ -14,6 +14,10 @@ abstract class Shape {
final ValueCache<Vector2> _halfSizeCache = ValueCache(); final ValueCache<Vector2> _halfSizeCache = ValueCache();
final ValueCache<Vector2> _localCenterCache = ValueCache(); final ValueCache<Vector2> _localCenterCache = ValueCache();
final ValueCache<Vector2> _absoluteCenterCache = ValueCache(); final ValueCache<Vector2> _absoluteCenterCache = ValueCache();
final ValueCache<Vector2> _relativePositionCache = ValueCache();
// These are used to avoid creating new vector objects on some method calls
final Vector2 _identityVector2 = Vector2Extension.identity();
/// Should be the center of that [offsetPosition] and [relativeOffset] /// Should be the center of that [offsetPosition] and [relativeOffset]
/// should be calculated from, if they are not set this is the center of the /// should be calculated from, if they are not set this is the center of the
@ -23,6 +27,10 @@ abstract class Shape {
/// The size is the bounding box of the [Shape] /// The size is the bounding box of the [Shape]
Vector2 size; Vector2 size;
/// The scaled size of the bounding box of the [Shape], if no scaling of the
/// parent is supported this will return [size].
Vector2 get scale => _identityVector2;
Vector2 get halfSize { Vector2 get halfSize {
if (!_halfSizeCache.isCacheValid([size])) { if (!_halfSizeCache.isCacheValid([size])) {
_halfSizeCache.updateCache(size / 2, [size.clone()]); _halfSizeCache.updateCache(size / 2, [size.clone()]);
@ -41,11 +49,19 @@ abstract class Shape {
Vector2 relativeOffset = Vector2.zero(); Vector2 relativeOffset = Vector2.zero();
/// The [relativeOffset] converted to a length vector /// The [relativeOffset] converted to a length vector
Vector2 get relativePosition => (size / 2)..multiply(relativeOffset); Vector2 get relativePosition {
if (!_relativePositionCache.isCacheValid([size, relativeOffset])) {
_relativePositionCache.updateCache(
(size / 2)..multiply(relativeOffset),
[size.clone(), relativeOffset.clone()],
);
}
return _relativePositionCache.value!;
}
/// The angle of the parent that has to be taken into consideration for some /// The angle of the parent that has to be taken into consideration for some
/// applications of [Shape], for example [HitboxShape] /// applications of [Shape], for example [HitboxShape]
double parentAngle; double parentAngle = 0;
/// Whether the context that the shape is in has already prepared (rotated /// Whether the context that the shape is in has already prepared (rotated
/// and translated) the canvas before coming to the shape's render method. /// and translated) the canvas before coming to the shape's render method.
@ -103,7 +119,6 @@ abstract class Shape {
Vector2? position, Vector2? position,
Vector2? size, Vector2? size,
this.angle = 0, this.angle = 0,
this.parentAngle = 0,
}) : position = position ?? Vector2.zero(), }) : position = position ?? Vector2.zero(),
size = size ?? Vector2.zero(); size = size ?? Vector2.zero();
@ -116,25 +131,22 @@ abstract class Shape {
Set<Vector2> intersections(Shape other) { Set<Vector2> intersections(Shape other) {
return intersection_system.intersections(this, other); return intersection_system.intersections(this, other);
} }
/// Turns a [Shape] into a [ShapeComponent]
///
/// Do note that while a [Shape] is defined from the center, a
/// [ShapeComponent] like all other components default to an [Anchor] in the
/// top left corner.
ShapeComponent toComponent({Paint? paint}) {
return ShapeComponent(this, paint: paint);
}
} }
mixin HitboxShape on Shape { mixin HitboxShape on Shape {
late PositionComponent component; late PositionComponent component;
@override @override
Vector2 get size => component.scaledSize; bool isCanvasPrepared = true;
@override @override
double get parentAngle => component.angle; Vector2 get size => component.size;
@override
Vector2 get scale => component.scale;
@override
double get parentAngle => component.absoluteAngle;
@override @override
Vector2 get position => component.absoluteCenter; Vector2 get position => component.absoluteCenter;

View File

@ -1,6 +1,5 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:flame/geometry.dart';
import 'package:flame/input.dart'; import 'package:flame/input.dart';
import 'package:flame_test/flame_test.dart'; import 'package:flame_test/flame_test.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -14,7 +13,7 @@ void main() {
group('JoystickDirection tests', () { group('JoystickDirection tests', () {
test('Can convert angle to JoystickDirection', () { test('Can convert angle to JoystickDirection', () {
final joystick = JoystickComponent( final joystick = JoystickComponent(
knob: Circle(radius: 5.0).toComponent(), knob: CircleComponent(radius: 5.0),
size: 20, size: 20,
margin: const EdgeInsets.only(left: 20, bottom: 20), margin: const EdgeInsets.only(left: 20, bottom: 20),
); );
@ -47,7 +46,7 @@ void main() {
'knob should stay on correct side when the total delta is larger than the size and then the knob is moved slightly back again', 'knob should stay on correct side when the total delta is larger than the size and then the knob is moved slightly back again',
(game) async { (game) async {
final joystick = JoystickComponent( final joystick = JoystickComponent(
knob: Circle(radius: 5.0).toComponent(), knob: CircleComponent(radius: 5.0),
size: 20, size: 20,
margin: const EdgeInsets.only(left: 20, top: 20), margin: const EdgeInsets.only(left: 20, top: 20),
); );

View File

@ -0,0 +1,420 @@
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_test/flame_test.dart';
import 'package:test/test.dart';
void main() {
group('ShapeComponent.containsPoint tests', () {
test('Simple circle contains point', () {
final component = CircleComponent(
radius: 1.0,
position: Vector2(1, 1),
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2.all(1.5)),
isTrue,
);
});
test('Simple rectangle contains point', () {
final component = RectangleComponent(
position: Vector2(1, 1),
size: Vector2(1, 1),
);
expect(
component.containsPoint(Vector2.all(1.5)),
isTrue,
);
});
test('Simple polygon contains point', () {
final component = PolygonComponent.fromPoints(
[
Vector2(2, 2),
Vector2(2, 1),
Vector2(2, 0),
Vector2(1, 1),
],
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2(2.0, 1.9)),
isTrue,
);
});
test('Rotated circle does not contain point', () {
final component = CircleComponent(
radius: 1.0,
position: Vector2(1, 1),
angle: pi / 4,
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2.all(1.9)),
isFalse,
);
});
test('Rotated rectangle does not contain point', () {
final component = RectangleComponent(
position: Vector2.all(1.0),
size: Vector2.all(2.0),
angle: pi / 4,
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2.all(1.9)),
isFalse,
);
});
test('Rotated polygon does not contain point', () {
final component = PolygonComponent.fromPoints(
[
Vector2(2, 2),
Vector2(2, 1),
Vector2(2, 0),
Vector2(1, 1),
],
angle: pi / 4,
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2.all(1.9)),
isFalse,
);
});
test('Rotated circle contains point', () {
final component = CircleComponent(
radius: 1.0,
position: Vector2(1, 1),
angle: pi / 4,
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2(1.0, 1.9)),
isTrue,
);
});
test('Rotated rectangle contains point', () {
final component = RectangleComponent(
position: Vector2.all(1.0),
size: Vector2.all(2.0),
angle: pi / 4,
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2(1.0, 2.1)),
isTrue,
);
});
test('Rotated polygon contains point', () {
final component = PolygonComponent.fromPoints(
[
Vector2(2, 2),
Vector2(3, 1),
Vector2(2, 0),
Vector2(1, 1),
],
angle: pi / 4,
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2(2.7, 1.7)),
isTrue,
);
});
test('Horizontally flipped rectangle contains point', () {
final component = RectangleComponent(
position: Vector2.all(1.0),
size: Vector2.all(2.0),
anchor: Anchor.center,
)..flipVerticallyAroundCenter();
expect(
component.containsPoint(Vector2(2.0, 2.0)),
isTrue,
);
});
test('Initially rotated CircleComponent does not contain point', () {
final component = CircleComponent(
radius: 1.0,
position: Vector2(1, 1),
angle: pi / 4,
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2.all(1.9)),
isFalse,
);
});
test('Initially rotated RectangleComponent does not contain point', () {
final component = RectangleComponent(
position: Vector2.all(1.0),
size: Vector2.all(2.0),
angle: pi / 4,
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2.all(1.9)),
isFalse,
);
});
test('Initially rotated PolygonComponent does not contain point', () {
final component = PolygonComponent.fromPoints(
[
Vector2(2, 2),
Vector2(3, 1),
Vector2(2, 0),
Vector2(1, 1),
],
angle: pi / 4,
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2.all(1.9)),
isFalse,
);
});
test('Rotated PolygonComponent contains point', () {
final component = PolygonComponent.fromPoints(
[
Vector2(2, 2),
Vector2(3, 1),
Vector2(2, 0),
Vector2(1, 1),
],
anchor: Anchor.center,
);
component.angle = pi / 4;
expect(
component.containsPoint(Vector2(2.7, 1.7)),
isTrue,
);
});
test('Moved CircleComponent contains point', () {
final component = CircleComponent(
radius: 1.0,
position: Vector2(2, 2),
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2.all(2.1)),
isTrue,
);
});
test('Moved RectangleComponent contains point', () {
final component = RectangleComponent(
position: Vector2(2, 2),
size: Vector2(1, 1),
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2.all(2.1)),
isTrue,
);
});
test('Moved PolygonComponent contains point', () {
final component = PolygonComponent.fromPoints(
[
Vector2(2, 0),
Vector2(1, 1),
Vector2(2, 2),
Vector2(3, 1),
],
position: Vector2.all(1.0),
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2(0.9, 1.0)),
isTrue,
);
});
test('Sized up CircleComponent does not contain point', () {
final component = CircleComponent(
radius: 1.0,
position: Vector2(1, 1),
anchor: Anchor.center,
);
component.size += Vector2.all(1.0);
expect(
component.containsPoint(Vector2.all(2.1)),
isFalse,
);
});
test('Sized up RectangleComponent does not contain point', () {
final component = RectangleComponent(
position: Vector2(1, 1),
size: Vector2(2, 2),
anchor: Anchor.center,
);
expect(
component.containsPoint(Vector2.all(2.1)),
isFalse,
);
});
test('Sized PolygonComponent does not contain point', () {
final component = PolygonComponent.fromPoints(
[
Vector2(2, 0),
Vector2(1, 1),
Vector2(2, 2),
Vector2(3, 1),
],
anchor: Anchor.center,
);
component.size += Vector2.all(1.0);
expect(
component.containsPoint(Vector2(2.0, 2.6)),
isFalse,
);
});
test('CircleComponent with default anchor (topLeft) contains point', () {
final component = CircleComponent(
radius: 1.0,
position: Vector2.all(1.0),
angle: pi / 4,
);
expect(
component.containsPoint(Vector2(0.9, 2.0)),
isTrue,
);
});
test('RectangleComponent with default anchor (topLeft) contains point', () {
final component = RectangleComponent(
position: Vector2.all(1.0),
size: Vector2.all(1.0),
angle: pi / 4,
);
expect(
component.containsPoint(Vector2(0.9, 2.0)),
isTrue,
);
});
test('PolygonComponent with default anchor (topLeft) contains point', () {
final component = PolygonComponent.fromPoints(
[
Vector2(2, 0),
Vector2(1, 1),
Vector2(2, 2),
Vector2(3, 1),
],
angle: pi / 4,
);
expect(
component.containsPoint(Vector2(1.0, 0.99)),
isTrue,
);
});
flameGame.test(
'CircleComponent with multiple parents contains point',
(game) async {
PositionComponent createParent() {
return PositionComponent(
position: Vector2.all(1.0),
size: Vector2.all(2.0),
angle: pi / 2,
);
}
final component = CircleComponent(
radius: 1.0,
position: Vector2.all(1.0),
anchor: Anchor.center,
);
final grandParent = createParent();
final parent = createParent();
grandParent.add(parent);
parent.add(component);
await game.add(grandParent);
expect(
component.containsPoint(Vector2(-1.0, 1.0)),
isTrue,
);
},
);
flameGame.test(
'RectangleComponent with multiple parents contains point',
(game) async {
PositionComponent createParent() {
return PositionComponent(
position: Vector2.all(1.0),
size: Vector2.all(2.0),
angle: pi / 2,
);
}
final component = RectangleComponent(
size: Vector2.all(1.0),
position: Vector2.all(1.0),
anchor: Anchor.center,
);
final grandParent = createParent();
final parent = createParent();
grandParent.add(parent);
parent.add(component);
await game.add(grandParent);
expect(
component.containsPoint(Vector2(-1.0, 1.0)),
isTrue,
);
},
);
flameGame.test(
'PolygonComponent with multiple parents contains point',
(game) async {
PositionComponent createParent() {
return PositionComponent(
position: Vector2.all(1.0),
size: Vector2.all(2.0),
angle: pi / 2,
);
}
final component = PolygonComponent(
normalizedVertices: [
Vector2(1, 0),
Vector2(0, -1),
Vector2(-1, 0),
Vector2(0, 1),
],
size: Vector2.all(1.0),
position: Vector2.all(1.0),
anchor: Anchor.center,
);
final grandParent = createParent();
final parent = createParent();
grandParent.add(parent);
parent.add(component);
await game.add(grandParent);
expect(
component.containsPoint(Vector2(-1.0, 1.0)),
isTrue,
);
},
);
});
}

View File

@ -229,7 +229,7 @@ void main() {
const message = '"prepare/add" called before the game is ready. ' const message = '"prepare/add" called before the game is ready. '
'Did you try to access it on the Game constructor? ' 'Did you try to access it on the Game constructor? '
'Use the "onLoad" or "onParentMethod" method instead.'; 'Use the "onLoad" or "onMount" method instead.';
expect( expect(
() => game.add(component), () => game.add(component),

View File

@ -77,19 +77,3 @@ class SkillsAnimationComponent extends RiveComponent with Tappable {
return true; return true;
} }
} }
class Square extends PositionComponent with HasGameRef<RiveExampleGame> {
late final Paint paint;
Square(Vector2 position) {
this.position.setFrom(position);
size.setValues(100, 100);
paint = PaintExtension.random(withAlpha: 0.9, base: 100);
}
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRect(size.toRect(), paint);
}
}