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
This commit is contained in:
Lukas Klingsbo
2021-05-04 22:31:36 +02:00
committed by GitHub
parent 8a3c71567c
commit 6d17424c13
20 changed files with 525 additions and 133 deletions

View File

@ -4,6 +4,7 @@ import 'package:flame/game.dart';
import '../../commons/commons.dart'; import '../../commons/commons.dart';
import 'circles.dart'; import 'circles.dart';
import 'multiple_shapes.dart'; import 'multiple_shapes.dart';
import 'only_shapes.dart';
void addCollisionDetectionStories(Dashbook dashbook) { void addCollisionDetectionStories(Dashbook dashbook) {
dashbook.storiesOf('Collision Detection') dashbook.storiesOf('Collision Detection')
@ -16,5 +17,10 @@ void addCollisionDetectionStories(Dashbook dashbook) {
'Multiple shapes', 'Multiple shapes',
(_) => GameWidget(game: MultipleShapes()), (_) => GameWidget(game: MultipleShapes()),
codeLink: baseLink('collision_detection/multiple_shapes.dart'), codeLink: baseLink('collision_detection/multiple_shapes.dart'),
)
..add(
'Shapes without components',
(_) => GameWidget(game: OnlyShapes()),
codeLink: baseLink('collision_detection/only_shapes.dart'),
); );
} }

View File

@ -19,9 +19,16 @@ abstract class MyCollidable extends PositionComponent
double angleDelta = 0; double angleDelta = 0;
bool _isDragged = false; bool _isDragged = false;
final _activePaint = Paint()..color = Colors.amber; 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.position = position;
this.size = size; this.size = size;
anchor = Anchor.center; anchor = Anchor.center;
@ -33,51 +40,56 @@ abstract class MyCollidable extends PositionComponent
if (_isDragged) { if (_isDragged) {
return; return;
} }
_wallHitTime += dt; if (!_isHit) {
debugColor = _defaultDebugColor;
} else {
_isHit = false;
}
delta.setFrom(velocity * dt); delta.setFrom(velocity * dt);
position.add(delta); position.add(delta);
angleDelta = dt * rotationSpeed; angleDelta = dt * rotationSpeed;
angle = (angle + angleDelta) % (2 * pi); 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 @override
void render(Canvas canvas) { void render(Canvas canvas) {
super.render(canvas); super.render(canvas);
renderShapes(canvas);
final localCenter = (size / 2).toOffset();
if (_isDragged) { if (_isDragged) {
final localCenter = (size / 2).toOffset();
canvas.drawCircle(localCenter, 5, _activePaint); 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 @override
void onCollision(Set<Vector2> intersectionPoints, Collidable other) { void onCollision(Set<Vector2> intersectionPoints, Collidable other) {
final averageIntersection = intersectionPoints.reduce((sum, v) => sum + v) / _isHit = true;
intersectionPoints.length.toDouble(); switch (other.runtimeType) {
final collisionDirection = (averageIntersection - absoluteCenter) case ScreenCollidable:
..normalize() debugColor = Colors.teal;
..round(); break;
if (velocity.angleToSigned(collisionDirection).abs() > 3) { case CollidablePolygon:
// This entity got hit by something else debugColor = Colors.blue;
return; break;
} case CollidableCircle:
final angleToCollision = velocity.angleToSigned(collisionDirection); debugColor = Colors.green;
if (angleToCollision.abs() < pi / 8) { break;
velocity.rotate(pi); case CollidableRectangle:
} else { debugColor = Colors.cyan;
velocity.rotate(-pi / 2 * angleToCollision.sign); break;
} case CollidableSnowman:
position.sub(delta * 2); debugColor = Colors.amber;
angle = (angle - angleDelta) % (2 * pi); break;
if (other is ScreenCollidable) { default:
_wallHitTime = 0; debugColor = Colors.pink;
} }
} }
@ -96,8 +108,12 @@ abstract class MyCollidable extends PositionComponent
} }
class CollidablePolygon extends MyCollidable { class CollidablePolygon extends MyCollidable {
CollidablePolygon(Vector2 position, Vector2 size, Vector2 velocity) CollidablePolygon(
: super(position, size, velocity) { Vector2 position,
Vector2 size,
Vector2 velocity,
ScreenCollidable screenCollidable,
) : super(position, size, velocity, screenCollidable) {
final shape = HitboxPolygon([ final shape = HitboxPolygon([
Vector2(-1.0, 0.0), Vector2(-1.0, 0.0),
Vector2(-0.8, 0.6), Vector2(-0.8, 0.6),
@ -113,15 +129,23 @@ class CollidablePolygon extends MyCollidable {
} }
class CollidableRectangle extends MyCollidable { class CollidableRectangle extends MyCollidable {
CollidableRectangle(Vector2 position, Vector2 size, Vector2 velocity) CollidableRectangle(
: super(position, size, velocity) { Vector2 position,
Vector2 size,
Vector2 velocity,
ScreenCollidable screenCollidable,
) : super(position, size, velocity, screenCollidable) {
addShape(HitboxRectangle()); addShape(HitboxRectangle());
} }
} }
class CollidableCircle extends MyCollidable { class CollidableCircle extends MyCollidable {
CollidableCircle(Vector2 position, Vector2 size, Vector2 velocity) CollidableCircle(
: super(position, size, velocity) { Vector2 position,
Vector2 size,
Vector2 velocity,
ScreenCollidable screenCollidable,
) : super(position, size, velocity, screenCollidable) {
final shape = HitboxCircle(); final shape = HitboxCircle();
addShape(shape); addShape(shape);
} }
@ -134,9 +158,9 @@ class SnowmanPart extends HitboxCircle {
..strokeWidth = 1 ..strokeWidth = 1
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
SnowmanPart(double definition, Vector2 relativePosition, Color hitColor) SnowmanPart(double definition, Vector2 relativeOffset, Color hitColor)
: super(definition: definition) { : super(definition: definition) {
this.relativePosition.setFrom(relativePosition); this.relativeOffset.setFrom(relativeOffset);
onCollision = (Set<Vector2> intersectionPoints, HitboxShape other) { onCollision = (Set<Vector2> intersectionPoints, HitboxShape other) {
if (other.component is ScreenCollidable) { if (other.component is ScreenCollidable) {
hitPaint..color = startColor; hitPaint..color = startColor;
@ -147,15 +171,20 @@ class SnowmanPart extends HitboxCircle {
} }
@override @override
void render(Canvas canvas, Paint paint) { void render(Canvas canvas, _) {
super.render(canvas, hitPaint); super.render(canvas, hitPaint);
} }
} }
class CollidableSnowman extends MyCollidable { class CollidableSnowman extends MyCollidable {
CollidableSnowman(Vector2 position, Vector2 size, Vector2 velocity) CollidableSnowman(
: super(position, size, velocity) { Vector2 position,
rotationSpeed = 0.2; 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 top = SnowmanPart(0.4, Vector2(0, -0.8), Colors.red);
final middle = SnowmanPart(0.6, Vector2(0, -0.3), Colors.yellow); final middle = SnowmanPart(0.6, Vector2(0, -0.3), Colors.yellow);
final bottom = SnowmanPart(1.0, Vector2(0, 0.5), Colors.green); final bottom = SnowmanPart(1.0, Vector2(0, 0.5), Colors.green);
@ -167,6 +196,9 @@ class CollidableSnowman extends MyCollidable {
class MultipleShapes extends BaseGame class MultipleShapes extends BaseGame
with HasCollidables, HasDraggableComponents { with HasCollidables, HasDraggableComponents {
@override
bool debugMode = true;
final TextPaint fpsTextPaint = TextPaint( final TextPaint fpsTextPaint = TextPaint(
config: TextPaintConfig( config: TextPaintConfig(
color: BasicPalette.white.color, color: BasicPalette.white.color,
@ -175,21 +207,25 @@ class MultipleShapes extends BaseGame
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
final screen = ScreenCollidable(); await super.onLoad();
final screenCollidable = ScreenCollidable();
final snowman = CollidableSnowman( final snowman = CollidableSnowman(
Vector2.all(150), Vector2.all(150),
Vector2(100, 200), Vector2(100, 200),
Vector2(-100, 100), Vector2(-100, 100),
screenCollidable,
); );
MyCollidable lastToAdd = snowman; MyCollidable lastToAdd = snowman;
add(screen); add(screenCollidable);
add(snowman); add(snowman);
var totalAdded = 1; var totalAdded = 1;
while (totalAdded < 20) { while (totalAdded < 10) {
lastToAdd = createRandomCollidable(lastToAdd); lastToAdd = createRandomCollidable(lastToAdd, screenCollidable);
final lastBottomRight = final lastBottomRight =
lastToAdd.toAbsoluteRect().bottomRight.toVector2(); lastToAdd.toAbsoluteRect().bottomRight.toVector2();
if (screen.containsPoint(lastBottomRight)) { final screenSize = size / camera.zoom;
if (lastBottomRight.x < screenSize.x &&
lastBottomRight.y < screenSize.y) {
add(lastToAdd); add(lastToAdd);
totalAdded++; totalAdded++;
} else { } else {
@ -201,7 +237,10 @@ class MultipleShapes extends BaseGame
final _rng = Random(); final _rng = Random();
final _distance = Vector2(100, 0); 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 collidableSize = Vector2.all(50) + Vector2.random(_rng) * 100;
final isXOverflow = lastCollidable.position.x + final isXOverflow = lastCollidable.position.x +
lastCollidable.size.x / 2 + lastCollidable.size.x / 2 +
@ -213,17 +252,18 @@ class MultipleShapes extends BaseGame
position = (lastCollidable.position + _distance) position = (lastCollidable.position + _distance)
..x += collidableSize.x / 2; ..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 rotationSpeed = 0.5 - _rng.nextDouble();
final shapeType = Shapes.values[_rng.nextInt(Shapes.values.length)]; final shapeType = Shapes.values[_rng.nextInt(Shapes.values.length)];
switch (shapeType) { switch (shapeType) {
case Shapes.circle: case Shapes.circle:
return CollidableCircle(position, collidableSize, velocity); return CollidableCircle(position, collidableSize, velocity, screen)
..rotationSpeed = rotationSpeed;
case Shapes.rectangle: case Shapes.rectangle:
return CollidableRectangle(position, collidableSize, velocity) return CollidableRectangle(position, collidableSize, velocity, screen)
..rotationSpeed = rotationSpeed; ..rotationSpeed = rotationSpeed;
case Shapes.polygon: case Shapes.polygon:
return CollidablePolygon(position, collidableSize, velocity) return CollidablePolygon(position, collidableSize, velocity, screen)
..rotationSpeed = rotationSpeed; ..rotationSpeed = rotationSpeed;
} }
} }

View File

@ -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;
}
}

View File

@ -12,6 +12,8 @@
- Abstracting the text api to allow custom text renderers on the framework - 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 - Set the same debug mode for children as for the parent when added
- Fix camera projections when camera is zoomed - 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] ## [1.0.0-rc9]
- Fix input bug with other anchors than center - Fix input bug with other anchors than center

View File

@ -14,6 +14,7 @@ export 'src/components/nine_tile_box_component.dart';
export 'src/components/parallax_component.dart'; export 'src/components/parallax_component.dart';
export 'src/components/particle_component.dart'; export 'src/components/particle_component.dart';
export 'src/components/position_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_component.dart';
export 'src/components/sprite_animation_group_component.dart'; export 'src/components/sprite_animation_group_component.dart';
export 'src/components/sprite_batch_component.dart'; export 'src/components/sprite_batch_component.dart';

View File

@ -55,7 +55,12 @@ class Anchor {
Anchor otherAnchor, Anchor otherAnchor,
Vector2 size, 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. /// Returns a string representation of this Anchor.

View File

@ -1,3 +1,5 @@
import '../../../components.dart';
import '../../../game.dart';
import '../../components/position_component.dart'; import '../../components/position_component.dart';
import '../../extensions/vector2.dart'; import '../../extensions/vector2.dart';
import '../../geometry/rectangle.dart'; import '../../geometry/rectangle.dart';
@ -18,17 +20,33 @@ mixin Collidable on Hitbox {
void onCollision(Set<Vector2> intersectionPoints, Collidable other) {} void onCollision(Set<Vector2> intersectionPoints, Collidable other) {}
} }
class ScreenCollidable extends PositionComponent with Hitbox, Collidable { class ScreenCollidable extends PositionComponent
with Hitbox, Collidable, HasGameRef<BaseGame> {
@override @override
CollidableType collidableType = CollidableType.passive; CollidableType collidableType = CollidableType.passive;
ScreenCollidable() { final Vector2 _effectiveSize = Vector2.zero();
double _zoom = 1.0;
@override
Future<void> onLoad() async {
await super.onLoad();
_updateSize();
addShape(HitboxRectangle()); addShape(HitboxRectangle());
} }
@override @override
void onGameResize(Vector2 gameSize) { void update(double dt) {
super.onGameResize(gameSize); super.update(dt);
size = gameSize; _updateSize();
}
void _updateSize() {
if (_effectiveSize != gameRef.viewport.effectiveSize ||
_zoom != gameRef.camera.zoom) {
_effectiveSize.setFrom(gameRef.viewport.effectiveSize);
_zoom = gameRef.camera.zoom;
size = _effectiveSize / _zoom;
}
} }
} }

View File

@ -34,8 +34,9 @@ mixin Hitbox on PositionComponent {
/// check can be done first to see if it even is possible that the shapes can /// 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. /// overlap, since the shapes have to be within the size of the component.
bool possiblyOverlapping(Hitbox other) { bool possiblyOverlapping(Hitbox other) {
return other.center.distanceToSquared(center) <= final maxDistance = other.size.length + size.length;
other.size.length2 + size.length2; return other.absoluteCenter.distanceToSquared(absoluteCenter) <=
maxDistance * maxDistance;
} }
/// Since this is a cheaper calculation than checking towards all shapes this /// 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 /// contain the point, since the shapes have to be within the size of the
/// component. /// component.
bool possiblyContainsPoint(Vector2 point) { bool possiblyContainsPoint(Vector2 point) {
return center.distanceToSquared(point) <= size.length2; return absoluteCenter.distanceToSquared(point) <= size.length2;
} }
} }

View File

@ -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 { Vector2 get center {
return anchor == Anchor.center if (anchor == Anchor.center) {
? position return position;
: anchor.toOtherAnchorPosition(position, Anchor.center, size); } 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; Vector2 get absoluteCenter => absoluteParentPosition + center;
/// Angle (with respect to the x-axis) this component should be rendered with. /// Angle (with respect to the x-axis) this component should be rendered with.
@ -142,7 +145,7 @@ abstract class PositionComponent extends BaseComponent {
@override @override
bool containsPoint(Vector2 point) { bool containsPoint(Vector2 point) {
final rectangle = Rectangle.fromRect(toAbsoluteRect(), angle: angle) final rectangle = Rectangle.fromRect(toAbsoluteRect(), angle: angle)
..anchorPosition = absolutePosition; ..position = absoluteCenter;
return rectangle.containsPoint(point); return rectangle.containsPoint(point);
} }

View File

@ -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);
}

View File

@ -34,9 +34,19 @@ extension Vector2Extension on Vector2 {
setFrom(this + (to - this) * t); 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 the [Vector2] with [angle] in radians
/// rotates around [center] if it is defined /// rotates around [center] if it is defined
/// In a screen coordinate system (where the y-axis is flipped) it rotates in
/// a clockwise fashion
/// In a normal coordinate system it rotates in a counter-clockwise fashion
void rotate(double angle, {Vector2? center}) { 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) { if (center == null) {
setValues( setValues(
x * cos(angle) - y * sin(angle), x * cos(angle) - y * sin(angle),

View File

@ -120,8 +120,8 @@ class Camera extends Projector {
/// add any non-smooth movement. /// add any non-smooth movement.
Rect? worldBounds; Rect? worldBounds;
/// If set, the camera will zoom by this ratio. This can be greater than 1 (zoom in) /// If set, the camera will zoom by this ratio. This can be greater than 1
/// or smaller (zoom out), but should always be greater than zero. /// (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 /// Note: do not confuse this with the zoom applied by the viewport. The
/// viewport applies a (normally) fixed zoom to adapt multiple screens into /// viewport applies a (normally) fixed zoom to adapt multiple screens into

View File

@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'dart:ui'; import 'dart:ui';
import '../../game.dart';
import '../../geometry.dart'; import '../../geometry.dart';
import '../extensions/vector2.dart'; import '../extensions/vector2.dart';
import 'shape.dart'; import 'shape.dart';
@ -33,23 +34,19 @@ class Circle extends Shape {
double? angle, double? angle,
}) : super(position: position, size: size, angle: angle ?? 0); }) : 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 @override
void render(Canvas canvas, Paint paint) { void render(Canvas canvas, Paint paint) {
final localPosition = size! / 2 + position; canvas.drawCircle(localCenter.toOffset(), radius, paint);
final localRelativePosition = (size! / 2)..multiply(relativePosition);
canvas.drawCircle(
(localPosition + localRelativePosition).toOffset(),
radius,
paint,
);
} }
/// Checks whether the represented circle contains the [point]. /// Checks whether the represented circle contains the [point].
@override @override
bool containsPoint(Vector2 point) { 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 /// 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(); double sq(double x) => pow(x, 2).toDouble();
final cx = shapeCenter.x; final cx = absoluteCenter.x;
final cy = shapeCenter.y; final cy = absoluteCenter.y;
final point1 = line.from; final point1 = line.from;
final point2 = line.to; final point2 = line.to;

View File

@ -1,13 +1,19 @@
import 'dart:math'; import 'dart:math';
import 'dart:ui'; import 'dart:ui' hide Canvas;
import '../../game.dart';
import '../../geometry.dart'; import '../../geometry.dart';
import '../extensions/canvas.dart';
import '../extensions/rect.dart'; import '../extensions/rect.dart';
import '../extensions/vector2.dart'; import '../extensions/vector2.dart';
import 'shape.dart'; import 'shape.dart';
class Polygon extends Shape { class Polygon extends Shape {
final List<Vector2> normalizedVertices; final List<Vector2> normalizedVertices;
// These lists are used to minimize the amount of [Vector2] objects that are
// created, only change them if the cache is deemed invalid
late final List<Vector2> _sizedVertices;
late final List<Vector2> _hitboxVertices;
/// With this constructor you create your [Polygon] from positions in your /// With this constructor you create your [Polygon] from positions in your
/// intended space. It will automatically calculate the [size] and center /// intended space. It will automatically calculate the [size] and center
@ -51,17 +57,27 @@ class Polygon extends Shape {
Vector2? position, Vector2? position,
Vector2? size, Vector2? size,
double? angle, 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<Iterable<Vector2>>(); final _cachedScaledShape = ShapeCache<Iterable<Vector2>>();
/// Gives back the shape vectors multiplied by the size /// Gives back the shape vectors multiplied by the size
Iterable<Vector2> scaled() { Iterable<Vector2> scaled() {
if (!_cachedScaledShape.isCacheValid([size])) { if (!_cachedScaledShape.isCacheValid([size])) {
_cachedScaledShape.updateCache( for (var i = 0; i < _sizedVertices.length; i++) {
normalizedVertices.map((p) => p.clone()..multiply(size! / 2)), final point = normalizedVertices[i];
[size!.clone()], (_sizedVertices[i]..setFrom(point)).multiply(halfSize);
); }
_cachedScaledShape.updateCache(_sizedVertices, [size.clone()]);
} }
return _cachedScaledShape.value!; return _cachedScaledShape.value!;
} }
@ -70,21 +86,25 @@ class Polygon extends Shape {
@override @override
void render(Canvas canvas, Paint paint) { void render(Canvas canvas, Paint paint) {
if (!_cachedRenderPath.isCacheValid([position, size])) { if (!_cachedRenderPath
.isCacheValid([offsetPosition, relativeOffset, size, angle])) {
final center = localCenter;
_cachedRenderPath.updateCache( _cachedRenderPath.updateCache(
Path() Path()
..addPolygon( ..addPolygon(
scaled() scaled()
.map((point) => (point + .map(
(position + size! / 2) + (point) => ((center + point)..rotate(angle, center: center))
((size! / 2)..multiply(relativePosition))) .toOffset(),
.toOffset()) )
.toList(), .toList(),
true, true,
), ),
[ [
position.clone(), offsetPosition.clone(),
size!.clone(), relativeOffset.clone(),
size.clone(),
angle,
], ],
); );
} }
@ -97,13 +117,19 @@ class Polygon extends Shape {
/// are the "corners" of the hitbox rotated with [angle]. /// are the "corners" of the hitbox rotated with [angle].
List<Vector2> hitbox() { List<Vector2> hitbox() {
// Use cached bounding vertices if state of the component hasn't changed // 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( _cachedHitbox.updateCache(
scaled() _hitboxVertices,
.map((point) => [absoluteCenter, size.clone(), parentAngle, angle],
(point + shapeCenter)..rotate(angle, center: anchorPosition))
.toList(growable: false),
[shapeCenter, size!.clone(), angle],
); );
} }
return _cachedHitbox.value!; return _cachedHitbox.value!;
@ -114,7 +140,7 @@ class Polygon extends Shape {
@override @override
bool containsPoint(Vector2 point) { bool containsPoint(Vector2 point) {
// If the size is 0 then it can't contain any points // If the size is 0 then it can't contain any points
if (size!.x == 0 || size!.y == 0) { if (size.x == 0 || size.y == 0) {
return false; return false;
} }

View File

@ -1,6 +1,7 @@
import 'dart:ui'; import 'dart:ui';
import '../../extensions.dart'; import '../../extensions.dart';
import '../../game.dart';
import '../../geometry.dart'; import '../../geometry.dart';
import 'shape.dart'; import 'shape.dart';

View File

@ -1,6 +1,7 @@
import 'dart:ui'; import 'dart:ui';
import '../../components.dart'; import '../../components.dart';
import '../../game.dart';
import '../extensions/vector2.dart'; import '../extensions/vector2.dart';
import 'shape_intersections.dart' as intersection_system; import 'shape_intersections.dart' as intersection_system;
@ -9,34 +10,100 @@ import 'shape_intersections.dart' as intersection_system;
/// center. /// center.
/// A point can be determined to be within of outside of a shape. /// A point can be determined to be within of outside of a shape.
abstract class Shape { abstract class Shape {
/// The position of your shape, it is up to you how you treat this final ShapeCache<Vector2> _halfSizeCache = ShapeCache();
Vector2 position; final ShapeCache<Vector2> _localCenterCache = ShapeCache();
final ShapeCache<Vector2> _absoluteCenterCache = ShapeCache();
/// The position of your shape in relation to its size /// Should be the center of that [offsetPosition] and [relativeOffset]
Vector2 relativePosition = Vector2.zero(); /// 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] /// 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 /// The angle of the shape from its initial definition
double angle; 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; /// The position of your shape in relation to its size from (-1,-1) to (1,1)
Vector2 get anchorPosition => _anchorPosition ?? position; Vector2 relativeOffset = Vector2.zero();
set anchorPosition(Vector2 position) => _anchorPosition = position;
/// 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({ Shape({
Vector2? position, Vector2? position,
this.size, Vector2? size,
this.angle = 0, 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 /// Whether the point [p] is within the shapes boundaries or not
bool containsPoint(Vector2 p); 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 /// Where this Shape has intersection points with another shape
Set<Vector2> intersections(Shape other) { Set<Vector2> intersections(Shape other) {
@ -47,23 +114,14 @@ abstract class Shape {
mixin HitboxShape on Shape { mixin HitboxShape on Shape {
late PositionComponent component; late PositionComponent component;
@override
Vector2 get anchorPosition => component.absolutePosition;
@override @override
Vector2 get size => component.size; Vector2 get size => component.size;
@override @override
double get angle => component.angle; double get parentAngle => component.angle;
/// The shape's absolute center
@override @override
Vector2 get shapeCenter { Vector2 get position => component.absoluteCenter;
return component.absoluteCenter +
position +
((size / 2)..multiply(relativePosition))
..rotate(angle, center: anchorPosition);
}
/// Assign your own [CollisionCallback] if you want a callback when this /// Assign your own [CollisionCallback] if you want a callback when this
/// shape collides with another [HitboxShape] /// shape collides with another [HitboxShape]

View File

@ -75,7 +75,7 @@ class CirclePolygonIntersections extends Intersections<Circle, Polygon> {
class CircleCircleIntersections extends Intersections<Circle, Circle> { class CircleCircleIntersections extends Intersections<Circle, Circle> {
@override @override
Set<Vector2> intersect(Circle shapeA, Circle shapeB) { Set<Vector2> intersect(Circle shapeA, Circle shapeB) {
final distance = shapeA.shapeCenter.distanceTo(shapeB.shapeCenter); final distance = shapeA.absoluteCenter.distanceTo(shapeB.absoluteCenter);
final radiusA = shapeA.radius; final radiusA = shapeA.radius;
final radiusB = shapeB.radius; final radiusB = shapeB.radius;
if (distance > radiusA + radiusB) { if (distance > radiusA + radiusB) {
@ -91,10 +91,10 @@ class CircleCircleIntersections extends Intersections<Circle, Circle> {
// infinite number of solutions. Since it is problematic to return a // infinite number of solutions. Since it is problematic to return a
// set of infinite size, we'll return 4 distinct points here. // set of infinite size, we'll return 4 distinct points here.
return { return {
shapeA.shapeCenter + Vector2(radiusA, 0), shapeA.absoluteCenter + Vector2(radiusA, 0),
shapeA.shapeCenter + Vector2(0, -radiusA), shapeA.absoluteCenter + Vector2(0, -radiusA),
shapeA.shapeCenter + Vector2(-radiusA, 0), shapeA.absoluteCenter + Vector2(-radiusA, 0),
shapeA.shapeCenter + Vector2(0, radiusA), shapeA.absoluteCenter + Vector2(0, radiusA),
}; };
} else { } else {
/// There are definitely collision points if we end up in here. /// There are definitely collision points if we end up in here.
@ -115,14 +115,14 @@ class CircleCircleIntersections extends Intersections<Circle, Circle> {
final lengthA = (pow(radiusA, 2) - pow(radiusB, 2) + pow(distance, 2)) / final lengthA = (pow(radiusA, 2) - pow(radiusB, 2) + pow(distance, 2)) /
(2 * distance); (2 * distance);
final lengthB = sqrt((pow(radiusA, 2) - pow(lengthA, 2)).abs()); final lengthB = sqrt((pow(radiusA, 2) - pow(lengthA, 2)).abs());
final centerPoint = shapeA.shapeCenter + final centerPoint = shapeA.absoluteCenter +
(shapeB.shapeCenter - shapeA.shapeCenter) * lengthA / distance; (shapeB.absoluteCenter - shapeA.absoluteCenter) * lengthA / distance;
final delta = Vector2( final delta = Vector2(
lengthB * lengthB *
(shapeB.shapeCenter.y - shapeA.shapeCenter.y).abs() / (shapeB.absoluteCenter.y - shapeA.absoluteCenter.y).abs() /
distance, distance,
-lengthB * -lengthB *
(shapeB.shapeCenter.x - shapeA.shapeCenter.x).abs() / (shapeB.absoluteCenter.x - shapeA.absoluteCenter.x).abs() /
distance, distance,
); );
return { return {

View File

@ -1,3 +1,4 @@
import 'package:flame/extensions.dart';
import 'package:flame/src/anchor.dart'; import 'package:flame/src/anchor.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -26,5 +27,27 @@ void main() {
throwsA(const TypeMatcher<AssertionError>()), throwsA(const TypeMatcher<AssertionError>()),
); );
}); });
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);
});
}); });
} }

View File

@ -172,5 +172,61 @@ void main() {
final point = Vector2(2.0, 2.0); final point = Vector2(2.0, 2.0);
expect(component.containsPoint(point), false); 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);
});
}); });
} }

View File

@ -3,6 +3,8 @@ import 'dart:math' as math;
import 'package:flame/extensions.dart'; import 'package:flame/extensions.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import '../util/expect_vector2.dart';
void expectDouble(double d1, double d2) { void expectDouble(double d1, double d2) {
expect((d1 - d2).abs() <= 0.0001, true); expect((d1 - d2).abs() <= 0.0001, true);
} }
@ -100,6 +102,7 @@ void main() {
expectDouble(p2.length, math.sqrt(2)); expectDouble(p2.length, math.sqrt(2));
expect(p2.x, p2.y); expect(p2.x, p2.y);
}); });
test('moveToTarget - fully horizontal', () { test('moveToTarget - fully horizontal', () {
final current = Vector2(10.0, 0.0); final current = Vector2(10.0, 0.0);
final target = Vector2(20.0, 0.0); final target = Vector2(20.0, 0.0);
@ -116,6 +119,7 @@ void main() {
current.moveToTarget(target, 5); current.moveToTarget(target, 5);
expect(current, Vector2(20.0, 0.0)); expect(current, Vector2(20.0, 0.0));
}); });
test('moveToTarget - fully vertical', () { test('moveToTarget - fully vertical', () {
final current = Vector2(10.0, 0.0); final current = Vector2(10.0, 0.0);
final target = Vector2(10.0, 100.0); final target = Vector2(10.0, 100.0);
@ -132,6 +136,7 @@ void main() {
current.moveToTarget(target, 19); current.moveToTarget(target, 19);
expect(current, Vector2(10.0, 100.0)); expect(current, Vector2(10.0, 100.0));
}); });
test('moveToTarget - arbitrary direction', () { test('moveToTarget - arbitrary direction', () {
final current = Vector2(2.0, 2.0); final current = Vector2(2.0, 2.0);
final target = Vector2(4.0, 6.0); // direction is 1,2 final target = Vector2(4.0, 6.0); // direction is 1,2
@ -145,5 +150,52 @@ void main() {
current.moveToTarget(target, math.sqrt(5)); current.moveToTarget(target, math.sqrt(5));
expect(current, Vector2(4.0, 6.0)); 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));
});
}); });
} }