diff --git a/doc/effects.md b/doc/effects.md index 871c21c55..7434da88b 100644 --- a/doc/effects.md +++ b/doc/effects.md @@ -35,17 +35,18 @@ the value you give it no matter where it started. When an effect is completed the callback `onComplete` will be called, it can be set as an optional argument to your effect. -## Common for MoveEffect, ScaleEffect and RotateEffect (SimplePositionComponentEffects) +## Common for MoveEffect, ScaleEffect, SizeEffect and RotateEffect (SimplePositionComponentEffects) -A common thing for `MoveEffect`, `ScaleEffect` and `RotateEffect` is that it takes `duration` and -`speed` as arguments, but only use one of them at a time. +A common thing for `MoveEffect`, `ScaleEffect`, `SizeEffect` and `RotateEffect` is that it takes +`duration` and `speed` as arguments, but only use one of them at a time. - Duration means the time it takes for one iteration from beginning to end, with alternation taken into account (but not `isInfinite`). - Speed is the speed of the effect + pixels per second for `MoveEffect` - + pixels per second for `ScaleEffect` + + pixels per second for `SizeEffect` + radians per second for `RotateEffect` + + percentage/100 per second for `ScaleEffect` One of these two needs to be defined, if both are defined `duration` takes precedence. @@ -93,6 +94,29 @@ component will first move to `(120, 0)` and then to `(120, 100)`. ## ScaleEffect +Applied to `PositionComponent`s, this effect can be used to change the scale with which the +component and its children is rendered on the canvas with, using an +[animation curve](https://api.flutter.dev/flutter/animation/Curves-class.html). + +This also affects the `scaledSize` property of the component. + +The speed is measured in percentage/100 per second, and remember that you can give `duration` as an +argument instead of `speed`. + +Usage example: +```dart +import 'package:flame/effects.dart'; + +// Square is a PositionComponent +square.addEffect(ScaleEffect( + scale: Vector2.all(2.0), + speed: 1.0, + curve: Curves.bounceInOut, +)); +``` + +## SizeEffect + Applied to `PositionComponent`s, this effect can be used to change the width and height of the component, using an [animation curve](https://api.flutter.dev/flutter/animation/Curves-class.html). @@ -104,8 +128,8 @@ Usage example: import 'package:flame/effects.dart'; // Square is a PositionComponent -square.addEffect(ScaleEffect( - size: Size(300, 300), +square.addEffect(SizeEffect( + size: Vector2.all(300), speed: 250.0, curve: Curves.bounceInOut, )); @@ -153,7 +177,7 @@ You can make the sequence go in a loop by setting both `isInfinite: true` and `i Usage example: ```dart final sequence = SequenceEffect( - effects: [move1, scale, move2, rotate], + effects: [move1, size, move2, rotate], isInfinite: true, isAlternating: true); myComponent.addEffect(sequence); @@ -179,7 +203,7 @@ You can make the combined effect go in a loop by setting both `isInfinite: true` Usage example: ```dart final combination = CombinedEffect( - effects: [move, scale, rotate], + effects: [move, size, rotate], isInfinite: true, isAlternating: true); myComponent.addEffect(combination); diff --git a/examples/lib/stories/collision_detection/multiple_shapes.dart b/examples/lib/stories/collision_detection/multiple_shapes.dart index 7ece53948..75dfbdf1d 100644 --- a/examples/lib/stories/collision_detection/multiple_shapes.dart +++ b/examples/lib/stories/collision_detection/multiple_shapes.dart @@ -50,12 +50,12 @@ abstract class MyCollidable extends PositionComponent 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; + final topLeft = absoluteCenter - (scaledSize / 2); + if (topLeft.x + scaledSize.x < 0 || + topLeft.y + scaledSize.y < 0 || + topLeft.x > screenCollidable.scaledSize.x || + topLeft.y > screenCollidable.scaledSize.y) { + final moduloSize = screenCollidable.scaledSize + scaledSize; topLeftPosition = topLeftPosition % moduloSize; } } @@ -65,7 +65,7 @@ abstract class MyCollidable extends PositionComponent super.render(canvas); renderHitboxes(canvas, paint: _activePaint); if (_isDragged) { - final localCenter = (size / 2).toOffset(); + final localCenter = (scaledSize / 2).toOffset(); canvas.drawCircle(localCenter, 5, _activePaint); } } diff --git a/examples/lib/stories/components/composability.dart b/examples/lib/stories/components/composability.dart index 3dc434ac3..7b4758ba2 100644 --- a/examples/lib/stories/components/composability.dart +++ b/examples/lib/stories/components/composability.dart @@ -1,12 +1,14 @@ import 'package:flame/components.dart'; import 'package:flame/game.dart'; +import 'package:flame/input.dart'; class Square extends PositionComponent { - Square(Vector2 position, Vector2 size, {double angle = 0}) { - this.position.setFrom(position); - this.size.setFrom(size); - this.angle = angle; - } + Square(Vector2 position, Vector2 size, {double angle = 0}) + : super( + position: position, + size: size, + angle: angle, + ); } class ParentSquare extends Square with HasGameRef { @@ -31,7 +33,7 @@ class ParentSquare extends Square with HasGameRef { } } -class Composability extends BaseGame { +class Composability extends BaseGame with TapDetector { late ParentSquare _parent; @override @@ -49,4 +51,10 @@ class Composability extends BaseGame { super.update(dt); _parent.angle += dt; } + + @override + void onTap() { + super.onTap(); + _parent.scale = Vector2.all(2.0); + } } diff --git a/examples/lib/stories/effects/combined_effect.dart b/examples/lib/stories/effects/combined_effect.dart index ad08a088e..430785a58 100644 --- a/examples/lib/stories/effects/combined_effect.dart +++ b/examples/lib/stories/effects/combined_effect.dart @@ -41,7 +41,7 @@ class CombinedEffectGame extends BaseGame with TapDetector { curve: Curves.bounceInOut, ); - final scale = ScaleEffect( + final scale = SizeEffect( size: currentTap, speed: 200.0, curve: Curves.linear, diff --git a/examples/lib/stories/effects/effects.dart b/examples/lib/stories/effects/effects.dart index 1229b6c6b..79e6a86fe 100644 --- a/examples/lib/stories/effects/effects.dart +++ b/examples/lib/stories/effects/effects.dart @@ -10,9 +10,15 @@ import 'opacity_effect.dart'; import 'rotate_effect.dart'; import 'scale_effect.dart'; import 'sequence_effect.dart'; +import 'size_effect.dart'; void addEffectsStories(Dashbook dashbook) { dashbook.storiesOf('Effects') + ..add( + 'Size Effect', + (_) => GameWidget(game: SizeEffectGame()), + codeLink: baseLink('effects/size_effect.dart'), + ) ..add( 'Scale Effect', (_) => GameWidget(game: ScaleEffectGame()), diff --git a/examples/lib/stories/effects/infinite_effect.dart b/examples/lib/stories/effects/infinite_effect.dart index e27ddc27a..b7cef901f 100644 --- a/examples/lib/stories/effects/infinite_effect.dart +++ b/examples/lib/stories/effects/infinite_effect.dart @@ -49,7 +49,7 @@ class InfiniteEffectGame extends BaseGame with TapDetector { ); redSquare.addEffect( - ScaleEffect( + SizeEffect( size: p, speed: 250.0, curve: Curves.easeInCubic, diff --git a/examples/lib/stories/effects/scale_effect.dart b/examples/lib/stories/effects/scale_effect.dart index 507faa53a..f9bffbe2e 100644 --- a/examples/lib/stories/effects/scale_effect.dart +++ b/examples/lib/stories/effects/scale_effect.dart @@ -3,6 +3,7 @@ import 'package:flame/effects.dart'; import 'package:flame/extensions.dart'; import 'package:flame/game.dart'; import 'package:flame/input.dart'; +import 'package:flame/palette.dart'; import 'package:flutter/material.dart'; import '../../commons/square_component.dart'; @@ -16,19 +17,26 @@ class ScaleEffectGame extends BaseGame with TapDetector { square = SquareComponent() ..position.setValues(200, 200) ..anchor = Anchor.center; + square.paint = BasicPalette.white.paint()..style = PaintingStyle.stroke; + final childSquare = SquareComponent() + ..position = Vector2.all(70) + ..size = Vector2.all(20) + ..anchor = Anchor.center; + + square.addChild(childSquare); add(square); } @override void onTap() { - final s = grow ? 300.0 : 100.0; + final s = grow ? 3.0 : 1.0; grow = !grow; square.addEffect( ScaleEffect( - size: Vector2.all(s), - speed: 250.0, - curve: Curves.bounceInOut, + scale: Vector2.all(s), + speed: 2.0, + curve: Curves.linear, ), ); } diff --git a/examples/lib/stories/effects/sequence_effect.dart b/examples/lib/stories/effects/sequence_effect.dart index 5ee172d12..9dc786c09 100644 --- a/examples/lib/stories/effects/sequence_effect.dart +++ b/examples/lib/stories/effects/sequence_effect.dart @@ -41,7 +41,7 @@ class SequenceEffectGame extends BaseGame with TapDetector { curve: Curves.easeIn, ); - final scale = ScaleEffect( + final scale = SizeEffect( size: currentTap, speed: 100.0, curve: Curves.easeInCubic, diff --git a/examples/lib/stories/effects/size_effect.dart b/examples/lib/stories/effects/size_effect.dart new file mode 100644 index 000000000..28fa1f3a9 --- /dev/null +++ b/examples/lib/stories/effects/size_effect.dart @@ -0,0 +1,35 @@ +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/game.dart'; +import 'package:flame/input.dart'; +import 'package:flutter/material.dart'; + +import '../../commons/square_component.dart'; + +class SizeEffectGame extends BaseGame with TapDetector { + late SquareComponent square; + bool grow = true; + + @override + Future onLoad() async { + square = SquareComponent() + ..position.setValues(200, 200) + ..anchor = Anchor.center; + add(square); + } + + @override + void onTap() { + final s = grow ? 300.0 : 100.0; + + grow = !grow; + square.addEffect( + SizeEffect( + size: Vector2.all(s), + speed: 250.0, + curve: Curves.bounceInOut, + ), + ); + } +} diff --git a/packages/flame/CHANGELOG.md b/packages/flame/CHANGELOG.md index 7cb240cc2..08c3714ff 100644 --- a/packages/flame/CHANGELOG.md +++ b/packages/flame/CHANGELOG.md @@ -24,6 +24,9 @@ - Adding `SpriteGroupComponent` - Fix truncated last frame in non-looping animations - Default size of `SpriteComponent` is `srcSize` instead of spritesheet size + - Rename `ScaleEffect` to `SizeEffect` + - Introduce `scale` on `PositionComponent` + - Add `ScaleEffect` that works on `scale` instead of `size` ## [1.0.0-releasecandidate.13] - Fix camera not ending up in the correct position on long jumps diff --git a/packages/flame/lib/effects.dart b/packages/flame/lib/effects.dart index 454c37809..5db554e66 100644 --- a/packages/flame/lib/effects.dart +++ b/packages/flame/lib/effects.dart @@ -5,5 +5,5 @@ export 'src/effects/move_effect.dart'; export 'src/effects/rotate_effect.dart'; export 'src/effects/scale_effect.dart'; export 'src/effects/sequence_effect.dart'; - +export 'src/effects/size_effect.dart'; export 'src/extensions/vector2.dart'; diff --git a/packages/flame/lib/src/memory_cache.dart b/packages/flame/lib/src/components/cache/memory_cache.dart similarity index 100% rename from packages/flame/lib/src/memory_cache.dart rename to packages/flame/lib/src/components/cache/memory_cache.dart diff --git a/packages/flame/lib/src/components/cache/value_cache.dart b/packages/flame/lib/src/components/cache/value_cache.dart new file mode 100644 index 000000000..263b5e93e --- /dev/null +++ b/packages/flame/lib/src/components/cache/value_cache.dart @@ -0,0 +1,28 @@ +/// Used for caching calculated values, the cache is determined to be valid by +/// comparing a list of values that can be of any type and is compared to the +/// values that was last used when the cache was updated. +class ValueCache { + T? value; + + List _lastValidCacheValues = []; + + ValueCache(); + + bool isCacheValid(List validCacheValues) { + if (value == null) { + return false; + } + for (var i = 0; i < _lastValidCacheValues.length; ++i) { + if (_lastValidCacheValues[i] != validCacheValues[i]) { + return false; + } + } + return true; + } + + T updateCache(T value, List validCacheValues) { + this.value = value; + _lastValidCacheValues = validCacheValues; + return value; + } +} diff --git a/packages/flame/lib/src/components/input/hud_margin_component.dart b/packages/flame/lib/src/components/input/hud_margin_component.dart index 52e4d2237..df9911be6 100644 --- a/packages/flame/lib/src/components/input/hud_margin_component.dart +++ b/packages/flame/lib/src/components/input/hud_margin_component.dart @@ -36,25 +36,26 @@ class HudMarginComponent extends PositionComponent if (margin != null) { final margin = this.margin!; final x = margin.left != 0 - ? margin.left + size.x / 2 - : gameRef.viewport.effectiveSize.x - margin.right - size.x / 2; + ? margin.left + scaledSize.x / 2 + : gameRef.viewport.effectiveSize.x - margin.right - scaledSize.x / 2; final y = margin.top != 0 - ? margin.top + size.y / 2 - : gameRef.viewport.effectiveSize.y - margin.bottom - size.y / 2; + ? margin.top + scaledSize.y / 2 + : gameRef.viewport.effectiveSize.y - margin.bottom - scaledSize.y / 2; position.setValues(x, y); - position = Anchor.center.toOtherAnchorPosition(center, anchor, size); + position = + Anchor.center.toOtherAnchorPosition(center, anchor, scaledSize); } else { final topLeft = gameRef.viewport.effectiveSize - anchor.toOtherAnchorPosition( position, Anchor.topLeft, - size, + scaledSize, ); final bottomRight = gameRef.viewport.effectiveSize - anchor.toOtherAnchorPosition( position, Anchor.bottomRight, - size, + scaledSize, ); margin = EdgeInsets.fromLTRB( topLeft.x, diff --git a/packages/flame/lib/src/components/mixins/hitbox.dart b/packages/flame/lib/src/components/mixins/hitbox.dart index 53102673f..814618838 100644 --- a/packages/flame/lib/src/components/mixins/hitbox.dart +++ b/packages/flame/lib/src/components/mixins/hitbox.dart @@ -37,7 +37,7 @@ 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) { - final maxDistance = other.size.length + size.length; + final maxDistance = other.scaledSize.length + scaledSize.length; return other.absoluteCenter.distanceToSquared(absoluteCenter) <= maxDistance * maxDistance; } @@ -47,6 +47,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 absoluteCenter.distanceToSquared(point) <= size.length2; + return absoluteCenter.distanceToSquared(point) <= scaledSize.length2; } } diff --git a/packages/flame/lib/src/components/position_component.dart b/packages/flame/lib/src/components/position_component.dart index 95c345e68..c6a27317d 100644 --- a/packages/flame/lib/src/components/position_component.dart +++ b/packages/flame/lib/src/components/position_component.dart @@ -6,6 +6,7 @@ import '../extensions/rect.dart'; import '../extensions/vector2.dart'; import '../geometry/rectangle.dart'; import 'base_component.dart'; +import 'cache/value_cache.dart'; import 'component.dart'; import 'mixins/hitbox.dart'; @@ -23,9 +24,9 @@ import 'mixins/hitbox.dart'; /// within this component's (width, height). abstract class PositionComponent extends BaseComponent { /// The position of this component on the screen (relative to the anchor). - final Vector2 _position; Vector2 get position => _position; set position(Vector2 position) => _position.setFrom(position); + final Vector2 _position; /// X position of this component on the screen (relative to the anchor). double get x => _position.x; @@ -35,19 +36,38 @@ abstract class PositionComponent extends BaseComponent { double get y => _position.y; set y(double y) => _position.y = y; - /// The size that this component is rendered with. + /// The size that this component is rendered with before [scale] is applied. /// This is not necessarily the source size of the asset. - final Vector2 _size; Vector2 get size => _size; set size(Vector2 size) => _size.setFrom(size); + final Vector2 _size; /// Width (size) that this component is rendered with. - double get width => size.x; - set width(double width) => size.x = width; + double get width => scaledSize.x; + set width(double width) => size.x = width / scale.x; /// Height (size) that this component is rendered with. - double get height => size.y; - set height(double height) => size.y = height; + double get height => scaledSize.y; + set height(double height) => size.y = height / scale.y; + + /// The scale factor of this component + Vector2 get scale => _scale; + set scale(Vector2 scale) => _scale.setFrom(scale); + final Vector2 _scale; + + /// Cache to store the calculated scaled size + final ValueCache _scaledSizeCache = ValueCache(); + + /// The size that this component is rendered with after [scale] is applied. + Vector2 get scaledSize { + if (!_scaledSizeCache.isCacheValid([scale, size])) { + _scaledSizeCache.updateCache( + size.clone()..multiply(scale), + [scale.clone(), size.clone()], + ); + } + return _scaledSizeCache.value!; + } /// Get the absolute position, with the anchor taken into consideration Vector2 get absolutePosition => absoluteParentPosition + position; @@ -57,13 +77,13 @@ abstract class PositionComponent extends BaseComponent { return anchor.toOtherAnchorPosition( position, Anchor.topLeft, - size, + scaledSize, ); } /// Set the top left position regardless of the anchor set topLeftPosition(Vector2 position) { - this.position = position + (anchor.toVector2()..multiply(size)); + this.position = position + (anchor.toVector2()..multiply(scaledSize)); } /// Get the absolute top left position regardless of whether it is a child or not @@ -93,7 +113,7 @@ abstract class PositionComponent extends BaseComponent { if (anchor == Anchor.center) { return position; } else { - return anchor.toOtherAnchorPosition(position, Anchor.center, size) + return anchor.toOtherAnchorPosition(position, Anchor.center, scaledSize) ..rotate(angle, center: absolutePosition); } } @@ -116,25 +136,10 @@ abstract class PositionComponent extends BaseComponent { /// Whether this component should be flipped ofn the Y axis before being rendered. bool renderFlipY = false; - /// Returns the relative position/size of this component. - /// Relative because it might be translated by their parents (which is not considered here). - Rect toRect() => topLeftPosition.toPositionedRect(size); - - /// Returns the absolute position/size of this component. - /// Absolute because it takes any possible parent position into consideration. - Rect toAbsoluteRect() => absoluteTopLeftPosition.toPositionedRect(size); - - /// Mutates position and size using the provided [rect] as basis. - /// This is a relative rect, same definition that [toRect] use - /// (therefore both methods are compatible, i.e. setByRect ∘ toRect = identity). - void setByRect(Rect rect) { - size.setValues(rect.width, rect.height); - topLeftPosition = rect.topLeft.toVector2(); - } - PositionComponent({ Vector2? position, Vector2? size, + Vector2? scale, this.angle = 0.0, this.anchor = Anchor.topLeft, this.renderFlipX = false, @@ -142,6 +147,7 @@ abstract class PositionComponent extends BaseComponent { int? priority, }) : _position = position ?? Vector2.zero(), _size = size ?? Vector2.zero(), + _scale = scale ?? Vector2.all(1.0), super(priority: priority); @override @@ -160,7 +166,7 @@ abstract class PositionComponent extends BaseComponent { if (this is Hitbox) { (this as Hitbox).renderHitboxes(canvas); } - canvas.drawRect(size.toRect(), debugPaint); + canvas.drawRect(scaledSize.toRect(), debugPaint); debugTextPaint.render( canvas, 'x: ${x.toStringAsFixed(2)} y:${y.toStringAsFixed(2)}', @@ -177,19 +183,51 @@ abstract class PositionComponent extends BaseComponent { ); } + final Matrix4 _preRenderMatrix = Matrix4.identity(); + @override void preRender(Canvas canvas) { - canvas.translate(x, y); - canvas.rotate(angle); - final delta = -anchor.toVector2() - ..multiply(size); - canvas.translate(delta.x, delta.y); + // Move canvas to the components anchor position. + _preRenderMatrix.translate(x, y); + // Rotate canvas around anchor + _preRenderMatrix.rotateZ(angle); + // Scale canvas if it should be scaled + if (scale.x != 1.0 || scale.y != 1.0) { + _preRenderMatrix.scale(scale.x, scale.y); + } + canvas.transform(_preRenderMatrix.storage); + _preRenderMatrix.setIdentity(); + + final delta = anchor.toVector2()..multiply(scaledSize); + canvas.translate(-delta.x, -delta.y); // Handle inverted rendering by moving center and flipping. if (renderFlipX || renderFlipY) { - canvas.translate(width / 2, height / 2); - canvas.scale(renderFlipX ? -1.0 : 1.0, renderFlipY ? -1.0 : 1.0); - canvas.translate(-width / 2, -height / 2); + final size = scaledSize; + _preRenderMatrix.translate(size.x / 2, size.y / 2); + _preRenderMatrix.scale( + renderFlipX ? -1.0 : 1.0, + renderFlipY ? -1.0 : 1.0, + ); + canvas.transform(_preRenderMatrix.storage); + canvas.translate(-size.x / 2, -size.y / 2); + _preRenderMatrix.setIdentity(); } } + + /// Returns the relative position/size of this component. + /// Relative because it might be translated by their parents (which is not considered here). + Rect toRect() => topLeftPosition.toPositionedRect(scaledSize); + + /// Returns the absolute position/size of this component. + /// Absolute because it takes any possible parent position into consideration. + Rect toAbsoluteRect() => absoluteTopLeftPosition.toPositionedRect(scaledSize); + + /// Mutates position and size using the provided [rect] as basis. + /// This is a relative rect, same definition that [toRect] use + /// (therefore both methods are compatible, i.e. setByRect ∘ toRect = identity). + void setByRect(Rect rect) { + size.setValues(rect.width, rect.height); + topLeftPosition = rect.topLeft.toVector2(); + } } diff --git a/packages/flame/lib/src/effects/effects.dart b/packages/flame/lib/src/effects/effects.dart index a7f16277c..9d44c96d6 100644 --- a/packages/flame/lib/src/effects/effects.dart +++ b/packages/flame/lib/src/effects/effects.dart @@ -8,8 +8,8 @@ export './color_effect.dart'; export './move_effect.dart'; export './opacity_effect.dart'; export './rotate_effect.dart'; -export './scale_effect.dart'; export './sequence_effect.dart'; +export './size_effect.dart'; abstract class ComponentEffect { T? component; @@ -140,16 +140,19 @@ abstract class PositionComponentEffect Vector2? originalPosition; double? originalAngle; Vector2? originalSize; + Vector2? originalScale; /// Used to be able to determine the end state of a sequence of effects Vector2? endPosition; double? endAngle; Vector2? endSize; + Vector2? endScale; /// Whether the state of a certain field was modified by the effect final bool modifiesPosition; final bool modifiesAngle; final bool modifiesSize; + final bool modifiesScale; PositionComponentEffect( bool initialIsInfinite, @@ -159,6 +162,7 @@ abstract class PositionComponentEffect this.modifiesPosition = false, this.modifiesAngle = false, this.modifiesSize = false, + this.modifiesScale = false, VoidCallback? onComplete, }) : super( initialIsInfinite, @@ -176,6 +180,7 @@ abstract class PositionComponentEffect originalPosition = component.position.clone(); originalAngle = component.angle; originalSize = component.size.clone(); + originalScale = component.scale.clone(); /// If these aren't modified by the extending effect it is assumed that the /// effect didn't bring the component to another state than the one it @@ -183,12 +188,18 @@ abstract class PositionComponentEffect endPosition = component.position.clone(); endAngle = component.angle; endSize = component.size.clone(); + endScale = component.scale.clone(); } /// Only change the parts of the component that is affected by the /// effect, and only set the state if it is the root effect (not part of /// another effect, like children of a CombinedEffect or SequenceEffect). - void _setComponentState(Vector2? position, double? angle, Vector2? size) { + void _setComponentState( + Vector2? position, + double? angle, + Vector2? size, + Vector2? scale, + ) { if (isRootEffect()) { if (modifiesPosition) { assert( @@ -211,17 +222,29 @@ abstract class PositionComponentEffect ); component?.size.setFrom(size!); } + if (modifiesScale) { + assert( + scale != null, + '`scale` must not be `null` for an effect which modifies `scale`', + ); + component?.scale.setFrom(scale!); + } } } @override void setComponentToOriginalState() { - _setComponentState(originalPosition, originalAngle, originalSize); + _setComponentState( + originalPosition, + originalAngle, + originalSize, + originalScale, + ); } @override void setComponentToEndState() { - _setComponentState(endPosition, endAngle, endSize); + _setComponentState(endPosition, endAngle, endSize, endScale); } } @@ -239,6 +262,7 @@ abstract class SimplePositionComponentEffect extends PositionComponentEffect { bool modifiesPosition = false, bool modifiesAngle = false, bool modifiesSize = false, + bool modifiesScale = false, VoidCallback? onComplete, }) : assert( (duration != null) ^ (speed != null), @@ -252,6 +276,7 @@ abstract class SimplePositionComponentEffect extends PositionComponentEffect { modifiesPosition: modifiesPosition, modifiesAngle: modifiesAngle, modifiesSize: modifiesSize, + modifiesScale: modifiesScale, onComplete: onComplete, ); } diff --git a/packages/flame/lib/src/effects/effects_handler.dart b/packages/flame/lib/src/effects/effects_handler.dart index 2dcbcdd26..3a62f8575 100644 --- a/packages/flame/lib/src/effects/effects_handler.dart +++ b/packages/flame/lib/src/effects/effects_handler.dart @@ -3,8 +3,8 @@ import 'effects.dart'; export './move_effect.dart'; export './rotate_effect.dart'; -export './scale_effect.dart'; export './sequence_effect.dart'; +export './size_effect.dart'; class EffectsHandler { /// The effects that should run on the component diff --git a/packages/flame/lib/src/effects/scale_effect.dart b/packages/flame/lib/src/effects/scale_effect.dart index 771ef98a6..595753f2f 100644 --- a/packages/flame/lib/src/effects/scale_effect.dart +++ b/packages/flame/lib/src/effects/scale_effect.dart @@ -7,13 +7,13 @@ import '../extensions/vector2.dart'; import 'effects.dart'; class ScaleEffect extends SimplePositionComponentEffect { - Vector2 size; - late Vector2 _startSize; + Vector2 scale; + late Vector2 _startScale; late Vector2 _delta; /// Duration or speed needs to be defined ScaleEffect({ - required this.size, + required this.scale, double? duration, // How long it should take for completion double? speed, // The speed of the scaling in pixels per second Curve? curve, @@ -32,17 +32,17 @@ class ScaleEffect extends SimplePositionComponentEffect { speed: speed, curve: curve, isRelative: isRelative, - modifiesSize: true, + modifiesScale: true, onComplete: onComplete, ); @override void initialize(PositionComponent component) { super.initialize(component); - _startSize = component.size.clone(); - _delta = isRelative ? size : size - _startSize; + _startScale = component.scale.clone(); + _delta = isRelative ? scale : scale - _startScale; if (!isAlternating) { - endSize = _startSize + _delta; + endScale = _startScale + _delta; } speed ??= _delta.length / duration!; duration ??= _delta.length / speed!; @@ -52,6 +52,6 @@ class ScaleEffect extends SimplePositionComponentEffect { @override void update(double dt) { super.update(dt); - component?.size.setFrom(_startSize + _delta * curveProgress); + component?.scale.setFrom(_startScale + _delta * curveProgress); } } diff --git a/packages/flame/lib/src/effects/size_effect.dart b/packages/flame/lib/src/effects/size_effect.dart new file mode 100644 index 000000000..b1ac89e95 --- /dev/null +++ b/packages/flame/lib/src/effects/size_effect.dart @@ -0,0 +1,57 @@ +import 'dart:ui'; + +import 'package:flutter/animation.dart'; + +import '../../components.dart'; +import '../extensions/vector2.dart'; +import 'effects.dart'; + +class SizeEffect extends SimplePositionComponentEffect { + Vector2 size; + late Vector2 _startSize; + late Vector2 _delta; + + /// Duration or speed needs to be defined + SizeEffect({ + required this.size, + double? duration, // How long it should take for completion + double? speed, // The speed of the scaling in pixels per second + Curve? curve, + bool isInfinite = false, + bool isAlternating = false, + bool isRelative = false, + VoidCallback? onComplete, + }) : assert( + duration != null || speed != null, + 'Either speed or duration necessary', + ), + super( + isInfinite, + isAlternating, + duration: duration, + speed: speed, + curve: curve, + isRelative: isRelative, + modifiesSize: true, + onComplete: onComplete, + ); + + @override + void initialize(PositionComponent component) { + super.initialize(component); + _startSize = component.size.clone(); + _delta = isRelative ? size : size - _startSize; + if (!isAlternating) { + endSize = _startSize + _delta; + } + speed ??= _delta.length / duration!; + duration ??= _delta.length / speed!; + peakTime = isAlternating ? duration! / 2 : duration!; + } + + @override + void update(double dt) { + super.update(dt); + component?.size.setFrom(_startSize + _delta * curveProgress); + } +} diff --git a/packages/flame/lib/src/geometry/polygon.dart b/packages/flame/lib/src/geometry/polygon.dart index a3792b03a..261e01d19 100644 --- a/packages/flame/lib/src/geometry/polygon.dart +++ b/packages/flame/lib/src/geometry/polygon.dart @@ -3,6 +3,7 @@ import 'dart:ui' hide Canvas; import '../../game.dart'; import '../../geometry.dart'; +import '../components/cache/value_cache.dart'; import '../extensions/canvas.dart'; import '../extensions/rect.dart'; import '../extensions/vector2.dart'; @@ -68,7 +69,7 @@ class Polygon extends Shape { normalizedVertices.map((_) => Vector2.zero()).toList(growable: false); } - final _cachedScaledShape = ShapeCache>(); + final _cachedScaledShape = ValueCache>(); /// Gives back the shape vectors multiplied by the size Iterable scaled() { @@ -82,7 +83,7 @@ class Polygon extends Shape { return _cachedScaledShape.value!; } - final _cachedRenderPath = ShapeCache(); + final _cachedRenderPath = ValueCache(); @override void render(Canvas canvas, Paint paint) { @@ -114,7 +115,7 @@ class Polygon extends Shape { canvas.drawPath(_cachedRenderPath.value!, paint); } - final _cachedHitbox = ShapeCache>(); + final _cachedHitbox = ValueCache>(); /// Gives back the vertices represented as a list of points which /// are the "corners" of the hitbox rotated with [angle]. diff --git a/packages/flame/lib/src/geometry/shape.dart b/packages/flame/lib/src/geometry/shape.dart index d7d41535a..45a53602d 100644 --- a/packages/flame/lib/src/geometry/shape.dart +++ b/packages/flame/lib/src/geometry/shape.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import '../../components.dart'; import '../../game.dart'; import '../../palette.dart'; +import '../components/cache/value_cache.dart'; import '../extensions/vector2.dart'; import 'shape_intersections.dart' as intersection_system; @@ -11,9 +12,9 @@ import 'shape_intersections.dart' as intersection_system; /// center. /// A point can be determined to be within of outside of a shape. abstract class Shape { - final ShapeCache _halfSizeCache = ShapeCache(); - final ShapeCache _localCenterCache = ShapeCache(); - final ShapeCache _absoluteCenterCache = ShapeCache(); + final ValueCache _halfSizeCache = ValueCache(); + final ValueCache _localCenterCache = ValueCache(); + final ValueCache _absoluteCenterCache = ValueCache(); /// Should be the center of that [offsetPosition] and [relativeOffset] /// should be calculated from, if they are not set this is the center of the @@ -133,7 +134,7 @@ mixin HitboxShape on Shape { late PositionComponent component; @override - Vector2 get size => component.size; + Vector2 get size => component.scaledSize; @override double get parentAngle => component.angle; @@ -159,32 +160,3 @@ typedef CollisionEndCallback = void Function(HitboxShape other); void emptyCollisionCallback(Set _, HitboxShape __) {} void emptyCollisionEndCallback(HitboxShape _) {} - -/// Used for caching calculated shapes, the cache is determined to be valid by -/// comparing a list of values that can be of any type and is compared to the -/// values that was last used when the cache was updated. -class ShapeCache { - T? value; - - List _lastValidCacheValues = []; - - ShapeCache(); - - bool isCacheValid(List validCacheValues) { - if (value == null) { - return false; - } - for (var i = 0; i < _lastValidCacheValues.length; ++i) { - if (_lastValidCacheValues[i] != validCacheValues[i]) { - return false; - } - } - return true; - } - - T updateCache(T value, List validCacheValues) { - this.value = value; - _lastValidCacheValues = validCacheValues; - return value; - } -} diff --git a/packages/flame/lib/src/text.dart b/packages/flame/lib/src/text.dart index 9f2c4c836..77c261d2a 100644 --- a/packages/flame/lib/src/text.dart +++ b/packages/flame/lib/src/text.dart @@ -3,10 +3,10 @@ import 'dart:ui'; import 'package:flutter/material.dart' as material; import 'anchor.dart'; +import 'components/cache/memory_cache.dart'; import 'components/text_component.dart'; import 'extensions/size.dart'; import 'extensions/vector2.dart'; -import 'memory_cache.dart'; /// [TextRenderer] is the abstract API that Flame uses for rendering text in its features /// this class can be extended to provide an implementation of text rendering in the engine. diff --git a/packages/flame/test/components/collidable_type_test.dart b/packages/flame/test/components/collidable_type_test.dart index 34954ae90..fbac9c8d5 100644 --- a/packages/flame/test/components/collidable_type_test.dart +++ b/packages/flame/test/components/collidable_type_test.dart @@ -256,6 +256,41 @@ void main() { true, ); }); + test('Detects collision after scale', () { + final blockA = TestBlock( + Vector2.zero(), + Vector2.all(10), + CollidableType.active, + ); + final blockB = TestBlock( + Vector2.all(11), + Vector2.all(10), + CollidableType.active, + ); + final game = gameWithCollidables([blockA, blockB]); + expect(blockA.collidedWith(blockB), false); + expect(blockB.collidedWith(blockA), false); + expect(blockA.collisions.length, 0); + expect(blockB.collisions.length, 0); + blockA.scale = Vector2.all(2.0); + game.update(0); + expect(blockA.collidedWith(blockB), true); + expect(blockB.collidedWith(blockA), true); + expect(blockA.collisions.length, 1); + expect(blockB.collisions.length, 1); + }); + test('TestPoint detects point after scale', () { + final blockA = TestBlock( + Vector2.zero(), + Vector2.all(10), + CollidableType.active, + ); + final game = gameWithCollidables([blockA]); + expect(blockA.containsPoint(Vector2.all(11)), false); + blockA.scale = Vector2.all(2.0); + game.update(0); + expect(blockA.containsPoint(Vector2.all(11)), true); + }); }, ); } diff --git a/packages/flame/test/effects/combined_effect_test.dart b/packages/flame/test/effects/combined_effect_test.dart index d075b5b9b..d4478e175 100644 --- a/packages/flame/test/effects/combined_effect_test.dart +++ b/packages/flame/test/effects/combined_effect_test.dart @@ -27,7 +27,7 @@ void main() { bool isAlternating = false, bool hasAlternatingMoveEffect = false, bool hasAlternatingRotateEffect = false, - bool hasAlternatingScaleEffect = false, + bool hasAlternatingSizeEffect = false, }) { final move = MoveEffect( path: path, @@ -39,10 +39,10 @@ void main() { duration: randomDuration(), isAlternating: hasAlternatingRotateEffect, )..skipEffectReset = true; - final scale = ScaleEffect( + final scale = SizeEffect( size: argumentSize, duration: randomDuration(), - isAlternating: hasAlternatingScaleEffect, + isAlternating: hasAlternatingSizeEffect, )..skipEffectReset = true; return CombinedEffect( effects: [move, scale, rotate], @@ -169,13 +169,13 @@ void main() { ); testWidgets( - 'CombinedEffect can contain alternating ScaleEffect', + 'CombinedEffect can contain alternating SizeEffect', (WidgetTester tester) async { final PositionComponent positionComponent = component(); effectTest( tester, positionComponent, - effect(hasAlternatingScaleEffect: true), + effect(hasAlternatingSizeEffect: true), expectedPosition: path.last, expectedAngle: argumentAngle, expectedSize: positionComponent.size.clone(), diff --git a/packages/flame/test/effects/effect_test_utils.dart b/packages/flame/test/effects/effect_test_utils.dart index 587046b0e..e4090327c 100644 --- a/packages/flame/test/effects/effect_test_utils.dart +++ b/packages/flame/test/effects/effect_test_utils.dart @@ -25,9 +25,11 @@ void effectTest( double expectedAngle = 0.0, Vector2? expectedPosition, Vector2? expectedSize, + Vector2? expectedScale, }) async { expectedPosition ??= Vector2.zero(); expectedSize ??= Vector2.all(100.0); + expectedScale ??= Vector2.all(1.0); final callback = Callback(); effect.onComplete = callback.call; final game = BaseGame(); @@ -63,6 +65,11 @@ void effectTest( expectedSize, reason: 'Size is not correct', ); + expectVector2( + component.scale, + expectedScale, + reason: 'Scale is not correct', + ); } else { // To account for float number operations making effects not finish const epsilon = 0.001; @@ -87,6 +94,11 @@ void effectTest( expectedSize, reason: 'Size is not exactly correct', ); + expectVector2( + component.scale, + expectedScale, + reason: 'Scale is not exactly correct', + ); } expect(effect.hasCompleted(), shouldComplete, reason: 'Effect shouldFinish'); game.update(0.0); @@ -103,11 +115,13 @@ class TestComponent extends PositionComponent { TestComponent({ Vector2? position, Vector2? size, + Vector2? scale, double angle = 0.0, Anchor anchor = Anchor.center, }) : super( position: position, size: size ?? Vector2.all(100.0), + scale: scale, angle: angle, anchor: anchor, ); diff --git a/packages/flame/test/effects/scale_effect_test.dart b/packages/flame/test/effects/scale_effect_test.dart index 210335cf3..a935d04fe 100644 --- a/packages/flame/test/effects/scale_effect_test.dart +++ b/packages/flame/test/effects/scale_effect_test.dart @@ -9,12 +9,12 @@ import 'effect_test_utils.dart'; void main() { final random = Random(); Vector2 randomVector2() => (Vector2.random(random) * 100)..round(); - final argumentSize = randomVector2(); - TestComponent component() => TestComponent(size: randomVector2()); + final argumentScale = randomVector2(); + TestComponent component() => TestComponent(scale: randomVector2()); ScaleEffect effect({bool isInfinite = false, bool isAlternating = false}) { return ScaleEffect( - size: argumentSize, + scale: argumentScale, duration: 1 + random.nextInt(100).toDouble(), isInfinite: isInfinite, isAlternating: isAlternating, @@ -26,7 +26,7 @@ void main() { tester, component(), effect(), - expectedSize: argumentSize, + expectedScale: argumentScale, ); }); @@ -37,7 +37,7 @@ void main() { tester, component(), effect(), - expectedSize: argumentSize, + expectedScale: argumentScale, iterations: 1.5, ); }, @@ -49,7 +49,7 @@ void main() { tester, positionComponent, effect(isAlternating: true), - expectedSize: positionComponent.size.clone(), + expectedScale: positionComponent.scale.clone(), ); }); @@ -61,7 +61,7 @@ void main() { tester, positionComponent, effect(isInfinite: true, isAlternating: true), - expectedSize: positionComponent.size.clone(), + expectedScale: positionComponent.scale.clone(), shouldComplete: false, ); }, @@ -73,7 +73,7 @@ void main() { tester, positionComponent, effect(isAlternating: true), - expectedSize: argumentSize, + expectedScale: argumentScale, shouldComplete: false, iterations: 0.5, ); @@ -85,7 +85,7 @@ void main() { tester, positionComponent, effect(isInfinite: true), - expectedSize: argumentSize, + expectedScale: argumentScale, iterations: 3.0, shouldComplete: false, ); diff --git a/packages/flame/test/effects/sequence_effect_test.dart b/packages/flame/test/effects/sequence_effect_test.dart index 1c45bc3d4..287239fd4 100644 --- a/packages/flame/test/effects/sequence_effect_test.dart +++ b/packages/flame/test/effects/sequence_effect_test.dart @@ -28,7 +28,7 @@ void main() { bool isAlternating = false, bool hasAlternatingMoveEffect = false, bool hasAlternatingRotateEffect = false, - bool hasAlternatingScaleEffect = false, + bool hasAlternatingSizeEffect = false, }) { final move = MoveEffect( path: path, @@ -40,10 +40,10 @@ void main() { duration: randomDuration(), isAlternating: hasAlternatingRotateEffect, )..skipEffectReset = true; - final scale = ScaleEffect( + final scale = SizeEffect( size: argumentSize, duration: randomDuration(), - isAlternating: hasAlternatingScaleEffect, + isAlternating: hasAlternatingSizeEffect, )..skipEffectReset = true; return SequenceEffect( effects: [move, scale, rotate], @@ -169,13 +169,13 @@ void main() { ); testWidgets( - 'SequenceEffect can contain alternating ScaleEffect', + 'SequenceEffect can contain alternating SizeEffect', (WidgetTester tester) async { final PositionComponent positionComponent = component(); effectTest( tester, positionComponent, - effect(hasAlternatingScaleEffect: true), + effect(hasAlternatingSizeEffect: true), expectedPosition: path.last, expectedAngle: argumentAngle, expectedSize: positionComponent.size.clone(), diff --git a/packages/flame/test/effects/size_effect_test.dart b/packages/flame/test/effects/size_effect_test.dart new file mode 100644 index 000000000..3b3856488 --- /dev/null +++ b/packages/flame/test/effects/size_effect_test.dart @@ -0,0 +1,93 @@ +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'effect_test_utils.dart'; + +void main() { + final random = Random(); + Vector2 randomVector2() => (Vector2.random(random) * 100)..round(); + final argumentSize = randomVector2(); + TestComponent component() => TestComponent(size: randomVector2()); + + SizeEffect effect({bool isInfinite = false, bool isAlternating = false}) { + return SizeEffect( + size: argumentSize, + duration: 1 + random.nextInt(100).toDouble(), + isInfinite: isInfinite, + isAlternating: isAlternating, + )..skipEffectReset = true; + } + + testWidgets('SizeEffect can scale', (WidgetTester tester) async { + effectTest( + tester, + component(), + effect(), + expectedSize: argumentSize, + ); + }); + + testWidgets( + 'SizeEffect will stop scaling after it is done', + (WidgetTester tester) async { + effectTest( + tester, + component(), + effect(), + expectedSize: argumentSize, + iterations: 1.5, + ); + }, + ); + + testWidgets('SizeEffect can alternate', (WidgetTester tester) async { + final PositionComponent positionComponent = component(); + effectTest( + tester, + positionComponent, + effect(isAlternating: true), + expectedSize: positionComponent.size.clone(), + ); + }); + + testWidgets( + 'SizeEffect can alternate and be infinite', + (WidgetTester tester) async { + final PositionComponent positionComponent = component(); + effectTest( + tester, + positionComponent, + effect(isInfinite: true, isAlternating: true), + expectedSize: positionComponent.size.clone(), + shouldComplete: false, + ); + }, + ); + + testWidgets('SizeEffect alternation can peak', (WidgetTester tester) async { + final PositionComponent positionComponent = component(); + effectTest( + tester, + positionComponent, + effect(isAlternating: true), + expectedSize: argumentSize, + shouldComplete: false, + iterations: 0.5, + ); + }); + + testWidgets('SizeEffect can be infinite', (WidgetTester tester) async { + final PositionComponent positionComponent = component(); + effectTest( + tester, + positionComponent, + effect(isInfinite: true), + expectedSize: argumentSize, + iterations: 3.0, + shouldComplete: false, + ); + }); +} diff --git a/packages/flame/test/game/camera_and_viewport_test.dart b/packages/flame/test/game/camera_and_viewport_test.dart index 4ee768c9a..1e8e84b40 100644 --- a/packages/flame/test/game/camera_and_viewport_test.dart +++ b/packages/flame/test/game/camera_and_viewport_test.dart @@ -127,7 +127,7 @@ void main() { ), [ 'transform(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0)', // camera translation - 'translate(10.0, 10.0)', // position component translation + 'transform(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 10.0, 10.0, 0.0, 1.0)', // position component translate 'translate(0.0, 0.0)', // position component anchor ], ); @@ -155,7 +155,7 @@ void main() { ), [ 'transform(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, -4.0, -4.0, 0.0, 1.0)', // camera translation - 'translate(10.0, 10.0)', // position component translation + 'transform(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 10.0, 10.0, 0.0, 1.0)', // position component translate 'translate(0.0, 0.0)', // position component anchor ], ); @@ -200,7 +200,7 @@ void main() { ), [ 'transform(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 40.0, 30.0, 0.0, 1.0)', // camera translation - 'translate(10.0, 20.0)', // position component translation + 'transform(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 10.0, 20.0, 0.0, 1.0)', // position component translate 'translate(-0.5, -0.5)', // position component anchor ], // result: 50 - w/2, 50 - h/2 (perfectly centered) @@ -231,7 +231,7 @@ void main() { ), [ 'transform(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, -550.0, -1920.0, 0.0, 1.0)', // camera translation - 'translate(600.0, 2000.0)', // position component translation + 'transform(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 600.0, 2000.0, 0.0, 1.0)', // position component translation 'translate(-0.5, -0.5)', // position component anchor ], // result: 50 - w/2, 80 - h/2 (respects fractional relative offset) @@ -323,7 +323,7 @@ void main() { ), [ 'transform(2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 1.0)', // camera translation and zoom - 'translate(100.0, 100.0)', // position component + 'transform(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 100.0, 100.0, 0.0, 1.0)', // position component translate 'translate(-0.5, -0.5)', // anchor ], ); @@ -346,7 +346,7 @@ void main() { ), [ 'transform(2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 100.0, 100.0, 0.0, 1.0)', // camera translation and zoom - 'translate(100.0, 100.0)', // position component + 'transform(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 100.0, 100.0, 0.0, 1.0)', // position component translate 'translate(-0.5, -0.5)', // anchor ], ); diff --git a/packages/flame/test/memory_cache_test.dart b/packages/flame/test/memory_cache_test.dart index 9f74ba8be..28a603119 100644 --- a/packages/flame/test/memory_cache_test.dart +++ b/packages/flame/test/memory_cache_test.dart @@ -1,7 +1,6 @@ +import 'package:flame/src/components/cache/memory_cache.dart'; import 'package:test/test.dart'; -import 'package:flame/src/memory_cache.dart'; - void main() { group('MemoryCache', () { test('basic cache addition', () {