mirror of
https://github.com/flame-engine/flame.git
synced 2025-11-02 11:43:19 +08:00
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:
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user