From 6d17424c13a6cb210a0186c52c81e86d9f328544 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 4 May 2021 22:31:36 +0200 Subject: [PATCH] Fix collision detection and rendering of local shape angles (#773) * Fix collision detection with anchor other than center * Fix rotation around anchor * Simplify advanced collision detection example * Add some tests * Simplify multiple shapes example more * Move shapeCenter logic into Shape * Render center point * More debugging in MultipleShapes * Wtf. * Re-add "possibly" calculation * Rotate shape around parent center * Only consider the parent center * Format multiple shapes example * Add simple shapes example * Add caching in polygon * Fix rendering of polygon shapes * Remove print * Add changelog entry * Fix analyze complaints * Remove all shapes that contain the pressed point * Take zoom into consideration in multiple shapes example * Remove useless import * map instead of generate * Fix position component test * Simpler negative vector2 * "Correct" format * Add ShapeComponent instead of camera aware shapes * Fix formatting * Remove zoom from collision detection example * No need for gameRef in MultipleShapes example * Fix naming in only_shapes --- .../collision_detection.dart | 6 + .../collision_detection/multiple_shapes.dart | 146 +++++++++++------- .../collision_detection/only_shapes.dart | 63 ++++++++ packages/flame/CHANGELOG.md | 2 + packages/flame/lib/components.dart | 1 + packages/flame/lib/src/anchor.dart | 7 +- .../lib/src/components/mixins/collidable.dart | 28 +++- .../lib/src/components/mixins/hitbox.dart | 7 +- .../src/components/position_component.dart | 15 +- .../lib/src/components/shape_component.dart | 30 ++++ .../flame/lib/src/extensions/vector2.dart | 10 ++ packages/flame/lib/src/game/camera.dart | 4 +- packages/flame/lib/src/geometry/circle.dart | 19 +-- packages/flame/lib/src/geometry/polygon.dart | 66 +++++--- .../flame/lib/src/geometry/rectangle.dart | 1 + packages/flame/lib/src/geometry/shape.dart | 104 ++++++++++--- .../lib/src/geometry/shape_intersections.dart | 18 +-- packages/flame/test/anchor_test.dart | 23 +++ .../components/position_component_test.dart | 56 +++++++ .../flame/test/extensions/vector2_test.dart | 52 +++++++ 20 files changed, 525 insertions(+), 133 deletions(-) create mode 100644 examples/lib/stories/collision_detection/only_shapes.dart create mode 100644 packages/flame/lib/src/components/shape_component.dart diff --git a/examples/lib/stories/collision_detection/collision_detection.dart b/examples/lib/stories/collision_detection/collision_detection.dart index ff8989cb4..d8f45d3fe 100644 --- a/examples/lib/stories/collision_detection/collision_detection.dart +++ b/examples/lib/stories/collision_detection/collision_detection.dart @@ -4,6 +4,7 @@ import 'package:flame/game.dart'; import '../../commons/commons.dart'; import 'circles.dart'; import 'multiple_shapes.dart'; +import 'only_shapes.dart'; void addCollisionDetectionStories(Dashbook dashbook) { dashbook.storiesOf('Collision Detection') @@ -16,5 +17,10 @@ void addCollisionDetectionStories(Dashbook dashbook) { 'Multiple shapes', (_) => GameWidget(game: MultipleShapes()), codeLink: baseLink('collision_detection/multiple_shapes.dart'), + ) + ..add( + 'Shapes without components', + (_) => GameWidget(game: OnlyShapes()), + codeLink: baseLink('collision_detection/only_shapes.dart'), ); } diff --git a/examples/lib/stories/collision_detection/multiple_shapes.dart b/examples/lib/stories/collision_detection/multiple_shapes.dart index 20edf2852..1c49c2f44 100644 --- a/examples/lib/stories/collision_detection/multiple_shapes.dart +++ b/examples/lib/stories/collision_detection/multiple_shapes.dart @@ -19,9 +19,16 @@ abstract class MyCollidable extends PositionComponent double angleDelta = 0; bool _isDragged = false; final _activePaint = Paint()..color = Colors.amber; - double _wallHitTime = double.infinity; + late final Color _defaultDebugColor = debugColor; + bool _isHit = false; + final ScreenCollidable screenCollidable; - MyCollidable(Vector2 position, Vector2 size, this.velocity) { + MyCollidable( + Vector2 position, + Vector2 size, + this.velocity, + this.screenCollidable, + ) { this.position = position; this.size = size; anchor = Anchor.center; @@ -33,51 +40,56 @@ abstract class MyCollidable extends PositionComponent if (_isDragged) { return; } - _wallHitTime += dt; + if (!_isHit) { + debugColor = _defaultDebugColor; + } else { + _isHit = false; + } delta.setFrom(velocity * dt); position.add(delta); angleDelta = dt * rotationSpeed; angle = (angle + angleDelta) % (2 * pi); + // Takes rotation into consideration (which topLeftPosition doesn't) + final topLeft = absoluteCenter - (size / 2); + if (topLeft.x + size.x < 0 || + topLeft.y + size.y < 0 || + topLeft.x > screenCollidable.size.x || + topLeft.y > screenCollidable.size.y) { + final moduloSize = screenCollidable.size + size; + topLeftPosition = topLeftPosition % moduloSize; + } } @override void render(Canvas canvas) { super.render(canvas); - renderShapes(canvas); - final localCenter = (size / 2).toOffset(); if (_isDragged) { + final localCenter = (size / 2).toOffset(); canvas.drawCircle(localCenter, 5, _activePaint); } - if (_wallHitTime < 1.0) { - // Show a rectangle in the center for a second if we hit the wall - canvas.drawRect( - Rect.fromCenter(center: localCenter, width: 10, height: 10), - debugPaint, - ); - } } @override void onCollision(Set intersectionPoints, Collidable other) { - final averageIntersection = intersectionPoints.reduce((sum, v) => sum + v) / - intersectionPoints.length.toDouble(); - final collisionDirection = (averageIntersection - absoluteCenter) - ..normalize() - ..round(); - if (velocity.angleToSigned(collisionDirection).abs() > 3) { - // This entity got hit by something else - return; - } - final angleToCollision = velocity.angleToSigned(collisionDirection); - if (angleToCollision.abs() < pi / 8) { - velocity.rotate(pi); - } else { - velocity.rotate(-pi / 2 * angleToCollision.sign); - } - position.sub(delta * 2); - angle = (angle - angleDelta) % (2 * pi); - if (other is ScreenCollidable) { - _wallHitTime = 0; + _isHit = true; + switch (other.runtimeType) { + case ScreenCollidable: + debugColor = Colors.teal; + break; + case CollidablePolygon: + debugColor = Colors.blue; + break; + case CollidableCircle: + debugColor = Colors.green; + break; + case CollidableRectangle: + debugColor = Colors.cyan; + break; + case CollidableSnowman: + debugColor = Colors.amber; + break; + default: + debugColor = Colors.pink; } } @@ -96,8 +108,12 @@ abstract class MyCollidable extends PositionComponent } class CollidablePolygon extends MyCollidable { - CollidablePolygon(Vector2 position, Vector2 size, Vector2 velocity) - : super(position, size, velocity) { + CollidablePolygon( + Vector2 position, + Vector2 size, + Vector2 velocity, + ScreenCollidable screenCollidable, + ) : super(position, size, velocity, screenCollidable) { final shape = HitboxPolygon([ Vector2(-1.0, 0.0), Vector2(-0.8, 0.6), @@ -113,15 +129,23 @@ class CollidablePolygon extends MyCollidable { } class CollidableRectangle extends MyCollidable { - CollidableRectangle(Vector2 position, Vector2 size, Vector2 velocity) - : super(position, size, velocity) { + CollidableRectangle( + Vector2 position, + Vector2 size, + Vector2 velocity, + ScreenCollidable screenCollidable, + ) : super(position, size, velocity, screenCollidable) { addShape(HitboxRectangle()); } } class CollidableCircle extends MyCollidable { - CollidableCircle(Vector2 position, Vector2 size, Vector2 velocity) - : super(position, size, velocity) { + CollidableCircle( + Vector2 position, + Vector2 size, + Vector2 velocity, + ScreenCollidable screenCollidable, + ) : super(position, size, velocity, screenCollidable) { final shape = HitboxCircle(); addShape(shape); } @@ -134,9 +158,9 @@ class SnowmanPart extends HitboxCircle { ..strokeWidth = 1 ..style = PaintingStyle.stroke; - SnowmanPart(double definition, Vector2 relativePosition, Color hitColor) + SnowmanPart(double definition, Vector2 relativeOffset, Color hitColor) : super(definition: definition) { - this.relativePosition.setFrom(relativePosition); + this.relativeOffset.setFrom(relativeOffset); onCollision = (Set intersectionPoints, HitboxShape other) { if (other.component is ScreenCollidable) { hitPaint..color = startColor; @@ -147,15 +171,20 @@ class SnowmanPart extends HitboxCircle { } @override - void render(Canvas canvas, Paint paint) { + void render(Canvas canvas, _) { super.render(canvas, hitPaint); } } class CollidableSnowman extends MyCollidable { - CollidableSnowman(Vector2 position, Vector2 size, Vector2 velocity) - : super(position, size, velocity) { - rotationSpeed = 0.2; + CollidableSnowman( + Vector2 position, + Vector2 size, + Vector2 velocity, + ScreenCollidable screenCollidable, + ) : super(position, size, velocity, screenCollidable) { + rotationSpeed = 0.3; + anchor = Anchor.topLeft; final top = SnowmanPart(0.4, Vector2(0, -0.8), Colors.red); final middle = SnowmanPart(0.6, Vector2(0, -0.3), Colors.yellow); final bottom = SnowmanPart(1.0, Vector2(0, 0.5), Colors.green); @@ -167,6 +196,9 @@ class CollidableSnowman extends MyCollidable { class MultipleShapes extends BaseGame with HasCollidables, HasDraggableComponents { + @override + bool debugMode = true; + final TextPaint fpsTextPaint = TextPaint( config: TextPaintConfig( color: BasicPalette.white.color, @@ -175,21 +207,25 @@ class MultipleShapes extends BaseGame @override Future onLoad() async { - final screen = ScreenCollidable(); + await super.onLoad(); + final screenCollidable = ScreenCollidable(); final snowman = CollidableSnowman( Vector2.all(150), Vector2(100, 200), Vector2(-100, 100), + screenCollidable, ); MyCollidable lastToAdd = snowman; - add(screen); + add(screenCollidable); add(snowman); var totalAdded = 1; - while (totalAdded < 20) { - lastToAdd = createRandomCollidable(lastToAdd); + while (totalAdded < 10) { + lastToAdd = createRandomCollidable(lastToAdd, screenCollidable); final lastBottomRight = lastToAdd.toAbsoluteRect().bottomRight.toVector2(); - if (screen.containsPoint(lastBottomRight)) { + final screenSize = size / camera.zoom; + if (lastBottomRight.x < screenSize.x && + lastBottomRight.y < screenSize.y) { add(lastToAdd); totalAdded++; } else { @@ -201,7 +237,10 @@ class MultipleShapes extends BaseGame final _rng = Random(); final _distance = Vector2(100, 0); - MyCollidable createRandomCollidable(MyCollidable lastCollidable) { + MyCollidable createRandomCollidable( + MyCollidable lastCollidable, + ScreenCollidable screen, + ) { final collidableSize = Vector2.all(50) + Vector2.random(_rng) * 100; final isXOverflow = lastCollidable.position.x + lastCollidable.size.x / 2 + @@ -213,17 +252,18 @@ class MultipleShapes extends BaseGame position = (lastCollidable.position + _distance) ..x += collidableSize.x / 2; } - final velocity = Vector2.random(_rng) * 200; + final velocity = (Vector2.random(_rng) - Vector2.random(_rng)) * 400; final rotationSpeed = 0.5 - _rng.nextDouble(); final shapeType = Shapes.values[_rng.nextInt(Shapes.values.length)]; switch (shapeType) { case Shapes.circle: - return CollidableCircle(position, collidableSize, velocity); + return CollidableCircle(position, collidableSize, velocity, screen) + ..rotationSpeed = rotationSpeed; case Shapes.rectangle: - return CollidableRectangle(position, collidableSize, velocity) + return CollidableRectangle(position, collidableSize, velocity, screen) ..rotationSpeed = rotationSpeed; case Shapes.polygon: - return CollidablePolygon(position, collidableSize, velocity) + return CollidablePolygon(position, collidableSize, velocity, screen) ..rotationSpeed = rotationSpeed; } } diff --git a/examples/lib/stories/collision_detection/only_shapes.dart b/examples/lib/stories/collision_detection/only_shapes.dart new file mode 100644 index 000000000..389e70059 --- /dev/null +++ b/examples/lib/stories/collision_detection/only_shapes.dart @@ -0,0 +1,63 @@ +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/gestures.dart'; +import 'package:flame/palette.dart'; +import 'package:flutter/material.dart' hide Image, Draggable; + +enum Shapes { circle, rectangle, polygon } + +class OnlyShapes extends BaseGame with HasTapableComponents { + 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 event) { + super.onTapDown(pointerId, event); + final tapDownPoint = event.eventPosition.game; + final component = MyShapeComponent(randomShape(tapDownPoint), shapePaint); + add(component); + } +} + +class MyShapeComponent extends ShapeComponent with Tapable { + MyShapeComponent(Shape shape, Paint shapePaint) : super(shape, shapePaint); + + @override + bool onTapDown(TapDownInfo event) { + remove(); + return true; + } +} diff --git a/packages/flame/CHANGELOG.md b/packages/flame/CHANGELOG.md index 3e8cf0272..ffbf9b92f 100644 --- a/packages/flame/CHANGELOG.md +++ b/packages/flame/CHANGELOG.md @@ -12,6 +12,8 @@ - Abstracting the text api to allow custom text renderers on the framework - Set the same debug mode for children as for the parent when added - Fix camera projections when camera is zoomed + - Fix collision detection system with angle and parentAngle + - Fix rendering of shapes that aren't HitboxShape ## [1.0.0-rc9] - Fix input bug with other anchors than center diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index 97153bb86..361456028 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -14,6 +14,7 @@ export 'src/components/nine_tile_box_component.dart'; export 'src/components/parallax_component.dart'; export 'src/components/particle_component.dart'; export 'src/components/position_component.dart'; +export 'src/components/shape_component.dart'; export 'src/components/sprite_animation_component.dart'; export 'src/components/sprite_animation_group_component.dart'; export 'src/components/sprite_batch_component.dart'; diff --git a/packages/flame/lib/src/anchor.dart b/packages/flame/lib/src/anchor.dart index bef1e3775..075e7ee5a 100644 --- a/packages/flame/lib/src/anchor.dart +++ b/packages/flame/lib/src/anchor.dart @@ -55,7 +55,12 @@ class Anchor { Anchor otherAnchor, Vector2 size, ) { - return position + ((otherAnchor.toVector2() - toVector2())..multiply(size)); + if (this == otherAnchor) { + return position; + } else { + return position + + ((otherAnchor.toVector2() - toVector2())..multiply(size)); + } } /// Returns a string representation of this Anchor. diff --git a/packages/flame/lib/src/components/mixins/collidable.dart b/packages/flame/lib/src/components/mixins/collidable.dart index de4b4dfa7..9f6b2f454 100644 --- a/packages/flame/lib/src/components/mixins/collidable.dart +++ b/packages/flame/lib/src/components/mixins/collidable.dart @@ -1,3 +1,5 @@ +import '../../../components.dart'; +import '../../../game.dart'; import '../../components/position_component.dart'; import '../../extensions/vector2.dart'; import '../../geometry/rectangle.dart'; @@ -18,17 +20,33 @@ mixin Collidable on Hitbox { void onCollision(Set intersectionPoints, Collidable other) {} } -class ScreenCollidable extends PositionComponent with Hitbox, Collidable { +class ScreenCollidable extends PositionComponent + with Hitbox, Collidable, HasGameRef { @override CollidableType collidableType = CollidableType.passive; - ScreenCollidable() { + final Vector2 _effectiveSize = Vector2.zero(); + double _zoom = 1.0; + + @override + Future onLoad() async { + await super.onLoad(); + _updateSize(); addShape(HitboxRectangle()); } @override - void onGameResize(Vector2 gameSize) { - super.onGameResize(gameSize); - size = gameSize; + void update(double dt) { + super.update(dt); + _updateSize(); + } + + void _updateSize() { + if (_effectiveSize != gameRef.viewport.effectiveSize || + _zoom != gameRef.camera.zoom) { + _effectiveSize.setFrom(gameRef.viewport.effectiveSize); + _zoom = gameRef.camera.zoom; + size = _effectiveSize / _zoom; + } } } diff --git a/packages/flame/lib/src/components/mixins/hitbox.dart b/packages/flame/lib/src/components/mixins/hitbox.dart index c223e2085..c5ef3dc9b 100644 --- a/packages/flame/lib/src/components/mixins/hitbox.dart +++ b/packages/flame/lib/src/components/mixins/hitbox.dart @@ -34,8 +34,9 @@ mixin Hitbox on PositionComponent { /// check can be done first to see if it even is possible that the shapes can /// overlap, since the shapes have to be within the size of the component. bool possiblyOverlapping(Hitbox other) { - return other.center.distanceToSquared(center) <= - other.size.length2 + size.length2; + final maxDistance = other.size.length + size.length; + return other.absoluteCenter.distanceToSquared(absoluteCenter) <= + maxDistance * maxDistance; } /// Since this is a cheaper calculation than checking towards all shapes this @@ -43,6 +44,6 @@ mixin Hitbox on PositionComponent { /// contain the point, since the shapes have to be within the size of the /// component. bool possiblyContainsPoint(Vector2 point) { - return center.distanceToSquared(point) <= size.length2; + return absoluteCenter.distanceToSquared(point) <= size.length2; } } diff --git a/packages/flame/lib/src/components/position_component.dart b/packages/flame/lib/src/components/position_component.dart index d393abfa0..6f7c617ce 100644 --- a/packages/flame/lib/src/components/position_component.dart +++ b/packages/flame/lib/src/components/position_component.dart @@ -88,14 +88,17 @@ abstract class PositionComponent extends BaseComponent { } } - /// Get the position of the center of the component's bounding rectangle without rotation + /// Get the position of the center of the component's bounding rectangle Vector2 get center { - return anchor == Anchor.center - ? position - : anchor.toOtherAnchorPosition(position, Anchor.center, size); + if (anchor == Anchor.center) { + return position; + } else { + return anchor.toOtherAnchorPosition(position, Anchor.center, size) + ..rotate(angle, center: absolutePosition); + } } - /// Get the absolute center of the component without rotation + /// Get the absolute center of the component Vector2 get absoluteCenter => absoluteParentPosition + center; /// Angle (with respect to the x-axis) this component should be rendered with. @@ -142,7 +145,7 @@ abstract class PositionComponent extends BaseComponent { @override bool containsPoint(Vector2 point) { final rectangle = Rectangle.fromRect(toAbsoluteRect(), angle: angle) - ..anchorPosition = absolutePosition; + ..position = absoluteCenter; return rectangle.containsPoint(point); } diff --git a/packages/flame/lib/src/components/shape_component.dart b/packages/flame/lib/src/components/shape_component.dart new file mode 100644 index 000000000..32039a155 --- /dev/null +++ b/packages/flame/lib/src/components/shape_component.dart @@ -0,0 +1,30 @@ +import 'dart:ui' hide Offset; + +import '../../components.dart'; +import '../../geometry.dart'; +import '../anchor.dart'; +import '../extensions/vector2.dart'; + +class ShapeComponent extends PositionComponent { + final Shape shape; + final Paint shapePaint; + + ShapeComponent( + this.shape, + this.shapePaint, + ) : super( + position: shape.position, + size: shape.size, + angle: shape.angle, + anchor: Anchor.center, + ); + + @override + void render(Canvas canvas) { + super.render(canvas); + shape.render(canvas, shapePaint); + } + + @override + bool containsPoint(Vector2 point) => shape.containsPoint(point); +} diff --git a/packages/flame/lib/src/extensions/vector2.dart b/packages/flame/lib/src/extensions/vector2.dart index a2394bc35..be5c88c96 100644 --- a/packages/flame/lib/src/extensions/vector2.dart +++ b/packages/flame/lib/src/extensions/vector2.dart @@ -34,9 +34,19 @@ extension Vector2Extension on Vector2 { setFrom(this + (to - this) * t); } + /// Whether the [Vector2] is the zero vector or not + bool isZero() => x == 0 && y == 0; + /// 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 + /// a clockwise fashion + /// In a normal coordinate system it rotates in a counter-clockwise fashion void rotate(double angle, {Vector2? center}) { + if (isZero() || angle == 0) { + // No point in rotating the zero vector or to rotate with 0 as angle + return; + } if (center == null) { setValues( x * cos(angle) - y * sin(angle), diff --git a/packages/flame/lib/src/game/camera.dart b/packages/flame/lib/src/game/camera.dart index 9cfaf8129..5bd9bac2d 100644 --- a/packages/flame/lib/src/game/camera.dart +++ b/packages/flame/lib/src/game/camera.dart @@ -120,8 +120,8 @@ class Camera extends Projector { /// add any non-smooth movement. Rect? worldBounds; - /// If set, the camera will zoom by this ratio. This can be greater than 1 (zoom in) - /// or smaller (zoom out), but should always be greater than zero. + /// If set, the camera will zoom by this ratio. This can be greater than 1 + /// (zoom in) or smaller (zoom out), but should always be greater than zero. /// /// Note: do not confuse this with the zoom applied by the viewport. The /// viewport applies a (normally) fixed zoom to adapt multiple screens into diff --git a/packages/flame/lib/src/geometry/circle.dart b/packages/flame/lib/src/geometry/circle.dart index 0a74c5875..438c42805 100644 --- a/packages/flame/lib/src/geometry/circle.dart +++ b/packages/flame/lib/src/geometry/circle.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'dart:ui'; +import '../../game.dart'; import '../../geometry.dart'; import '../extensions/vector2.dart'; import 'shape.dart'; @@ -33,23 +34,19 @@ class Circle extends Shape { double? angle, }) : super(position: position, size: size, angle: angle ?? 0); - double get radius => (min(size!.x, size!.y) / 2) * normalizedRadius; + double get radius => (min(size.x, size.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. @override void render(Canvas canvas, Paint paint) { - final localPosition = size! / 2 + position; - final localRelativePosition = (size! / 2)..multiply(relativePosition); - canvas.drawCircle( - (localPosition + localRelativePosition).toOffset(), - radius, - paint, - ); + canvas.drawCircle(localCenter.toOffset(), radius, paint); } /// Checks whether the represented circle contains the [point]. @override bool containsPoint(Vector2 point) { - return shapeCenter.distanceToSquared(point) < radius * radius; + return absoluteCenter.distanceToSquared(point) < radius * radius; } /// Returns the locus of points in which the provided line segment intersect @@ -63,8 +60,8 @@ class Circle extends Shape { }) { double sq(double x) => pow(x, 2).toDouble(); - final cx = shapeCenter.x; - final cy = shapeCenter.y; + final cx = absoluteCenter.x; + final cy = absoluteCenter.y; final point1 = line.from; final point2 = line.to; diff --git a/packages/flame/lib/src/geometry/polygon.dart b/packages/flame/lib/src/geometry/polygon.dart index 2ef4d0eec..ea1367980 100644 --- a/packages/flame/lib/src/geometry/polygon.dart +++ b/packages/flame/lib/src/geometry/polygon.dart @@ -1,13 +1,19 @@ import 'dart:math'; -import 'dart:ui'; +import 'dart:ui' hide Canvas; +import '../../game.dart'; import '../../geometry.dart'; +import '../extensions/canvas.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; /// With this constructor you create your [Polygon] from positions in your /// intended space. It will automatically calculate the [size] and center @@ -51,17 +57,27 @@ class Polygon extends Shape { Vector2? position, Vector2? size, double? angle, - }) : super(position: position, size: size, angle: angle ?? 0); + }) : super( + position: position, + size: size, + angle: angle ?? 0, + ) { + _sizedVertices = + normalizedVertices.map((_) => Vector2.zero()).toList(growable: false); + _hitboxVertices = + normalizedVertices.map((_) => Vector2.zero()).toList(growable: false); + } final _cachedScaledShape = ShapeCache>(); /// Gives back the shape vectors multiplied by the size Iterable scaled() { if (!_cachedScaledShape.isCacheValid([size])) { - _cachedScaledShape.updateCache( - normalizedVertices.map((p) => p.clone()..multiply(size! / 2)), - [size!.clone()], - ); + for (var i = 0; i < _sizedVertices.length; i++) { + final point = normalizedVertices[i]; + (_sizedVertices[i]..setFrom(point)).multiply(halfSize); + } + _cachedScaledShape.updateCache(_sizedVertices, [size.clone()]); } return _cachedScaledShape.value!; } @@ -70,21 +86,25 @@ class Polygon extends Shape { @override void render(Canvas canvas, Paint paint) { - if (!_cachedRenderPath.isCacheValid([position, size])) { + if (!_cachedRenderPath + .isCacheValid([offsetPosition, relativeOffset, size, angle])) { + final center = localCenter; _cachedRenderPath.updateCache( Path() ..addPolygon( scaled() - .map((point) => (point + - (position + size! / 2) + - ((size! / 2)..multiply(relativePosition))) - .toOffset()) + .map( + (point) => ((center + point)..rotate(angle, center: center)) + .toOffset(), + ) .toList(), true, ), [ - position.clone(), - size!.clone(), + offsetPosition.clone(), + relativeOffset.clone(), + size.clone(), + angle, ], ); } @@ -97,13 +117,19 @@ class Polygon extends Shape { /// 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([shapeCenter, size, angle])) { + 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( - scaled() - .map((point) => - (point + shapeCenter)..rotate(angle, center: anchorPosition)) - .toList(growable: false), - [shapeCenter, size!.clone(), angle], + _hitboxVertices, + [absoluteCenter, size.clone(), parentAngle, angle], ); } return _cachedHitbox.value!; @@ -114,7 +140,7 @@ class Polygon extends Shape { @override bool containsPoint(Vector2 point) { // If the size is 0 then it can't contain any points - if (size!.x == 0 || size!.y == 0) { + if (size.x == 0 || size.y == 0) { return false; } diff --git a/packages/flame/lib/src/geometry/rectangle.dart b/packages/flame/lib/src/geometry/rectangle.dart index 15aebf485..40f5e49dc 100644 --- a/packages/flame/lib/src/geometry/rectangle.dart +++ b/packages/flame/lib/src/geometry/rectangle.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import '../../extensions.dart'; +import '../../game.dart'; import '../../geometry.dart'; import 'shape.dart'; diff --git a/packages/flame/lib/src/geometry/shape.dart b/packages/flame/lib/src/geometry/shape.dart index e1ff814a8..ba60f1239 100644 --- a/packages/flame/lib/src/geometry/shape.dart +++ b/packages/flame/lib/src/geometry/shape.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import '../../components.dart'; +import '../../game.dart'; import '../extensions/vector2.dart'; import 'shape_intersections.dart' as intersection_system; @@ -9,34 +10,100 @@ import 'shape_intersections.dart' as intersection_system; /// center. /// A point can be determined to be within of outside of a shape. abstract class Shape { - /// The position of your shape, it is up to you how you treat this - Vector2 position; + final ShapeCache _halfSizeCache = ShapeCache(); + final ShapeCache _localCenterCache = ShapeCache(); + final ShapeCache _absoluteCenterCache = ShapeCache(); - /// The position of your shape in relation to its size - Vector2 relativePosition = Vector2.zero(); + /// Should be the center of that [offsetPosition] and [relativeOffset] + /// should be calculated from, if they are not set this is the center of the + /// shape + Vector2 position = Vector2.zero(); /// The size is the bounding box of the [Shape] - Vector2? size; + Vector2 size; + + Vector2 get halfSize { + if (!_halfSizeCache.isCacheValid([size])) { + _halfSizeCache.updateCache(size / 2, [size.clone()]); + } + return _halfSizeCache.value!; + } /// The angle of the shape from its initial definition double angle; - Vector2 get shapeCenter => position; + /// The local position of your shape, so the diff from the [position] of the + /// shape + Vector2 offsetPosition = Vector2.zero(); - Vector2? _anchorPosition; - Vector2 get anchorPosition => _anchorPosition ?? position; - set anchorPosition(Vector2 position) => _anchorPosition = position; + /// The position of your shape in relation to its size from (-1,-1) to (1,1) + Vector2 relativeOffset = Vector2.zero(); + + /// The [relativeOffset] converted to a length vector + Vector2 get relativePosition => (size / 2)..multiply(relativeOffset); + + /// The angle of the parent that has to be taken into consideration for some + /// applications of [Shape], for example [HitboxShape] + double parentAngle; + + /// The center position of the shape within itself, without rotation + Vector2 get localCenter { + final stateValues = [ + size, + relativeOffset, + offsetPosition, + ]; + if (!_localCenterCache.isCacheValid(stateValues)) { + final center = (size / 2)..add(relativePosition)..add(offsetPosition); + _localCenterCache.updateCache( + center, + stateValues.map((e) => e.clone()).toList(growable: false), + ); + } + return _localCenterCache.value!; + } + + /// The shape's absolute center with rotation taken into account + Vector2 get absoluteCenter { + final stateValues = [ + position, + offsetPosition, + relativeOffset, + angle, + parentAngle, + ]; + if (!_absoluteCenterCache.isCacheValid(stateValues)) { + /// The center of the shape, before any rotation + final center = position + offsetPosition; + if (!relativeOffset.isZero()) { + center.add(relativePosition); + } + if (angle != 0 || parentAngle != 0) { + center.rotate(parentAngle + angle, center: position); + } + _absoluteCenterCache.updateCache(center, [ + position.clone(), + offsetPosition.clone(), + relativeOffset.clone(), + angle, + parentAngle, + ]); + } + return _absoluteCenterCache.value!; + } Shape({ Vector2? position, - this.size, + Vector2? size, this.angle = 0, - }) : position = position ?? Vector2.zero(); + this.parentAngle = 0, + }) : position = position ?? Vector2.zero(), + size = size ?? Vector2.zero(); /// Whether the point [p] is within the shapes boundaries or not bool containsPoint(Vector2 p); - void render(Canvas c, Paint paint); + void render(Canvas canvas, Paint paint); /// Where this Shape has intersection points with another shape Set intersections(Shape other) { @@ -47,23 +114,14 @@ abstract class Shape { mixin HitboxShape on Shape { late PositionComponent component; - @override - Vector2 get anchorPosition => component.absolutePosition; - @override Vector2 get size => component.size; @override - double get angle => component.angle; + double get parentAngle => component.angle; - /// The shape's absolute center @override - Vector2 get shapeCenter { - return component.absoluteCenter + - position + - ((size / 2)..multiply(relativePosition)) - ..rotate(angle, center: anchorPosition); - } + Vector2 get position => component.absoluteCenter; /// Assign your own [CollisionCallback] if you want a callback when this /// shape collides with another [HitboxShape] diff --git a/packages/flame/lib/src/geometry/shape_intersections.dart b/packages/flame/lib/src/geometry/shape_intersections.dart index 022448a76..c6940a791 100644 --- a/packages/flame/lib/src/geometry/shape_intersections.dart +++ b/packages/flame/lib/src/geometry/shape_intersections.dart @@ -75,7 +75,7 @@ class CirclePolygonIntersections extends Intersections { class CircleCircleIntersections extends Intersections { @override Set intersect(Circle shapeA, Circle shapeB) { - final distance = shapeA.shapeCenter.distanceTo(shapeB.shapeCenter); + final distance = shapeA.absoluteCenter.distanceTo(shapeB.absoluteCenter); final radiusA = shapeA.radius; final radiusB = shapeB.radius; if (distance > radiusA + radiusB) { @@ -91,10 +91,10 @@ class CircleCircleIntersections extends Intersections { // infinite number of solutions. Since it is problematic to return a // set of infinite size, we'll return 4 distinct points here. return { - shapeA.shapeCenter + Vector2(radiusA, 0), - shapeA.shapeCenter + Vector2(0, -radiusA), - shapeA.shapeCenter + Vector2(-radiusA, 0), - shapeA.shapeCenter + Vector2(0, radiusA), + shapeA.absoluteCenter + Vector2(radiusA, 0), + shapeA.absoluteCenter + Vector2(0, -radiusA), + shapeA.absoluteCenter + Vector2(-radiusA, 0), + shapeA.absoluteCenter + Vector2(0, radiusA), }; } else { /// There are definitely collision points if we end up in here. @@ -115,14 +115,14 @@ class CircleCircleIntersections extends Intersections { final lengthA = (pow(radiusA, 2) - pow(radiusB, 2) + pow(distance, 2)) / (2 * distance); final lengthB = sqrt((pow(radiusA, 2) - pow(lengthA, 2)).abs()); - final centerPoint = shapeA.shapeCenter + - (shapeB.shapeCenter - shapeA.shapeCenter) * lengthA / distance; + final centerPoint = shapeA.absoluteCenter + + (shapeB.absoluteCenter - shapeA.absoluteCenter) * lengthA / distance; final delta = Vector2( lengthB * - (shapeB.shapeCenter.y - shapeA.shapeCenter.y).abs() / + (shapeB.absoluteCenter.y - shapeA.absoluteCenter.y).abs() / distance, -lengthB * - (shapeB.shapeCenter.x - shapeA.shapeCenter.x).abs() / + (shapeB.absoluteCenter.x - shapeA.absoluteCenter.x).abs() / distance, ); return { diff --git a/packages/flame/test/anchor_test.dart b/packages/flame/test/anchor_test.dart index 94c9f5dac..27e57ac71 100644 --- a/packages/flame/test/anchor_test.dart +++ b/packages/flame/test/anchor_test.dart @@ -1,3 +1,4 @@ +import 'package:flame/extensions.dart'; import 'package:flame/src/anchor.dart'; import 'package:test/test.dart'; @@ -26,5 +27,27 @@ void main() { throwsA(const TypeMatcher()), ); }); + + test('can convert topLeft anchor to another anchor positions', () { + final position = Vector2(3, 1); + final size = Vector2(2, 3); + final center = Anchor.topLeft.toOtherAnchorPosition( + position, + Anchor.center, + size, + ); + expect(center, position + size / 2); + }); + + test('can convert center anchor to another anchor positions', () { + final position = Vector2(3, 1); + final size = Vector2(2, 3); + final topLeft = Anchor.center.toOtherAnchorPosition( + position, + Anchor.topLeft, + size, + ); + expect(topLeft, position - size / 2); + }); }); } diff --git a/packages/flame/test/components/position_component_test.dart b/packages/flame/test/components/position_component_test.dart index 31d6c1cd6..6e3c6b3d4 100644 --- a/packages/flame/test/components/position_component_test.dart +++ b/packages/flame/test/components/position_component_test.dart @@ -172,5 +172,61 @@ void main() { final point = Vector2(2.0, 2.0); expect(component.containsPoint(point), false); }); + + test('component with zero size does not contain point', () { + final PositionComponent component = MyComponent(); + component.position.setValues(2.0, 2.0); + component.size.setValues(0.0, 0.0); + component.angle = 0.0; + component.anchor = Anchor.center; + + final point = Vector2(2.0, 2.0); + expect(component.containsPoint(point), false); + }); + + test('component with anchor center has the same center and position', () { + final PositionComponent component = MyComponent(); + component.position.setValues(2.0, 1.0); + component.size.setValues(3.0, 1.0); + component.angle = 2.0; + component.anchor = Anchor.center; + + expect(component.center, component.position); + expect(component.absoluteCenter, component.position); + expect( + component.topLeftPosition, + component.position - component.size / 2, + ); + }); + + test('component with anchor topLeft has the correct center', () { + final PositionComponent component = MyComponent(); + component.position.setValues(2.0, 1.0); + component.size.setValues(3.0, 1.0); + component.angle = 0.0; + component.anchor = Anchor.topLeft; + + expect(component.center, component.position + component.size / 2); + expect(component.absoluteCenter, component.position + component.size / 2); + }); + + test('component with parent has the correct center', () { + final PositionComponent parent = MyComponent(); + parent.position.setValues(2.0, 1.0); + parent.anchor = Anchor.topLeft; + final PositionComponent child = MyComponent(); + child.position.setValues(2.0, 1.0); + child.size.setValues(3.0, 1.0); + child.angle = 0.0; + child.anchor = Anchor.topLeft; + parent.addChild(child); + + expect(child.absoluteTopLeftPosition, child.position + parent.position); + expect( + child.absoluteTopLeftPosition, + child.topLeftPosition + parent.topLeftPosition, + ); + expect(child.absoluteCenter, parent.position + child.center); + }); }); } diff --git a/packages/flame/test/extensions/vector2_test.dart b/packages/flame/test/extensions/vector2_test.dart index 466cf8cae..0060d0262 100644 --- a/packages/flame/test/extensions/vector2_test.dart +++ b/packages/flame/test/extensions/vector2_test.dart @@ -3,6 +3,8 @@ import 'dart:math' as math; import 'package:flame/extensions.dart'; import 'package:test/test.dart'; +import '../util/expect_vector2.dart'; + void expectDouble(double d1, double d2) { expect((d1 - d2).abs() <= 0.0001, true); } @@ -100,6 +102,7 @@ void main() { expectDouble(p2.length, math.sqrt(2)); expect(p2.x, p2.y); }); + test('moveToTarget - fully horizontal', () { final current = Vector2(10.0, 0.0); final target = Vector2(20.0, 0.0); @@ -116,6 +119,7 @@ void main() { current.moveToTarget(target, 5); expect(current, Vector2(20.0, 0.0)); }); + test('moveToTarget - fully vertical', () { final current = Vector2(10.0, 0.0); final target = Vector2(10.0, 100.0); @@ -132,6 +136,7 @@ void main() { current.moveToTarget(target, 19); expect(current, Vector2(10.0, 100.0)); }); + test('moveToTarget - arbitrary direction', () { final current = Vector2(2.0, 2.0); final target = Vector2(4.0, 6.0); // direction is 1,2 @@ -145,5 +150,52 @@ void main() { current.moveToTarget(target, math.sqrt(5)); expect(current, Vector2(4.0, 6.0)); }); + + test('rotate - no center defined', () { + final position = Vector2(0.0, 1.0); + position.rotate(-math.pi / 2); + expectVector2(position, Vector2(1.0, 0.0)); + }); + + test('rotate - no center defined, negative position', () { + final position = Vector2(0.0, -1.0); + position.rotate(-math.pi / 2); + expectVector2(position, Vector2(-1.0, 0.0)); + }); + + test('rotate - with center defined', () { + final position = Vector2(0.0, 1.0); + final center = Vector2(1.0, 1.0); + position.rotate(-math.pi / 2, center: center); + expectVector2(position, Vector2(1.0, 2.0)); + }); + + test('rotate - with positive direction', () { + final position = Vector2(0.0, 1.0); + final center = Vector2(1.0, 1.0); + position.rotate(math.pi / 2, center: center); + expectVector2(position, Vector2(1.0, 0.0)); + }); + + test('rotate - with a negative y position', () { + final position = Vector2(2.0, -3.0); + final center = Vector2(1.0, 1.0); + position.rotate(math.pi / 2, center: center); + expectVector2(position, Vector2(5.0, 2.0)); + }); + + test('rotate - with a negative x position', () { + final position = Vector2(-2.0, 3.0); + final center = Vector2(1.0, 1.0); + position.rotate(math.pi / 2, center: center); + expectVector2(position, Vector2(-1.0, -2.0)); + }); + + test('rotate - with a negative position', () { + final position = Vector2(-2.0, -3.0); + final center = Vector2(1.0, 0.0); + position.rotate(math.pi / 2, center: center); + expectVector2(position, Vector2(4.0, -3.0)); + }); }); }