Scale for PositionComponent (#892)

* Draft of PositionComponent.scale

* Use matrix transformations

* Update tests to take matrix transform into consideration

* Add tests for collision detection with scale

* Rename ScaleEffect to SizeEffect

* Use transform matrix to prepare canvas

* Fix scaledSizeCache

* Add changelog entries and docs

* Dartdoc on public access methods

* Update packages/flame/CHANGELOG.md

Co-authored-by: Jochum van der Ploeg <jochum@vdploeg.net>

* Move cache classes to own directory

Co-authored-by: Jochum van der Ploeg <jochum@vdploeg.net>
This commit is contained in:
Lukas Klingsbo
2021-08-06 21:59:52 +02:00
committed by GitHub
parent 4860cac87f
commit 54fbd260bc
31 changed files with 497 additions and 150 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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()),

View File

@ -49,7 +49,7 @@ class InfiniteEffectGame extends BaseGame with TapDetector {
);
redSquare.addEffect(
ScaleEffect(
SizeEffect(
size: p,
speed: 250.0,
curve: Curves.easeInCubic,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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> {
T? value;
List<dynamic> _lastValidCacheValues = <dynamic>[];
ValueCache();
bool isCacheValid<F>(List<F> 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<F>(T value, List<F> validCacheValues) {
this.value = value;
_lastValidCacheValues = validCacheValues;
return value;
}
}

View File

@ -36,25 +36,26 @@ class HudMarginComponent<T extends BaseGame> 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,

View File

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

View File

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

View File

@ -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 extends BaseComponent> {
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,
);
}

View File

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

View File

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

View File

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

View File

@ -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<Iterable<Vector2>>();
final _cachedScaledShape = ValueCache<Iterable<Vector2>>();
/// Gives back the shape vectors multiplied by the size
Iterable<Vector2> scaled() {
@ -82,7 +83,7 @@ class Polygon extends Shape {
return _cachedScaledShape.value!;
}
final _cachedRenderPath = ShapeCache<Path>();
final _cachedRenderPath = ValueCache<Path>();
@override
void render(Canvas canvas, Paint paint) {
@ -114,7 +115,7 @@ class Polygon extends Shape {
canvas.drawPath(_cachedRenderPath.value!, paint);
}
final _cachedHitbox = ShapeCache<List<Vector2>>();
final _cachedHitbox = ValueCache<List<Vector2>>();
/// Gives back the vertices represented as a list of points which
/// are the "corners" of the hitbox rotated with [angle].

View File

@ -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<Vector2> _halfSizeCache = ShapeCache();
final ShapeCache<Vector2> _localCenterCache = ShapeCache();
final ShapeCache<Vector2> _absoluteCenterCache = ShapeCache();
final ValueCache<Vector2> _halfSizeCache = ValueCache();
final ValueCache<Vector2> _localCenterCache = ValueCache();
final ValueCache<Vector2> _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<Vector2> _, 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> {
T? value;
List<dynamic> _lastValidCacheValues = <dynamic>[];
ShapeCache();
bool isCacheValid<F>(List<F> 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<F>(T value, List<F> validCacheValues) {
this.value = value;
_lastValidCacheValues = validCacheValues;
return value;
}
}

View File

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

View File

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

View File

@ -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(),

View File

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

View File

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

View File

@ -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(),

View File

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

View File

@ -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
],
);

View File

@ -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', () {