diff --git a/doc/collision_detection.md b/doc/collision_detection.md index 3af47ff85..86cb603f5 100644 --- a/doc/collision_detection.md +++ b/doc/collision_detection.md @@ -25,6 +25,11 @@ the latter is very useful for accurate gesture detection. The collision detectio what should happen when two hitboxes collide, so it is up to the user to implement what will happen when for example two position components have intersecting hitboxes. +Do note that the built-in collision detection system does not take collisions between two hitboxes +that overshoot each other into account, this could happen when they either move too fast or `update` +being called with a large delta time (for example if your app is not in the foreground). This +behaviour is called tunneling, if you want to read more about it. + ## Mixins ### Hitbox The `Hitbox` mixin is mainly used for two things; to make detection of collisions with other diff --git a/doc/components.md b/doc/components.md index 3f0421d93..c57dc97a6 100644 --- a/doc/components.md +++ b/doc/components.md @@ -59,8 +59,12 @@ Example of usage, where visibility of two components are handled by a wrapper: ```dart class GameOverPanel extends PositionComponent with HasGameRef { bool visible = false; + final Image spriteImage; - GameOverPanel(Image spriteImage) : super() { + GameOverPanel(this.spriteImage); + + @override + Future onLoad() async { final gameOverText = GameOverText(spriteImage); // GameOverText is a Component final gameOverButton = GameOverButton(spriteImage); // GameOverRestart is a SpriteComponent @@ -196,15 +200,15 @@ FlareController that can play multiple animations and control nodes. import 'package:flame_flare/flame_flare.dart'; class YourFlareController extends FlareControls { - - ActorNode rightHandNode; - - void initialize(FlutterActorArtboard artboard) { - super.initialize(artboard); - - // get flare node - rightHand = artboard.getNode('right_hand'); - } + + late ActorNode rightHandNode; + + void initialize(FlutterActorArtboard artboard) { + super.initialize(artboard); + + // get flare node + rightHand = artboard.getNode('right_hand'); + } } final fileName = 'assets/george_washington.flr'; diff --git a/examples/lib/main.dart b/examples/lib/main.dart index ee8b627d0..3b3935fdd 100644 --- a/examples/lib/main.dart +++ b/examples/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flame/flame.dart'; import 'package:flutter/material.dart'; import 'stories/animations/animations.dart'; +import 'stories/collision_detection/collision_detection.dart'; import 'stories/components/components.dart'; import 'stories/controls/controls.dart'; import 'stories/effects/effects.dart'; @@ -15,12 +16,13 @@ import 'stories/widgets/widgets.dart'; void main() async { final dashbook = Dashbook( - title: 'Flame Example', + title: 'Flame Examples', theme: ThemeData.dark(), ); addAnimationStories(dashbook); addComponentsStories(dashbook); + addCollisionDetectionStories(dashbook); addEffectsStories(dashbook); addTileMapStories(dashbook); addControlsStories(dashbook); diff --git a/examples/lib/stories/collision_detection/circles.dart b/examples/lib/stories/collision_detection/circles.dart new file mode 100644 index 000000000..fc9ab9e9d --- /dev/null +++ b/examples/lib/stories/collision_detection/circles.dart @@ -0,0 +1,71 @@ +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:flutter/material.dart' hide Image, Draggable; + +class MyCollidable extends PositionComponent + with HasGameRef, Hitbox, Collidable { + late Vector2 velocity; + final _collisionColor = Colors.amber; + final _defaultColor = Colors.cyan; + bool _isWallHit = false; + bool _isCollision = false; + + MyCollidable(Vector2 position) + : super( + position: position, + size: Vector2.all(100), + anchor: Anchor.center, + ) { + addShape(HitboxCircle()); + } + + @override + Future onLoad() async { + final center = gameRef.size / 2; + velocity = (center - position)..scaleTo(150); + } + + @override + void update(double dt) { + super.update(dt); + if (_isWallHit) { + remove(); + return; + } + debugColor = _isCollision ? _collisionColor : _defaultColor; + position.add(velocity * dt); + _isCollision = false; + } + + @override + void render(Canvas canvas) { + super.render(canvas); + renderShapes(canvas); + } + + @override + void onCollision(Set intersectionPoints, Collidable other) { + if (other is ScreenCollidable) { + _isWallHit = true; + return; + } + _isCollision = true; + } +} + +class Circles extends BaseGame with HasCollidables, TapDetector { + @override + Future onLoad() async { + add(ScreenCollidable()); + } + + @override + void onTapDown(TapDownDetails details) { + add(MyCollidable(details.localPosition.toVector2())); + } +} diff --git a/examples/lib/stories/collision_detection/collision_detection.dart b/examples/lib/stories/collision_detection/collision_detection.dart new file mode 100644 index 000000000..ff8989cb4 --- /dev/null +++ b/examples/lib/stories/collision_detection/collision_detection.dart @@ -0,0 +1,20 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:flame/game.dart'; + +import '../../commons/commons.dart'; +import 'circles.dart'; +import 'multiple_shapes.dart'; + +void addCollisionDetectionStories(Dashbook dashbook) { + dashbook.storiesOf('Collision Detection') + ..add( + 'Circles', + (_) => GameWidget(game: Circles()), + codeLink: baseLink('collision_detection/circles.dart'), + ) + ..add( + 'Multiple shapes', + (_) => GameWidget(game: MultipleShapes()), + codeLink: baseLink('collision_detection/multiple_shapes.dart'), + ); +} diff --git a/examples/lib/stories/collision_detection/multiple_shapes.dart b/examples/lib/stories/collision_detection/multiple_shapes.dart new file mode 100644 index 000000000..abbdc91db --- /dev/null +++ b/examples/lib/stories/collision_detection/multiple_shapes.dart @@ -0,0 +1,237 @@ +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/palette.dart'; +import 'package:flutter/material.dart' hide Image, Draggable; + +enum Shapes { circle, rectangle, polygon } + +abstract class MyCollidable extends PositionComponent + with Draggable, Hitbox, Collidable { + double rotationSpeed = 0.0; + final Vector2 velocity; + final delta = Vector2.zero(); + double angleDelta = 0; + bool _isDragged = false; + final _activePaint = Paint()..color = Colors.amber; + double _wallHitTime = double.infinity; + + MyCollidable(Vector2 position, Vector2 size, this.velocity) { + this.position = position; + this.size = size; + anchor = Anchor.center; + } + + @override + void update(double dt) { + super.update(dt); + if (_isDragged) { + return; + } + _wallHitTime += dt; + delta.setFrom(velocity * dt); + position.add(delta); + angleDelta = dt * rotationSpeed; + angle = (angle + angleDelta) % (2 * pi); + } + + @override + void render(Canvas canvas) { + super.render(canvas); + renderShapes(canvas); + final localCenter = (size / 2).toOffset(); + if (_isDragged) { + 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; + } + } + + @override + bool onDragUpdate(int pointerId, DragUpdateDetails details) { + _isDragged = true; + return true; + } + + @override + bool onDragEnd(int pointerId, DragEndDetails details) { + velocity.setFrom(details.velocity.pixelsPerSecond.toVector2() / 10); + _isDragged = false; + return true; + } +} + +class CollidablePolygon extends MyCollidable { + CollidablePolygon(Vector2 position, Vector2 size, Vector2 velocity) + : super(position, size, velocity) { + final shape = HitboxPolygon([ + Vector2(-1.0, 0.0), + Vector2(-0.8, 0.6), + Vector2(0.0, 1.0), + Vector2(0.6, 0.9), + Vector2(1.0, 0.0), + Vector2(0.6, -0.8), + Vector2(0, -1.0), + Vector2(-0.8, -0.8), + ]); + addShape(shape); + } +} + +class CollidableRectangle extends MyCollidable { + CollidableRectangle(Vector2 position, Vector2 size, Vector2 velocity) + : super(position, size, velocity) { + addShape(HitboxRectangle()); + } +} + +class CollidableCircle extends MyCollidable { + CollidableCircle(Vector2 position, Vector2 size, Vector2 velocity) + : super(position, size, velocity) { + final shape = HitboxCircle(); + addShape(shape); + } +} + +class SnowmanPart extends HitboxCircle { + static const startColor = Colors.white; + final hitPaint = Paint() + ..color = startColor + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; + + SnowmanPart(double definition, Vector2 relativePosition, Color hitColor) + : super(definition: definition) { + this.relativePosition.setFrom(relativePosition); + onCollision = (Set intersectionPoints, HitboxShape other) { + if (other.component is ScreenCollidable) { + hitPaint..color = startColor; + } else { + hitPaint..color = hitColor; + } + }; + } + + @override + void render(Canvas canvas, Paint paint) { + super.render(canvas, hitPaint); + } +} + +class CollidableSnowman extends MyCollidable { + CollidableSnowman(Vector2 position, Vector2 size, Vector2 velocity) + : super(position, size, velocity) { + rotationSpeed = 0.2; + 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); + addShape(top); + addShape(middle); + addShape(bottom); + } +} + +class MultipleShapes extends BaseGame + with HasCollidables, HasDraggableComponents { + final TextConfig fpsTextConfig = TextConfig( + color: BasicPalette.white.color, + ); + + @override + Future onLoad() async { + final screen = ScreenCollidable(); + final snowman = CollidableSnowman( + Vector2.all(150), + Vector2(100, 200), + Vector2(-100, 100), + ); + MyCollidable lastToAdd = snowman; + add(screen); + add(snowman); + var totalAdded = 1; + while (totalAdded < 20) { + lastToAdd = createRandomCollidable(lastToAdd); + final lastBottomRight = + lastToAdd.toAbsoluteRect().bottomRight.toVector2(); + if (screen.containsPoint(lastBottomRight)) { + add(lastToAdd); + totalAdded++; + } else { + break; + } + } + } + + final _rng = Random(); + final _distance = Vector2(100, 0); + + MyCollidable createRandomCollidable(MyCollidable lastCollidable) { + final collidableSize = Vector2.all(50) + Vector2.random(_rng) * 100; + final isXOverflow = lastCollidable.position.x + + lastCollidable.size.x / 2 + + _distance.x + + collidableSize.x > + size.x; + var position = _distance + Vector2(0, lastCollidable.position.y + 200); + if (!isXOverflow) { + position = (lastCollidable.position + _distance) + ..x += collidableSize.x / 2; + } + final velocity = Vector2.random(_rng) * 200; + 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); + case Shapes.rectangle: + return CollidableRectangle(position, collidableSize, velocity) + ..rotationSpeed = rotationSpeed; + case Shapes.polygon: + return CollidablePolygon(position, collidableSize, velocity) + ..rotationSpeed = rotationSpeed; + } + } + + @override + void render(Canvas canvas) { + super.render(canvas); + fpsTextConfig.render( + canvas, + '${fps(120).toStringAsFixed(2)}fps', + Vector2(0, size.y - 24), + ); + } +} diff --git a/packages/flame/CHANGELOG.md b/packages/flame/CHANGELOG.md index 45aefdf9e..fcdf66bab 100644 --- a/packages/flame/CHANGELOG.md +++ b/packages/flame/CHANGELOG.md @@ -21,6 +21,8 @@ - Add a `renderPoint` method to `Canvas` - Add zoom to the camera - Add `moveToTarget` as an extension method to `Vector2` + - Bring back collision detection examples + - Fix collision detection in Collidable with multiple offset shapes ## 1.0.0-rc8 - Migrate to null safety diff --git a/packages/flame/lib/src/components/mixins/hitbox.dart b/packages/flame/lib/src/components/mixins/hitbox.dart index f1065442d..11f7748e9 100644 --- a/packages/flame/lib/src/components/mixins/hitbox.dart +++ b/packages/flame/lib/src/components/mixins/hitbox.dart @@ -38,8 +38,8 @@ mixin Hitbox on PositionComponent { if (!_cachedBoundingRect.isCacheValid([position, size])) { final maxRadius = size.length; _cachedBoundingRect.updateCache( - Rect.fromCenter( - center: absoluteCenter.toOffset(), + RectExtension.fromVector2Center( + center: absoluteCenter, width: maxRadius, height: maxRadius, ), diff --git a/packages/flame/lib/src/extensions/rect.dart b/packages/flame/lib/src/extensions/rect.dart index afc91dff1..d036e4b15 100644 --- a/packages/flame/lib/src/extensions/rect.dart +++ b/packages/flame/lib/src/extensions/rect.dart @@ -37,11 +37,7 @@ extension RectExtension on Rect { bottomLeft.toVector2(), ]; } -} -// Until [extension] will allow static methods we need to keep these functions -// in a utility class -class RectFactory { /// Creates bounds in from of a [Rect] from a list of [Vector2] static Rect fromBounds(List pts) { final minX = pts.map((e) => e.x).reduce(min); @@ -50,4 +46,19 @@ class RectFactory { final maxY = pts.map((e) => e.y).reduce(max); return Rect.fromPoints(Offset(minX, minY), Offset(maxX, maxY)); } + + /// Constructs a rectangle from its center point (specified as a Vector2), + /// width and height. + static Rect fromVector2Center({ + required Vector2 center, + required double width, + required double height, + }) { + return Rect.fromLTRB( + center.x - width / 2, + center.y - height / 2, + center.x + width / 2, + center.y + height / 2, + ); + } } diff --git a/packages/flame/lib/src/geometry/shape.dart b/packages/flame/lib/src/geometry/shape.dart index d07ee1702..e1ff814a8 100644 --- a/packages/flame/lib/src/geometry/shape.dart +++ b/packages/flame/lib/src/geometry/shape.dart @@ -56,10 +56,12 @@ mixin HitboxShape on Shape { @override double get angle => component.angle; - /// The shapes center, before rotation + /// The shape's absolute center @override Vector2 get shapeCenter { - return (component.absoluteCenter + position) + return component.absoluteCenter + + position + + ((size / 2)..multiply(relativePosition)) ..rotate(angle, center: anchorPosition); }