diff --git a/doc/collision_detection.md b/doc/collision_detection.md index 2987f2d39..1b1113012 100644 --- a/doc/collision_detection.md +++ b/doc/collision_detection.md @@ -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 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 ### HasHitboxes The `HasHitboxes` mixin is mainly used for two things; to make detection of collisions with other diff --git a/doc/components.md b/doc/components.md index fc66047cf..b4e5dc3fa 100644 --- a/doc/components.md +++ b/doc/components.md @@ -422,6 +422,100 @@ It is also possible to create custom renderers by extending the `ParallaxRendere Three example implementations can be found in the [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 See [SpriteBodyComponent](forge2d.md#SpriteBodyComponent) in the Forge2D documentation. diff --git a/examples/lib/commons/square_component.dart b/examples/lib/commons/square_component.dart index fdecc49e7..2e758138d 100644 --- a/examples/lib/commons/square_component.dart +++ b/examples/lib/commons/square_component.dart @@ -10,8 +10,8 @@ class SquareComponent extends RectangleComponent with EffectsHelper { Paint? paint, int priority = 0, }) : super( - Vector2.all(size), position: position, + size: Vector2.all(size), paint: paint, priority: priority, ); diff --git a/examples/lib/stories/camera_and_viewport/follow_object.dart b/examples/lib/stories/camera_and_viewport/follow_object.dart index 8847fef3f..7a4f12042 100644 --- a/examples/lib/stories/camera_and_viewport/follow_object.dart +++ b/examples/lib/stories/camera_and_viewport/follow_object.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'package:flame/components.dart'; import 'package:flame/game.dart'; -import 'package:flame/geometry.dart'; import 'package:flame/input.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -12,11 +11,7 @@ import '../../commons/square_component.dart'; final R = Random(); class MovableSquare extends SquareComponent - with - HasHitboxes, - Collidable, - HasGameRef, - KeyboardHandler { + with Collidable, HasGameRef, KeyboardHandler { static const double speed = 300; static final TextPaint textRenderer = TextPaint( config: const TextPaintConfig( @@ -27,8 +22,11 @@ class MovableSquare extends SquareComponent final Vector2 velocity = Vector2.zero(); late Timer timer; - MovableSquare() : super(priority: 1) { - addHitbox(HitboxRectangle()); + MovableSquare() : super(priority: 1); + + @override + Future onLoad() async { + await super.onLoad(); timer = Timer(3.0) ..stop() ..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 pressedPaint = Paint()..color = const Color(0xFF414175); @@ -114,9 +112,7 @@ class Rock extends SquareComponent with HasHitboxes, Collidable, Tappable { size: 50, priority: 2, paint: unpressedPaint, - ) { - addHitbox(HitboxRectangle()); - } + ); @override bool onTapDown(_) { diff --git a/examples/lib/stories/collision_detection/circles.dart b/examples/lib/stories/collision_detection/circles.dart index 6f7333ec3..c002fd24a 100644 --- a/examples/lib/stories/collision_detection/circles.dart +++ b/examples/lib/stories/collision_detection/circles.dart @@ -7,14 +7,27 @@ import 'package:flame/geometry.dart'; import 'package:flame/input.dart'; import 'package:flutter/material.dart' hide Image, Draggable; -const circlesInfo = ''' -This example will create a circle every time you tap on the screen. It will have -the initial velocity towards the center of the screen and if it touches another -circle both of them will change color. -'''; +class CirclesExample extends FlameGame with HasCollidables, TapDetector { + static const description = ''' + This example will create a circle every time you tap on the screen. It will + have the initial velocity towards the center of the screen and if it touches + another circle both of them will change color. + '''; + + @override + Future onLoad() async { + super.onLoad(); + add(ScreenCollidable()); + } + + @override + void onTapDown(TapDownInfo info) { + add(MyCollidable(info.eventPosition.game)); + } +} class MyCollidable extends PositionComponent - with HasGameRef, HasHitboxes, Collidable { + with HasGameRef, HasHitboxes, Collidable { late Vector2 velocity; final _collisionColor = Colors.amber; final _defaultColor = Colors.cyan; @@ -63,16 +76,3 @@ class MyCollidable extends PositionComponent _isCollision = true; } } - -class Circles extends FlameGame with HasCollidables, TapDetector { - @override - Future onLoad() async { - super.onLoad(); - add(ScreenCollidable()); - } - - @override - void onTapDown(TapDownInfo info) { - add(MyCollidable(info.eventPosition.game)); - } -} diff --git a/examples/lib/stories/collision_detection/collidable_animation.dart b/examples/lib/stories/collision_detection/collidable_animation.dart new file mode 100644 index 000000000..68c75b58b --- /dev/null +++ b/examples/lib/stories/collision_detection/collidable_animation.dart @@ -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 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 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 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 intersectionPoints, Collidable other) { + if (!activeCollisions.contains(other)) { + velocity.negate(); + flipVertically(); + activeCollisions.add(other); + } + } + + @override + void onCollisionEnd(Collidable other) { + activeCollisions.remove(other); + } +} diff --git a/examples/lib/stories/collision_detection/collision_detection.dart b/examples/lib/stories/collision_detection/collision_detection.dart index 14489b078..3ca073719 100644 --- a/examples/lib/stories/collision_detection/collision_detection.dart +++ b/examples/lib/stories/collision_detection/collision_detection.dart @@ -3,27 +3,34 @@ import 'package:flame/game.dart'; import '../../commons/commons.dart'; import 'circles.dart'; +import 'collidable_animation.dart'; import 'multiple_shapes.dart'; -import 'only_shapes.dart'; +import 'simple_shapes.dart'; void addCollisionDetectionStories(Dashbook dashbook) { dashbook.storiesOf('Collision Detection') + ..add( + 'Collidable AnimationComponent', + (_) => GameWidget(game: CollidableAnimationExample()), + codeLink: baseLink('collision_detection/collidable_animation.dart'), + info: CollidableAnimationExample.description, + ) ..add( 'Circles', - (_) => GameWidget(game: Circles()), + (_) => GameWidget(game: CirclesExample()), codeLink: baseLink('collision_detection/circles.dart'), - info: circlesInfo, + info: CirclesExample.description, ) ..add( 'Multiple shapes', - (_) => GameWidget(game: MultipleShapes()), + (_) => GameWidget(game: MultipleShapesExample()), codeLink: baseLink('collision_detection/multiple_shapes.dart'), - info: multipleShapesInfo, + info: MultipleShapesExample.description, ) ..add( 'Simple Shapes', - (_) => GameWidget(game: OnlyShapes()), - codeLink: baseLink('collision_detection/only_shapes.dart'), - info: onlyShapesInfo, + (_) => GameWidget(game: SimpleShapesExample()), + codeLink: baseLink('collision_detection/simple_shapes.dart'), + info: SimpleShapesExample.description, ); } diff --git a/examples/lib/stories/collision_detection/multiple_shapes.dart b/examples/lib/stories/collision_detection/multiple_shapes.dart index d33555638..a15daefc6 100644 --- a/examples/lib/stories/collision_detection/multiple_shapes.dart +++ b/examples/lib/stories/collision_detection/multiple_shapes.dart @@ -9,21 +9,95 @@ import 'package:flame/input.dart'; import 'package:flame/palette.dart'; 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 } +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 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 with Draggable, HasHitboxes, Collidable { double rotationSpeed = 0.0; @@ -41,11 +115,7 @@ abstract class MyCollidable extends PositionComponent Vector2 size, this.velocity, this.screenCollidable, - ) { - this.position = position; - this.size = size; - anchor = Anchor.center; - } + ) : super(position: position, size: size, anchor: Anchor.center); @override Future onLoad() async { @@ -137,7 +207,7 @@ class CollidablePolygon extends MyCollidable { Vector2 velocity, ScreenCollidable screenCollidable, ) : super(position, size, velocity, screenCollidable) { - final shape = HitboxPolygon([ + final hitbox = HitboxPolygon([ Vector2(-1.0, 0.0), Vector2(-0.8, 0.6), Vector2(0.0, 1.0), @@ -147,7 +217,7 @@ class CollidablePolygon extends MyCollidable { Vector2(0, -1.0), Vector2(-0.8, -0.8), ]); - addHitbox(shape); + addHitbox(hitbox); } } @@ -169,8 +239,7 @@ class CollidableCircle extends MyCollidable { Vector2 velocity, ScreenCollidable screenCollidable, ) : super(position, size, velocity, screenCollidable) { - final shape = HitboxCircle(); - addHitbox(shape); + addHitbox(HitboxCircle()); } } @@ -179,7 +248,7 @@ class SnowmanPart extends HitboxCircle { final hitPaint = Paint(); SnowmanPart(double definition, Vector2 relativeOffset, Color hitColor) - : super(definition: definition) { + : super(normalizedRadius: definition) { this.relativeOffset.setFrom(relativeOffset); hitPaint..color = startColor; onCollision = (Set intersectionPoints, HitboxShape other) { @@ -245,77 +314,3 @@ MyCollidable randomCollidable( ..rotationSpeed = rotationSpeed; } } - -class MultipleShapes extends FlameGame - with HasCollidables, HasDraggableComponents, FPSCounter { - final TextPaint fpsTextPaint = TextPaint( - config: TextPaintConfig( - color: BasicPalette.white.color, - ), - ); - - @override - Future 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), - ); - } -} diff --git a/examples/lib/stories/collision_detection/only_shapes.dart b/examples/lib/stories/collision_detection/only_shapes.dart deleted file mode 100644 index 86019e0d6..000000000 --- a/examples/lib/stories/collision_detection/only_shapes.dart +++ /dev/null @@ -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; - } -} diff --git a/examples/lib/stories/collision_detection/simple_shapes.dart b/examples/lib/stories/collision_detection/simple_shapes.dart new file mode 100644 index 000000000..da2371a19 --- /dev/null +++ b/examples/lib/stories/collision_detection/simple_shapes.dart @@ -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; + } +} diff --git a/examples/lib/stories/effects/move_effect.dart b/examples/lib/stories/effects/move_effect.dart index cef1d1855..c005aa406 100644 --- a/examples/lib/stories/effects/move_effect.dart +++ b/examples/lib/stories/effects/move_effect.dart @@ -22,7 +22,8 @@ class MoveEffectGame extends FlameGame with TapDetector { await super.onLoad(); square = SquareComponent(size: 50, position: Vector2(200, 150)); add(square); - final pathMarkers = path.map((p) => CircleComponent(3, position: p)); + final pathMarkers = + path.map((p) => CircleComponent(radius: 3, position: p)); addAll(pathMarkers); } diff --git a/examples/lib/stories/effects/move_effect_example.dart b/examples/lib/stories/effects/move_effect_example.dart index 6fc93e742..2043cd9b0 100644 --- a/examples/lib/stories/effects/move_effect_example.dart +++ b/examples/lib/stories/effects/move_effect_example.dart @@ -84,7 +84,7 @@ class MoveEffectExample extends FlameGame { } for (var i = 0; i < 40; i++) { add( - CircleComponent(5) + CircleComponent(radius: 5) ..add( MoveEffect.along( path1, diff --git a/examples/lib/stories/effects/remove_effect_example.dart b/examples/lib/stories/effects/remove_effect_example.dart index 499e5679d..6f5b5d3fa 100644 --- a/examples/lib/stories/effects/remove_effect_example.dart +++ b/examples/lib/stories/effects/remove_effect_example.dart @@ -25,7 +25,7 @@ class RemoveEffectExample extends FlameGame with HasTappableComponents { class _RandomCircle extends CircleComponent with Tappable { _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) { final radius = rng.nextDouble() * 30 + 10; diff --git a/examples/lib/stories/input/joystick.dart b/examples/lib/stories/input/joystick.dart index 3b1a66360..f3d518c54 100644 --- a/examples/lib/stories/input/joystick.dart +++ b/examples/lib/stories/input/joystick.dart @@ -1,6 +1,5 @@ import 'package:flame/components.dart'; import 'package:flame/game.dart'; -import 'package:flame/geometry.dart'; import 'package:flame/input.dart'; import 'package:flame/palette.dart'; import 'package:flutter/painting.dart'; @@ -17,8 +16,8 @@ class JoystickGame extends FlameGame with HasDraggableComponents { final knobPaint = BasicPalette.blue.withAlpha(200).paint(); final backgroundPaint = BasicPalette.blue.withAlpha(100).paint(); joystick = JoystickComponent( - knob: Circle(radius: 30).toComponent(paint: knobPaint), - background: Circle(radius: 100).toComponent(paint: backgroundPaint), + knob: CircleComponent(radius: 30, paint: knobPaint), + background: CircleComponent(radius: 100, paint: backgroundPaint), margin: const EdgeInsets.only(left: 40, bottom: 40), ); player = JoystickPlayer(joystick); diff --git a/examples/lib/stories/input/joystick_advanced.dart b/examples/lib/stories/input/joystick_advanced.dart index c303a631b..1b8d31cd1 100644 --- a/examples/lib/stories/input/joystick_advanced.dart +++ b/examples/lib/stories/input/joystick_advanced.dart @@ -4,7 +4,6 @@ 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'; 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 // when it is pressed. final shapeButton = HudButtonComponent( - button: Circle(radius: 35).toComponent(paint: BasicPalette.white.paint()), - buttonDown: Rectangle(size: buttonSize) - .toComponent(paint: BasicPalette.blue.paint()), + button: CircleComponent(radius: 35), + buttonDown: RectangleComponent( + size: buttonSize, + paint: BasicPalette.blue.paint(), + ), margin: const EdgeInsets.only( right: 85, bottom: 150, diff --git a/packages/flame/CHANGELOG.md b/packages/flame/CHANGELOG.md index 3a70c294d..80b2ea7ee 100644 --- a/packages/flame/CHANGELOG.md +++ b/packages/flame/CHANGELOG.md @@ -22,6 +22,8 @@ - Components that manipulate canvas state are now responsible for saving/restoring that state - Remove `super.render` calls that are no longer needed - 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] - `changePriority` no longer breaks game loop iteration diff --git a/packages/flame/lib/src/components/component.dart b/packages/flame/lib/src/components/component.dart index 0904726ac..8b0c243d3 100644 --- a/packages/flame/lib/src/components/component.dart +++ b/packages/flame/lib/src/components/component.dart @@ -176,6 +176,20 @@ class Component with Loadable { nextParent = component; } + final List _ancestors = []; + + /// A list containing the current parent and its parent, and so on, until it + /// reaches a component without a parent. + List ancestors() { + _ancestors.clear(); + for (var currentParent = parent; + currentParent != null; + currentParent = currentParent.parent) { + _ancestors.add(currentParent); + } + return _ancestors; + } + /// It receives the new game size. /// Executed right after the component is attached to a game and right before /// [onLoad] is called. @@ -321,7 +335,7 @@ class Component with Loadable { parentGame.hasLayout, '"prepare/add" called before the game is ready. ' '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) { parentGame.prepareComponent(this); diff --git a/packages/flame/lib/src/components/position_component.dart b/packages/flame/lib/src/components/position_component.dart index 00b4f6474..63b09aad0 100644 --- a/packages/flame/lib/src/components/position_component.dart +++ b/packages/flame/lib/src/components/position_component.dart @@ -163,8 +163,17 @@ class PositionComponent extends Component { /// component as seen from the parent's perspective, and it is equal to /// [size] * [scale]. This is a computed property and cannot be /// modified by the user. - Vector2 get scaledSize => - Vector2(width * scale.x.abs(), height * scale.y.abs()); + Vector2 get scaledSize { + 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() + .fold(angle, (totalAngle, c) => totalAngle + c.angle); + } /// Measure the distance (in parent's coordinate space) between this /// component's anchor and the [other] component's anchor. diff --git a/packages/flame/lib/src/components/shape_component.dart b/packages/flame/lib/src/components/shape_component.dart index a54c9a377..d0d59160b 100644 --- a/packages/flame/lib/src/components/shape_component.dart +++ b/packages/flame/lib/src/components/shape_component.dart @@ -8,96 +8,155 @@ import '../../palette.dart'; import '../anchor.dart'; import '../extensions/vector2.dart'; -class ShapeComponent extends PositionComponent { - final Shape shape; +/// A [ShapeComponent] is a [Shape] wrapped in a [PositionComponent] so that it +/// 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; - /// 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( this.shape, { Paint? paint, + Vector2? position, + Vector2? size, + Vector2? scale, + double? angle, + Anchor? anchor, int? priority, }) : paint = paint ?? BasicPalette.white.paint(), super( - position: shape.position, - size: shape.size, - angle: shape.angle, - anchor: Anchor.center, + position: position, + size: size, + scale: scale, + angle: angle, + anchor: anchor, priority: priority, ) { shape.isCanvasPrepared = true; + addHitbox(shape); } @override void render(Canvas canvas) { shape.render(canvas, paint); } - - @override - bool containsPoint(Vector2 point) => shape.containsPoint(point); } class CircleComponent extends ShapeComponent { - CircleComponent( - double radius, { - Vector2? position, + CircleComponent({ + required double radius, Paint? paint, + Vector2? position, + Vector2? scale, + double? angle, + Anchor? anchor, int? priority, }) : super( - Circle(radius: radius, position: position), + HitboxCircle(), paint: paint, + position: position, + size: Vector2.all(radius * 2), + scale: scale, + angle: angle, + anchor: anchor, priority: priority, ); } class RectangleComponent extends ShapeComponent { - RectangleComponent( - Vector2 size, { - Vector2? position, + RectangleComponent({ + required Vector2 size, Paint? paint, + Vector2? position, + Vector2? scale, + double? angle, + Anchor? anchor, int? priority, }) : super( - Rectangle(size: size, position: position), + HitboxRectangle(), paint: paint, + position: position, + size: size, + scale: scale, + angle: angle, + anchor: anchor, priority: priority, ); - RectangleComponent.square( - double size, { - Vector2? position, + RectangleComponent.square({ + required double size, Paint? paint, + Vector2? position, + Vector2? scale, + double? angle, + Anchor? anchor, int? priority, }) : super( - Rectangle(size: Vector2.all(size), position: position), + HitboxRectangle(), paint: paint, + position: position, + size: Vector2.all(size), + scale: scale, + angle: angle, + anchor: anchor, priority: priority, ); } class PolygonComponent extends ShapeComponent { - PolygonComponent( - List points, { + /// The [normalizedVertices] should be a list of points that range between + /// [-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 normalizedVertices, Paint? paint, - int? priority, - }) : super(Polygon(points), paint: paint, priority: priority); - - PolygonComponent.fromDefinition( - List normalizedVertices, { - Vector2? size, Vector2? position, - Paint? paint, + Vector2? size, + Vector2? scale, + double? angle, + Anchor? anchor, int? priority, }) : super( - Polygon.fromDefinition( - normalizedVertices, - position: position, - size: size, - ), + HitboxPolygon(normalizedVertices), paint: paint, + position: position, + size: size, + scale: scale, + angle: angle, + anchor: anchor, 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 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, + ); + } } diff --git a/packages/flame/lib/src/extensions/vector2.dart b/packages/flame/lib/src/extensions/vector2.dart index 0cfb00c59..009754ef2 100644 --- a/packages/flame/lib/src/extensions/vector2.dart +++ b/packages/flame/lib/src/extensions/vector2.dart @@ -37,6 +37,9 @@ extension Vector2Extension on Vector2 { /// Whether the [Vector2] is the zero vector or not 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 around [center] if it is defined /// 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 /// 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: /// 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. static Vector2 fromDegrees(double d) => fromRadians(d * degrees2Radians); + + /// Creates a new identity [Vector2] (1.0, 1.0). + static Vector2 identity() => Vector2.all(1.0); } diff --git a/packages/flame/lib/src/geometry/circle.dart b/packages/flame/lib/src/geometry/circle.dart index 438c42805..d91742c7a 100644 --- a/packages/flame/lib/src/geometry/circle.dart +++ b/packages/flame/lib/src/geometry/circle.dart @@ -7,9 +7,9 @@ import '../extensions/vector2.dart'; import 'shape.dart'; class Circle extends Shape { - /// The [normalizedRadius] is how many percentages of the shortest edge of - /// [size] that the circle should cover. - double normalizedRadius = 1; + /// The [normalizedRadius] is what ratio (0.0, 1.0] of the shortest edge of + /// [size]/2 that the circle should cover. + double normalizedRadius = 1.0; /// With this constructor you can create your [Circle] from a radius and /// 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] - /// definition is the percentages of the shortest edge of [size] that the - /// circle should fill. + /// [relation] is the relation [0.0, 1.0] of the shortest edge of [size] that + /// the circle should fill. Circle.fromDefinition({ - this.normalizedRadius = 1.0, + double? relation, Vector2? position, Vector2? size, 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 /// circle will look the same rotated as not rotated. @@ -100,9 +110,15 @@ class Circle extends Shape { } class HitboxCircle extends Circle with HitboxShape { - @override - HitboxCircle({double definition = 1}) - : super.fromDefinition( - normalizedRadius: definition, + HitboxCircle({ + double? normalizedRadius, + Vector2? position, + Vector2? size, + double? angle, + }) : super.fromDefinition( + relation: normalizedRadius, + position: position, + size: size, + angle: angle ?? 0, ); } diff --git a/packages/flame/lib/src/geometry/line_segment.dart b/packages/flame/lib/src/geometry/line_segment.dart index 423678998..a472e33e3 100644 --- a/packages/flame/lib/src/geometry/line_segment.dart +++ b/packages/flame/lib/src/geometry/line_segment.dart @@ -9,6 +9,8 @@ class LineSegment { 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 /// If the segments are concurrent, the intersecting point is returned as a /// list with a single point diff --git a/packages/flame/lib/src/geometry/polygon.dart b/packages/flame/lib/src/geometry/polygon.dart index 261e01d19..e58dff05a 100644 --- a/packages/flame/lib/src/geometry/polygon.dart +++ b/packages/flame/lib/src/geometry/polygon.dart @@ -1,20 +1,28 @@ -import 'dart:math'; import 'dart:ui' hide Canvas; import '../../game.dart'; import '../../geometry.dart'; import '../components/cache/value_cache.dart'; import '../extensions/canvas.dart'; +import '../extensions/offset.dart'; import '../extensions/rect.dart'; import '../extensions/vector2.dart'; import 'shape.dart'; class Polygon extends Shape { final List normalizedVertices; - // These lists are used to minimize the amount of [Vector2] objects that are - // created, only change them if the cache is deemed invalid - late final List _sizedVertices; - late final List _hitboxVertices; + // These lists are used to minimize the amount of objects that are created, + // and only change the contained object if the corresponding `ValueCache` is + // deemed outdated. + late final List _localVertices; + late final List _globalVertices; + late final List _renderVertices; + late final List _lineSegments; + final _path = Path(); + + final _cachedLocalVertices = ValueCache>(); + final _cachedGlobalVertices = ValueCache>(); + final _cachedRenderPath = ValueCache(); /// With this constructor you create your [Polygon] from positions in your /// intended space. It will automatically calculate the [size] and center @@ -23,21 +31,19 @@ class Polygon extends Shape { List points, { double angle = 0, }) { - final center = points.fold( - Vector2.zero(), - (sum, v) => sum + v, - ) / - points.length.toDouble(); - final bottomRight = points.fold( - Vector2.zero(), - (bottomRight, v) { - return Vector2( - max(bottomRight.x, v.x), - max(bottomRight.y, v.y), - ); - }, + assert( + points.length > 2, + 'List of points is too short to create a polygon', ); - 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 = points.map((v) => (v - center)..divide(halfSize)).toList(); return Polygon.fromDefinition( @@ -50,9 +56,10 @@ class Polygon extends Shape { /// With this constructor you define the [Polygon] from the center of and with /// 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. - /// 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( this.normalizedVertices, { Vector2? position, @@ -63,51 +70,113 @@ class Polygon extends Shape { size: size, angle: angle ?? 0, ) { - _sizedVertices = - normalizedVertices.map((_) => Vector2.zero()).toList(growable: false); - _hitboxVertices = - normalizedVertices.map((_) => Vector2.zero()).toList(growable: false); - } + List generateList() { + return List.generate( + normalizedVertices.length, + (_) => Vector2.zero(), + growable: false, + ); + } - final _cachedScaledShape = ValueCache>(); + _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 - Iterable scaled() { - if (!_cachedScaledShape.isCacheValid([size])) { - for (var i = 0; i < _sizedVertices.length; i++) { + Iterable localVertices() { + final center = localCenter; + if (!_cachedLocalVertices.isCacheValid([size, center])) { + final halfSize = this.halfSize; + for (var i = 0; i < _localVertices.length; 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(); + /// Gives back the shape vectors multiplied by the size and scale + List 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 void render(Canvas canvas, Paint paint) { - if (!_cachedRenderPath - .isCacheValid([offsetPosition, relativeOffset, size, angle])) { - final center = localCenter; + if (!_cachedRenderPath.isCacheValid([ + offsetPosition, + relativeOffset, + size, + parentAngle, + angle, + ])) { + var i = 0; + (isCanvasPrepared ? localVertices() : globalVertices()).forEach((point) { + _renderVertices[i] = point.toOffset(); + i++; + }); _cachedRenderPath.updateCache( - Path() - ..addPolygon( - scaled().map( - (point) { - final pathPoint = center + point; - if (!isCanvasPrepared) { - pathPoint.rotate(angle, center: center); - } - return pathPoint.toOffset(); - }, - ).toList(), - true, - ), + _path + ..reset() + ..addPolygon(_renderVertices, true), [ offsetPosition.clone(), relativeOffset.clone(), size.clone(), + parentAngle, angle, ], ); @@ -115,32 +184,13 @@ class Polygon extends Shape { canvas.drawPath(_cachedRenderPath.value!, paint); } - final _cachedHitbox = ValueCache>(); - /// Gives back the vertices represented as a list of points which /// are the "corners" of the hitbox rotated with [angle]. - List hitbox() { - // Use cached bounding vertices if state of the component hasn't changed - 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!; - } + /// These are in the global hitbox coordinate space since all hitboxes are + /// compared towards each other. - /// Checks whether the polygon represented by the list of [Vector2] contains - /// the [point]. + /// Checks whether the polygon contains the [point]. + /// Note: The polygon needs to be convex for this to work. @override bool containsPoint(Vector2 point) { // If the size is 0 then it can't contain any points @@ -148,7 +198,7 @@ class Polygon extends Shape { return false; } - final vertices = hitbox(); + final vertices = globalVertices(); for (var i = 0; i < vertices.length; i++) { final edge = getEdge(i, vertices: vertices); 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. List possibleIntersectionVertices(Rect? rect) { final rectIntersections = []; - final vertices = hitbox(); + final vertices = globalVertices(); for (var i = 0; i < vertices.length; i++) { final edge = getEdge(i, vertices: vertices); if (rect?.intersectsSegment(edge.from, edge.to) ?? true) { @@ -177,21 +227,28 @@ class Polygon extends Shape { } LineSegment getEdge(int i, {required List vertices}) { - return LineSegment( - getVertex(i, vertices: vertices), - getVertex( - i + 1, - vertices: vertices, - ), - ); + _lineSegments[i].from.setFrom(getVertex(i, vertices: vertices)); + _lineSegments[i].to.setFrom(getVertex(i + 1, vertices: vertices)); + return _lineSegments[i]; } Vector2 getVertex(int i, {List? vertices}) { - vertices ??= hitbox(); + vertices ??= globalVertices(); return vertices[i % vertices.length]; } + + void _reverseList(List 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 { HitboxPolygon(List definition) : super.fromDefinition(definition); + + factory HitboxPolygon.fromPolygon(Polygon polygon) => + HitboxPolygon(polygon.normalizedVertices); } diff --git a/packages/flame/lib/src/geometry/shape.dart b/packages/flame/lib/src/geometry/shape.dart index f2401a15b..432a42493 100644 --- a/packages/flame/lib/src/geometry/shape.dart +++ b/packages/flame/lib/src/geometry/shape.dart @@ -14,6 +14,10 @@ abstract class Shape { final ValueCache _halfSizeCache = ValueCache(); final ValueCache _localCenterCache = ValueCache(); final ValueCache _absoluteCenterCache = ValueCache(); + final ValueCache _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 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] 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 { if (!_halfSizeCache.isCacheValid([size])) { _halfSizeCache.updateCache(size / 2, [size.clone()]); @@ -41,11 +49,19 @@ abstract class Shape { Vector2 relativeOffset = Vector2.zero(); /// 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 /// applications of [Shape], for example [HitboxShape] - double parentAngle; + double parentAngle = 0; /// Whether the context that the shape is in has already prepared (rotated /// and translated) the canvas before coming to the shape's render method. @@ -103,7 +119,6 @@ abstract class Shape { Vector2? position, Vector2? size, this.angle = 0, - this.parentAngle = 0, }) : position = position ?? Vector2.zero(), size = size ?? Vector2.zero(); @@ -116,25 +131,22 @@ abstract class Shape { Set intersections(Shape 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 { late PositionComponent component; @override - Vector2 get size => component.scaledSize; + bool isCanvasPrepared = true; @override - double get parentAngle => component.angle; + Vector2 get size => component.size; + + @override + Vector2 get scale => component.scale; + + @override + double get parentAngle => component.absoluteAngle; @override Vector2 get position => component.absoluteCenter; diff --git a/packages/flame/test/components/joystick_component_test.dart b/packages/flame/test/components/joystick_component_test.dart index 69e8d963d..7c3862647 100644 --- a/packages/flame/test/components/joystick_component_test.dart +++ b/packages/flame/test/components/joystick_component_test.dart @@ -1,6 +1,5 @@ import 'package:flame/components.dart'; import 'package:flame/game.dart'; -import 'package:flame/geometry.dart'; import 'package:flame/input.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/widgets.dart'; @@ -14,7 +13,7 @@ void main() { group('JoystickDirection tests', () { test('Can convert angle to JoystickDirection', () { final joystick = JoystickComponent( - knob: Circle(radius: 5.0).toComponent(), + knob: CircleComponent(radius: 5.0), size: 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', (game) async { final joystick = JoystickComponent( - knob: Circle(radius: 5.0).toComponent(), + knob: CircleComponent(radius: 5.0), size: 20, margin: const EdgeInsets.only(left: 20, top: 20), ); diff --git a/packages/flame/test/components/shape_component_test.dart b/packages/flame/test/components/shape_component_test.dart new file mode 100644 index 000000000..2fa221b23 --- /dev/null +++ b/packages/flame/test/components/shape_component_test.dart @@ -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, + ); + }, + ); + }); +} diff --git a/packages/flame/test/game/base_game_test.dart b/packages/flame/test/game/base_game_test.dart index c16e896c0..a40fe3ae9 100644 --- a/packages/flame/test/game/base_game_test.dart +++ b/packages/flame/test/game/base_game_test.dart @@ -229,7 +229,7 @@ void main() { const message = '"prepare/add" called before the game is ready. ' '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( () => game.add(component), diff --git a/packages/flame_rive/example/lib/main.dart b/packages/flame_rive/example/lib/main.dart index 8ecb9a5e7..abbb02b27 100644 --- a/packages/flame_rive/example/lib/main.dart +++ b/packages/flame_rive/example/lib/main.dart @@ -77,19 +77,3 @@ class SkillsAnimationComponent extends RiveComponent with Tappable { return true; } } - -class Square extends PositionComponent with HasGameRef { - 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); - } -}