From e6f3d105577cd346d377aaaed42d4ceb93aec077 Mon Sep 17 00:00:00 2001 From: Luan Nico Date: Fri, 27 Jun 2025 09:39:41 -0400 Subject: [PATCH] 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 Co-authored-by: Lukas Klingsbo --- .../stories/components/look_at_example.dart | 193 +++++++++++----- .../src/components/position_component.dart | 97 +++++--- packages/flame/lib/src/extensions/double.dart | 16 ++ .../components/position_component_test.dart | 209 +++++++++++++++++- .../speed_effect_controller_test.dart | 5 +- .../effects/move_along_path_effect_test.dart | 32 ++- .../test/effects/rotate_effect_test.dart | 9 +- .../flame/test/extensions/double_test.dart | 52 ++++- 8 files changed, 496 insertions(+), 117 deletions(-) diff --git a/examples/lib/stories/components/look_at_example.dart b/examples/lib/stories/components/look_at_example.dart index 090241c78..5c851045e 100644 --- a/examples/lib/stories/components/look_at_example.dart +++ b/examples/lib/stories/components/look_at_example.dart @@ -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) { - // 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( - nativeAngle: pi, - size: Vector2.all(128), - anchor: Anchor.center, - animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05), + _choppers = [ + // Notice now the nativeAngle is set to pi because the chopper + // is facing in down/south direction in the original image. + _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. + _ChopperParent( + position: Vector2(0, 200), + chopper: SpriteAnimationComponent( + size: Vector2.all(128), + anchor: Anchor.center, + animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05), + ), + ), + ]; + world.addAll(_choppers); + } +} - // 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( - size: Vector2.all(128), - anchor: Anchor.center, - animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05), - position: Vector2(0, 160), - ), - ); +class _TapWorld extends World + with TapCallbacks, KeyboardHandler, HasGameReference { + 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 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 { + final PositionComponent chopper; + late TextBoxComponent textBox; + + _ChopperParent({ + required super.position, + required this.chopper, + }) : super(children: [chopper]); + + @override + FutureOr 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); - } - _targetComponent.position = event.localPosition; - final choppers = children.query(); - for (final chopper in choppers) { - chopper.lookAt(event.localPosition); - } + 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)}) + '''; + } + + String _asSigns(Vector2 v) { + return '[${_asSign(v.x)}, ${_asSign(v.y)}]'; + } + + String _asSign(double value) { + return switch (value.sign) { + 1 => '+', + -1 => '-', + _ => '0', + }; } } diff --git a/packages/flame/lib/src/components/position_component.dart b/packages/flame/lib/src/components/position_component.dart index 5e58c3cb8..5ce090fce 100644 --- a/packages/flame/lib/src/components/position_component.dart +++ b/packages/flame/lib/src/components/position_component.dart @@ -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() - .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().fold( - scale.clone(), + Vector2 get absoluteScale => scale.clone()..multiply(_parentAbsoluteScale); + + Vector2 get _parentAbsoluteScale { + return ancestors().whereType().fold( + 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 diff --git a/packages/flame/lib/src/extensions/double.dart b/packages/flame/lib/src/extensions/double.dart index 89539b693..ef9a0eb6e 100644 --- a/packages/flame/lib/src/extensions/double.dart +++ b/packages/flame/lib/src/extensions/double.dart @@ -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; + } } diff --git a/packages/flame/test/components/position_component_test.dart b/packages/flame/test/components/position_component_test.dart index c68562239..03b226e64 100644 --- a/packages/flame/test/components/position_component_test.dart +++ b/packages/flame/test/components/position_component_test.dart @@ -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(); + } } diff --git a/packages/flame/test/effects/controllers/speed_effect_controller_test.dart b/packages/flame/test/effects/controllers/speed_effect_controller_test.dart index 792884872..1d2cfd673 100644 --- a/packages/flame/test/effects/controllers/speed_effect_controller_test.dart +++ b/packages/flame/test/effects/controllers/speed_effect_controller_test.dart @@ -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 { diff --git a/packages/flame/test/effects/move_along_path_effect_test.dart b/packages/flame/test/effects/move_along_path_effect_test.dart index db2483f67..d5f1378be 100644 --- a/packages/flame/test/effects/move_along_path_effect_test.dart +++ b/packages/flame/test/effects/move_along_path_effect_test.dart @@ -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); } diff --git a/packages/flame/test/effects/rotate_effect_test.dart b/packages/flame/test/effects/rotate_effect_test.dart index 6829d8ca5..5afca5228 100644 --- a/packages/flame/test/effects/rotate_effect_test.dart +++ b/packages/flame/test/effects/rotate_effect_test.dart @@ -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); }); diff --git a/packages/flame/test/extensions/double_test.dart b/packages/flame/test/extensions/double_test.dart index 13ce1ecd8..c0fe47e7c 100644 --- a/packages/flame/test/extensions/double_test.dart +++ b/packages/flame/test/extensions/double_test.dart @@ -1,19 +1,53 @@ +import 'dart:math'; + import 'package:flame/src/extensions/double.dart'; import 'package:test/test.dart'; void main() { group('DoubleExtension', () { - test('Properly converts infinite values to maxFinite', () { - const infinity = double.infinity; - expect(infinity.toFinite(), double.maxFinite); - const negativeInfinity = -double.infinity; - expect(negativeInfinity.toFinite(), -double.maxFinite); + group('toFinite', () { + test('Properly converts infinite values to maxFinite', () { + const infinity = double.infinity; + expect(infinity.toFinite(), double.maxFinite); + const negativeInfinity = -double.infinity; + expect(negativeInfinity.toFinite(), -double.maxFinite); + }); + + test('Does not convert already finite value', () { + expect(0.0.toFinite(), 0.0); + expect(double.maxFinite.toFinite(), double.maxFinite); + expect((-double.maxFinite).toFinite(), -double.maxFinite); + }); }); - test('Does not convert already finite value', () { - expect(0.0.toFinite(), 0.0); - expect(double.maxFinite.toFinite(), double.maxFinite); - 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); + }); }); }); }