fix: angleTo and lookAt should consider parental transformations (#3629)

Fix `angleTo` and `lookAt` considering parental transformations, notably
the angle and flips of parents.

Closes: #3625

---------

Co-authored-by: Lukas Klingsbo <lukas.klingsbo@gmail.com>
Co-authored-by: Lukas Klingsbo <me@lukas.fyi>
This commit is contained in:
Luan Nico
2025-06-27 09:39:41 -04:00
committed by GitHub
parent bfbb49f5b6
commit e6f3d10557
8 changed files with 496 additions and 117 deletions

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math';
import 'package:flame/components.dart';
@ -7,8 +8,10 @@ import 'package:flame/game.dart';
import 'package:flame/palette.dart';
import 'package:flame/sprite.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class LookAtExample extends FlameGame {
class LookAtExample extends FlameGame<_TapWorld>
with HasKeyboardHandlerComponents {
static const description = 'This example demonstrates how a component can be '
'made to look at a specific target using the lookAt method. Tap anywhere '
'to change the target point for both the choppers. '
@ -18,8 +21,7 @@ class LookAtExample extends FlameGame {
LookAtExample() : super(world: _TapWorld());
late SpriteAnimationComponent _chopper1;
late SpriteAnimationComponent _chopper2;
late List<_ChopperParent> _choppers;
@override
Color backgroundColor() => const Color.fromARGB(255, 96, 145, 112);
@ -32,36 +34,113 @@ class LookAtExample extends FlameGame {
);
_spawnChoppers(spriteSheet);
_spawnInfoText();
}
void _spawnChoppers(SpriteSheet spriteSheet) {
_choppers = [
// Notice now the nativeAngle is set to pi because the chopper
// is facing in down/south direction in the original image.
world.add(
_chopper1 = SpriteAnimationComponent(
_ChopperParent(
position: Vector2(0, -200),
chopper: SpriteAnimationComponent(
nativeAngle: pi,
size: Vector2.all(128),
anchor: Anchor.center,
animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05),
),
);
),
// This chopper does not use correct nativeAngle, hence using
// lookAt on it results in the sprite pointing in incorrect
// direction visually.
world.add(
_chopper2 = SpriteAnimationComponent(
_ChopperParent(
position: Vector2(0, 200),
chopper: SpriteAnimationComponent(
size: Vector2.all(128),
anchor: Anchor.center,
animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05),
position: Vector2(0, 160),
),
),
];
world.addAll(_choppers);
}
}
class _TapWorld extends World
with TapCallbacks, KeyboardHandler, HasGameReference<LookAtExample> {
final CircleComponent target = CircleComponent(
radius: 5,
anchor: Anchor.center,
paint: BasicPalette.black.paint(),
);
int _currentFlipIdx = 0;
final _flips = [
(Vector2(1, 1), Vector2(1, 1)),
(Vector2(1, 1), Vector2(1, -1)),
(Vector2(1, 1), Vector2(-1, 1)),
(Vector2(1, 1), Vector2(-1, -1)),
(Vector2(1, -1), Vector2(1, 1)),
(Vector2(1, -1), Vector2(1, -1)),
(Vector2(1, -1), Vector2(-1, 1)),
(Vector2(1, -1), Vector2(-1, -1)),
(Vector2(-1, 1), Vector2(1, 1)),
(Vector2(-1, 1), Vector2(1, -1)),
(Vector2(-1, 1), Vector2(-1, 1)),
(Vector2(-1, 1), Vector2(-1, -1)),
(Vector2(-1, -1), Vector2(1, 1)),
(Vector2(-1, -1), Vector2(1, -1)),
(Vector2(-1, -1), Vector2(-1, 1)),
(Vector2(-1, -1), Vector2(-1, -1)),
];
@override
bool onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
if (event is KeyDownEvent) {
if (keysPressed.contains(LogicalKeyboardKey.keyF)) {
_cycleFlips();
return true;
}
}
return false;
}
// Just displays some information. No functional contribution to the example.
void _spawnInfoText() {
@override
void onTapDown(TapDownEvent event) {
_updatePosition(event.localPosition);
}
void _cycleFlips() {
_currentFlipIdx = (_currentFlipIdx + 1) % _flips.length;
final nextFlip = _flips[_currentFlipIdx];
for (final parent in game._choppers) {
parent.scale = nextFlip.$1;
parent.chopper.scale = nextFlip.$2;
}
}
void _updatePosition(Vector2 position) {
if (!target.isMounted) {
add(target);
}
target.position = position;
for (final parent in game._choppers) {
parent.chopper.lookAt(position);
}
}
}
class _ChopperParent extends PositionComponent
with HasGameReference<LookAtExample> {
final PositionComponent chopper;
late TextBoxComponent textBox;
_ChopperParent({
required super.position,
required this.chopper,
}) : super(children: [chopper]);
@override
FutureOr<void> onLoad() {
final shaded = TextPaint(
style: TextStyle(
color: BasicPalette.white.color,
@ -71,43 +150,41 @@ class LookAtExample extends FlameGame {
],
),
);
world.add(
TextComponent(
text: 'nativeAngle = pi',
textRenderer: shaded,
parent!.add(
textBox = TextBoxComponent(
text: '-',
position: position + Vector2(0, -150),
anchor: Anchor.center,
position: _chopper1.absolutePosition + Vector2(0, -70),
),
);
world.add(
TextComponent(
text: 'nativeAngle = 0',
textRenderer: shaded,
anchor: Anchor.center,
position: _chopper2.absolutePosition + Vector2(0, -70),
align: Anchor.topCenter,
textRenderer: shaded,
boxConfig: const TextBoxConfig(
maxWidth: 600,
),
),
);
return super.onLoad();
}
}
class _TapWorld extends World with TapCallbacks {
final CircleComponent _targetComponent = CircleComponent(
radius: 5,
anchor: Anchor.center,
paint: BasicPalette.black.paint(),
);
@override
void onTapDown(TapDownEvent event) {
if (!_targetComponent.isMounted) {
add(_targetComponent);
void update(double dt) {
final angleTo = chopper.angleTo(game.world.target.position);
textBox.text = '''
nativeAngle = ${chopper.nativeAngle.toStringAsFixed(2)}
angleTo = ${angleTo.toStringAsFixed(2)}
absoluteAngle = ${chopper.absoluteAngle.toStringAsFixed(2)}
absoluteScale = ${_asSigns(chopper.absoluteScale)} (${_asSigns(absoluteScale)} * ${_asSigns(chopper.scale)})
''';
}
_targetComponent.position = event.localPosition;
final choppers = children.query<SpriteAnimationComponent>();
for (final chopper in choppers) {
chopper.lookAt(event.localPosition);
String _asSigns(Vector2 v) {
return '[${_asSign(v.x)}, ${_asSign(v.y)}]';
}
String _asSign(double value) {
return switch (value.sign) {
1 => '+',
-1 => '-',
_ => '0',
};
}
}

View File

@ -1,14 +1,12 @@
import 'dart:math' as math;
import 'dart:ui' hide Offset;
import 'package:collection/collection.dart';
import 'package:flame/camera.dart';
import 'package:flame/extensions.dart';
import 'package:flame/geometry.dart';
import 'package:flame/src/anchor.dart';
import 'package:flame/src/components/core/component.dart';
import 'package:flame/src/components/mixins/coordinate_transform.dart';
import 'package:flame/src/effects/provider_interfaces.dart';
import 'package:flame/src/extensions/offset.dart';
import 'package:flame/src/extensions/vector2.dart';
import 'package:flame/src/game/notifying_vector2.dart';
import 'package:flame/src/game/transform2d.dart';
import 'package:flame/src/rendering/decorator.dart';
@ -148,7 +146,7 @@ class PositionComponent extends Component
@override
double get angle => transform.angle;
@override
set angle(double a) => transform.angle = a;
set angle(double a) => transform.angle = a.toNormalizedAngle();
/// The scale factor of this component. The scale can be different along
/// the X and Y dimensions. A scale greater than 1 makes the component
@ -229,21 +227,46 @@ class PositionComponent extends Component
);
}
/// The resulting angle after all the ancestors and the components own angle
/// has been applied.
/// The resulting angle after all the ancestors and the components own angles
/// and scales have been applied.
double get absoluteAngle {
// TODO(spydon): take scale into consideration
return ancestors(includeSelf: true)
.whereType<ReadOnlyAngleProvider>()
.map((c) => c.angle)
.sum;
var angle = 0.0;
var totalScaleX = 1.0;
var totalScaleY = 1.0;
final ancestorChain = ancestors(includeSelf: true).toList(growable: false)
..reverse();
for (final ancestor in ancestorChain) {
if (ancestor is ReadOnlyScaleProvider) {
final ancestorScale = (ancestor as ReadOnlyScaleProvider).scale;
totalScaleX *= ancestorScale.x;
totalScaleY *= ancestorScale.y;
if (ancestorScale.x.isNegative) {
angle *= -1;
}
if (ancestorScale.y.isNegative) {
angle -= math.pi - angle;
}
}
if (ancestor is ReadOnlyAngleProvider) {
final reflected = totalScaleX.isNegative ^ totalScaleY.isNegative;
final localAngle = (ancestor as ReadOnlyAngleProvider).angle;
angle += reflected ? -localAngle : localAngle;
}
}
return angle.toNormalizedAngle();
}
/// The resulting scale after all the ancestors and the components own scale
/// has been applied.
Vector2 get absoluteScale {
return ancestors().whereType<PositionComponent>().fold<Vector2>(
scale.clone(),
Vector2 get absoluteScale => scale.clone()..multiply(_parentAbsoluteScale);
Vector2 get _parentAbsoluteScale {
return ancestors().whereType<ReadOnlyScaleProvider>().fold<Vector2>(
Vector2.all(1.0),
(totalScale, c) => totalScale..multiply(c.scale),
);
}
@ -362,21 +385,41 @@ class PositionComponent extends Component
Vector2 get absoluteCenter => absolutePositionOfAnchor(Anchor.center);
/// Returns the angle formed by component's orientation vector and a vector
/// starting at component's absolute position and ending at [target]. This
/// angle is measured in clockwise direction. [target] should be in absolute/world
/// coordinate system.
/// starting at component's absolute position and ending at [target]. I.e.
/// how much the current component need to rotate to face the target. This
/// angle is measured in clockwise direction. [target] should be in
/// absolute/world coordinate system.
///
/// Uses [nativeAngle] to decide the orientation direction of the component.
/// See [lookAt] to make the component instantly rotate towards target.
///
/// Note: If target coincides with the current component, then it is treated
/// as being north.
/// Note: If target coincides with the current component's position, then it
/// is treated as being north.
double angleTo(Vector2 target) {
return math.atan2(
target.x - absolutePosition.x,
absolutePosition.y - target.y,
) -
(nativeAngle + absoluteAngle);
final direction = target - absolutePosition;
if (direction.isZero()) {
// If the target coincides with the component's position, we treat it as
// being north.
return -nativeAngle % tau;
}
final parentAbsoluteScale = _parentAbsoluteScale;
final targetAngle = math.atan2(
direction.x * scale.x.sign,
-direction.y * scale.y.sign,
);
final angleDifference = targetAngle - absoluteAngle - nativeAngle;
final hasOddFlips = parentAbsoluteScale.x.isNegative ^
parentAbsoluteScale.y.isNegative ^
scale.x.isNegative ^
scale.y.isNegative;
final hasSelfYFlip =
!parentAbsoluteScale.y.isNegative && scale.y.isNegative;
final result = (hasOddFlips ? -1 : 1) * angleDifference +
(hasSelfYFlip ? 1 : 0) * math.pi;
return result.toNormalizedAngle();
}
/// Rotates/snaps the component to look at the [target].
@ -386,7 +429,9 @@ class PositionComponent extends Component
/// [target] should to be in absolute/world coordinate system.
///
/// See also: [angleTo]
void lookAt(Vector2 target) => angle += angleTo(target);
void lookAt(Vector2 target) {
angle += angleTo(target);
}
//#endregion

View File

@ -1,3 +1,7 @@
import 'dart:math';
import 'package:flame/geometry.dart';
extension DoubleExtension on double {
/// Converts +-[infinity] to +-[maxFinite].
/// If it is already a finite value, that is returned.
@ -10,4 +14,16 @@ extension DoubleExtension on double {
return this;
}
}
/// This method normalizes the angle in radians to the range [-π, π].
///
/// If the angle is already within this range, it is returned unchanged.
double toNormalizedAngle() {
if (this >= -pi && this <= pi) {
return this;
}
final normalized = this % tau;
return normalized > pi ? normalized - tau : normalized;
}
}

View File

@ -1,9 +1,9 @@
import 'dart:math';
import 'dart:ui';
import 'package:canvas_test/canvas_test.dart';
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flame/geometry.dart';
import 'package:flame_test/flame_test.dart';
@ -903,14 +903,21 @@ void main() {
final target = targets.elementAt(i);
final angle = expectedAngles.elementAt(i);
final result = component.angleTo(target);
expectDouble(
component.angleTo(target),
angle - component.angle,
result,
(angle - component.angle).toNormalizedAngle(),
epsilon: 1e-10,
reason: 'angleTo $i ($angle)',
);
component.lookAt(target);
expectDouble(component.angle, angle, epsilon: 1e-10);
expectDouble(
component.angle,
angle.toNormalizedAngle(),
epsilon: 1e-10,
reason: 'lookAt $i ($angle)',
);
}
});
@ -923,7 +930,7 @@ void main() {
Vector2(-1, 0),
Vector2.all(-50),
];
final expectedAngles = [pi / 2, (pi / 4), -pi, (-3 * pi / 4)];
final expectedAngles = [pi / 2, pi / 4, pi, -3 * pi / 4];
for (var i = 0; i < targets.length; ++i) {
final target = targets.elementAt(i);
@ -931,12 +938,18 @@ void main() {
expectDouble(
component.angleTo(target),
angle - component.angle,
(angle - component.angle).toNormalizedAngle(),
epsilon: 1e-10,
reason: 'angleTo $i ($angle)',
);
component.lookAt(target);
expectDouble(component.angle, angle, epsilon: 1e-10);
expectDouble(
component.angle,
angle.toNormalizedAngle(),
epsilon: 1e-10,
reason: 'lookAt $i ($angle)',
);
}
});
@ -971,12 +984,18 @@ void main() {
expectDouble(
component.angleTo(target),
angle - component.angle,
(angle - component.angle).toNormalizedAngle(),
epsilon: 1e-10,
reason: 'angleTo $i ($angle)',
);
component.lookAt(target);
expectDouble(component.angle, angle, epsilon: 1e-10);
expectDouble(
component.angle,
angle.toNormalizedAngle(),
epsilon: 1e-10,
reason: 'lookAt $i ($angle)',
);
}
});
@ -987,7 +1006,88 @@ void main() {
component.nativeAngle = 3 * pi / 2;
component.lookAt(component.absolutePosition);
expectDouble(component.angle, -component.nativeAngle, epsilon: 1e-10);
expectDouble(
component.angle,
-component.nativeAngle.toNormalizedAngle(),
epsilon: 1e-10,
);
});
test('lookAt with parental flips', () {
final child = PositionComponent();
final wrapper = PositionComponent(
children: [child],
);
final flips = [
(Vector2(1, 1), Vector2(1, 1)),
(Vector2(1, 1), Vector2(1, -1)),
(Vector2(1, 1), Vector2(-1, 1)),
(Vector2(1, 1), Vector2(-1, -1)),
(Vector2(1, -1), Vector2(1, 1)),
(Vector2(1, -1), Vector2(1, -1)),
(Vector2(1, -1), Vector2(-1, 1)),
(Vector2(1, -1), Vector2(-1, -1)),
(Vector2(-1, 1), Vector2(1, 1)),
(Vector2(-1, 1), Vector2(1, -1)),
(Vector2(-1, 1), Vector2(-1, 1)),
(Vector2(-1, 1), Vector2(-1, -1)),
(Vector2(-1, -1), Vector2(1, 1)),
(Vector2(-1, -1), Vector2(1, -1)),
(Vector2(-1, -1), Vector2(-1, 1)),
(Vector2(-1, -1), Vector2(-1, -1)),
];
final notableAngles = List.generate(8, (i) => i * tau / 8);
final expectedResults = [
0, 1, 2, 3, 4, -3, -2, -1, //
-4, -3, -2, -1, 0, 1, 2, 3, //
0, 1, 2, 3, 4, -3, -2, -1, //
4, -3, -2, -1, 0, 1, 2, 3, //
-4, 3, 2, 1, 0, -1, -2, -3, //
0, -1, -2, -3, -4, 3, 2, 1, //
-4, 3, 2, 1, 0, -1, -2, -3, //
0, -1, -2, -3, 4, 3, 2, 1, //
0, -1, -2, -3, -4, 3, 2, 1, //
4, 3, 2, 1, 0, -1, -2, -3, //
0, -1, -2, -3, -4, 3, 2, 1, //
4, 3, 2, 1, 0, -1, -2, -3, //
4, -3, -2, -1, 0, 1, 2, 3, //
0, 1, 2, 3, 4, -3, -2, -1, //
4, -3, -2, -1, 0, 1, 2, 3, //
0, 1, 2, 3, -4, -3, -2, -1, //
0, 1, 2, 3, 4, -3, -2, -1, //
-4, -3, -2, -1, 0, 1, 2, 3, //
0, 1, 2, 3, 4, -3, -2, -1, //
-4, -3, -2, -1, 0, 1, 2, 3, //
-4, 3, 2, 1, 0, -1, -2, -3, //
0, -1, -2, -3, 4, 3, 2, 1, //
-4, 3, 2, 1, 0, -1, -2, -3, //
0, -1, -2, -3, 4, 3, 2, 1, //
0, -1, -2, -3, 4, 3, 2, 1, //
-4, 3, 2, 1, 0, -1, -2, -3, //
0, -1, -2, -3, 4, 3, 2, 1, //
-4, 3, 2, 1, 0, -1, -2, -3, //
-4, -3, -2, -1, 0, 1, 2, 3, //
0, 1, 2, 3, 4, -3, -2, -1, //
-4, -3, -2, -1, 0, 1, 2, 3, //
0, 1, 2, 3, 4, -3, -2, -1, //
];
var idx = 0;
for (final flip in flips) {
wrapper.scale = flip.$1;
child.scale = flip.$2;
for (final angle in notableAngles) {
final target = Vector2(0, -1)..rotate(angle);
expectDouble(
child.angleTo(target),
expectedResults[idx++] * tau / 8,
epsilon: 1e-10,
reason: 'angleTo with flip $flip, angle $angle, target $target',
);
}
}
});
});
@ -1148,12 +1248,101 @@ void main() {
expect(child.toAbsoluteRect(), const Rect.fromLTWH(7, 13, 1, 1));
});
});
group('absoluteAngle', () {
test('absoluteAngle with no parent', () {
final component = PositionComponent();
expect(component.absoluteAngle, 0.0);
component.angle = pi / 2;
expect(component.absoluteAngle, pi / 2);
});
testWithFlameGame('absoluteAngle with parent', (game) async {
final parent = PositionComponent()..angle = pi / 4;
final child = PositionComponent();
parent.add(child);
game.add(parent);
await game.ready();
expect(child.absoluteAngle, pi / 4);
child.angle = pi / 2;
expect(child.absoluteAngle, 3 * pi / 4);
});
testWithFlameGame('absoluteAngle with parent and child rotated',
(game) async {
final parent = PositionComponent()..angle = pi / 8;
final child = PositionComponent()..angle = pi / 8;
parent.add(child);
game.add(parent);
await game.ready();
expect(child.absoluteAngle, pi / 4);
parent.angle = pi / 4;
child.angle = pi / 2;
expect(child.absoluteAngle, 3 * pi / 4);
});
testWithFlameGame('absoluteAngle with flipped parent', (game) async {
final parent = _MyDebugComponent(name: 'parent')..angle = pi / 4;
final child = _MyDebugComponent(name: 'child');
parent.add(child);
game.add(parent);
await game.ready();
expect(child.absoluteAngle, pi / 4);
parent.flipHorizontally();
expect(child.absoluteAngle, -pi / 4);
parent.flipVertically();
expect(child.absoluteAngle, (pi / 4 + pi).toNormalizedAngle());
});
testWithFlameGame('absoluteAngle with flipped child', (game) async {
final parent = PositionComponent();
final child = PositionComponent()..angle = pi / 4;
parent.add(child);
game.add(parent);
await game.ready();
expect(child.absoluteAngle, pi / 4);
child.flipHorizontally();
expect(child.absoluteAngle, -pi / 4);
child.flipVertically();
expect(child.absoluteAngle, (pi / 4 + pi).toNormalizedAngle());
});
testWithFlameGame('absoluteAngle with flipped child and parent',
(game) async {
final parent = PositionComponent()..angle = pi / 8;
final child = PositionComponent()..angle = pi / 8;
parent.add(child);
game.add(parent);
await game.ready();
expect(child.absoluteAngle, pi / 4);
child.flipHorizontally();
expect(child.absoluteAngle, -pi / 4);
parent.flipVertically();
expect(child.absoluteAngle, (pi / 4 + pi).toNormalizedAngle());
});
});
});
}
class _MyHitboxComponent extends PositionComponent with GestureHitboxes {}
class _MyDebugComponent extends PositionComponent {
_MyDebugComponent({this.name});
final String? name;
@override
bool get debugMode => true;
@override
String toString() {
return name != null
? '$name(angle: $angle, scale: $scale)'
: super.toString();
}
}

View File

@ -1,7 +1,6 @@
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/extensions.dart';
import 'package:flame/geometry.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
@ -100,7 +99,7 @@ void main() {
expect(effect.controller.duration, tau);
game.update(tau);
expect(component.angle, closeTo(tau, 1e-15));
expect(component.angle, closeTo(tau.toNormalizedAngle(), 1e-15));
});
testWithFlameGame('reset', (game) async {

View File

@ -1,8 +1,8 @@
import 'dart:math';
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/extensions.dart';
import 'package:flame/geometry.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
@ -130,19 +130,28 @@ void main() {
expect(component.position.y, closeTo(200 - 8 * i, 1e-10));
expect(
component.angle,
closeTo(-asin(0.8) + component.nativeAngle, 1e-7),
closeTo(
(-asin(0.8) + component.nativeAngle).toNormalizedAngle(),
1e-7,
),
);
} else if (i <= 35) {
expect(component.position.x, closeTo(290 + 8 * (i - 15), 1e-10));
expect(component.position.y, closeTo(80 + 6 * (i - 15), 1e-10));
expect(
component.angle,
closeTo(asin(0.6) + component.nativeAngle, 1e-7),
closeTo(
(asin(0.6) + component.nativeAngle).toNormalizedAngle(),
1e-7,
),
);
} else {
expect(component.position.x, closeTo(450 - 10 * (i - 35), 1e-10));
expect(component.position.y, closeTo(200, 1e-10));
expect(component.angle, closeTo(pi + component.nativeAngle, 1e-7));
expect(
component.angle,
closeTo((pi + component.nativeAngle).toNormalizedAngle(), 1e-7),
);
}
game.update(0.1);
}
@ -243,19 +252,28 @@ void main() {
expect(component.position.y, closeTo(200 - 8 * i, 1e-10));
expect(
component.angle,
closeTo(-asin(0.8) + component.nativeAngle, 1e-7),
closeTo(
(-asin(0.8) + component.nativeAngle).toNormalizedAngle(),
1e-7,
),
);
} else if (i <= 35) {
expect(component.position.x, closeTo(290 + 8 * (i - 15), 1e-10));
expect(component.position.y, closeTo(80 + 6 * (i - 15), 1e-10));
expect(
component.angle,
closeTo(asin(0.6) + component.nativeAngle, 1e-7),
closeTo(
(asin(0.6) + component.nativeAngle).toNormalizedAngle(),
1e-7,
),
);
} else {
expect(component.position.x, closeTo(450 - 10 * (i - 35), 1e-10));
expect(component.position.y, closeTo(200, 1e-10));
expect(component.angle, closeTo(pi + component.nativeAngle, 1e-7));
expect(
component.angle,
closeTo((pi + component.nativeAngle).toNormalizedAngle(), 1e-7),
);
}
game.update(0.1);
}

View File

@ -1,6 +1,7 @@
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame/src/effects/controllers/effect_controller.dart';
import 'package:flame/src/effects/rotate_effect.dart';
import 'package:flame_test/flame_test.dart';
@ -59,13 +60,13 @@ void main() {
final effect = RotateEffect.by(1, EffectController(duration: 1));
component.add(effect..removeOnFinish = false);
for (var i = 0; i < 5; i++) {
expect(component.angle, i);
for (var i = 0.0; i < 5; i++) {
expect(component.angle, i.toNormalizedAngle());
// After each reset the object will be rotated by 1 radian relative to
// its orientation at the start of the effect
effect.reset();
game.update(1);
expect(component.angle, i + 1);
expect(component.angle, (i + 1.0).toNormalizedAngle());
}
});
@ -110,7 +111,7 @@ void main() {
for (var i = 0; i < 10; i++) {
game.update(1);
}
expect(component.angle, closeTo(5, 1e-15));
expect(component.angle, closeTo(5.0.toNormalizedAngle(), 1e-15));
expect(component.children.length, 0);
});

View File

@ -1,8 +1,11 @@
import 'dart:math';
import 'package:flame/src/extensions/double.dart';
import 'package:test/test.dart';
void main() {
group('DoubleExtension', () {
group('toFinite', () {
test('Properly converts infinite values to maxFinite', () {
const infinity = double.infinity;
expect(infinity.toFinite(), double.maxFinite);
@ -16,4 +19,35 @@ void main() {
expect((-double.maxFinite).toFinite(), -double.maxFinite);
});
});
group('normalizedAngle', () {
test('Does not convert value within [-pi, pi] range', () {
expect((pi / 2).toNormalizedAngle(), pi / 2);
});
test('Converts value greater than pi to normalized angle', () {
expect((3 * pi / 2).toNormalizedAngle(), -pi / 2);
});
test('Converts value less than -pi to normalized angle', () {
expect((-3 * pi / 2).toNormalizedAngle(), pi / 2);
});
test('Converts value equal to 2pi to normalized angle', () {
expect((2 * pi).toNormalizedAngle(), 0.0);
});
test('Converts value equal to -2pi to normalized angle', () {
expect((-2 * pi).toNormalizedAngle(), 0.0);
});
test('Does not convert value equal to pi', () {
expect(pi.toNormalizedAngle(), pi);
});
test('Does not convert value equal to -pi', () {
expect((-pi).toNormalizedAngle(), -pi);
});
});
});
}