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 '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'),
);
}

View File

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

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

View File

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

View File

@ -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.

View File

@ -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<Vector2> intersectionPoints, Collidable other) {}
}
class ScreenCollidable extends PositionComponent with Hitbox, Collidable {
class ScreenCollidable extends PositionComponent
with Hitbox, Collidable, HasGameRef<BaseGame> {
@override
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());
}
@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;
}
}
}

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

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

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);
}
/// 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),

View File

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

View File

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

View File

@ -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<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
/// 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<Iterable<Vector2>>();
/// Gives back the shape vectors multiplied by the size
Iterable<Vector2> 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<Vector2> 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;
}

View File

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

View File

@ -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<Vector2> _halfSizeCache = ShapeCache();
final ShapeCache<Vector2> _localCenterCache = ShapeCache();
final ShapeCache<Vector2> _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<Vector2> 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]

View File

@ -75,7 +75,7 @@ class CirclePolygonIntersections extends Intersections<Circle, Polygon> {
class CircleCircleIntersections extends Intersections<Circle, Circle> {
@override
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 radiusB = shapeB.radius;
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
// 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<Circle, Circle> {
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 {

View File

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