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 'dart:math';
|
||||||
|
|
||||||
import 'package:flame/components.dart';
|
import 'package:flame/components.dart';
|
||||||
@ -7,8 +8,10 @@ import 'package:flame/game.dart';
|
|||||||
import 'package:flame/palette.dart';
|
import 'package:flame/palette.dart';
|
||||||
import 'package:flame/sprite.dart';
|
import 'package:flame/sprite.dart';
|
||||||
import 'package:flutter/material.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 '
|
static const description = 'This example demonstrates how a component can be '
|
||||||
'made to look at a specific target using the lookAt method. Tap anywhere '
|
'made to look at a specific target using the lookAt method. Tap anywhere '
|
||||||
'to change the target point for both the choppers. '
|
'to change the target point for both the choppers. '
|
||||||
@ -18,8 +21,7 @@ class LookAtExample extends FlameGame {
|
|||||||
|
|
||||||
LookAtExample() : super(world: _TapWorld());
|
LookAtExample() : super(world: _TapWorld());
|
||||||
|
|
||||||
late SpriteAnimationComponent _chopper1;
|
late List<_ChopperParent> _choppers;
|
||||||
late SpriteAnimationComponent _chopper2;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Color backgroundColor() => const Color.fromARGB(255, 96, 145, 112);
|
Color backgroundColor() => const Color.fromARGB(255, 96, 145, 112);
|
||||||
@ -32,36 +34,113 @@ class LookAtExample extends FlameGame {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_spawnChoppers(spriteSheet);
|
_spawnChoppers(spriteSheet);
|
||||||
_spawnInfoText();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _spawnChoppers(SpriteSheet spriteSheet) {
|
void _spawnChoppers(SpriteSheet spriteSheet) {
|
||||||
// Notice now the nativeAngle is set to pi because the chopper
|
_choppers = [
|
||||||
// is facing in down/south direction in the original image.
|
// Notice now the nativeAngle is set to pi because the chopper
|
||||||
world.add(
|
// is facing in down/south direction in the original image.
|
||||||
_chopper1 = SpriteAnimationComponent(
|
_ChopperParent(
|
||||||
nativeAngle: pi,
|
position: Vector2(0, -200),
|
||||||
size: Vector2.all(128),
|
chopper: SpriteAnimationComponent(
|
||||||
anchor: Anchor.center,
|
nativeAngle: pi,
|
||||||
animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05),
|
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
|
class _TapWorld extends World
|
||||||
// lookAt on it results in the sprite pointing in incorrect
|
with TapCallbacks, KeyboardHandler, HasGameReference<LookAtExample> {
|
||||||
// direction visually.
|
final CircleComponent target = CircleComponent(
|
||||||
world.add(
|
radius: 5,
|
||||||
_chopper2 = SpriteAnimationComponent(
|
anchor: Anchor.center,
|
||||||
size: Vector2.all(128),
|
paint: BasicPalette.black.paint(),
|
||||||
anchor: Anchor.center,
|
);
|
||||||
animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05),
|
|
||||||
position: Vector2(0, 160),
|
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.
|
@override
|
||||||
void _spawnInfoText() {
|
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(
|
final shaded = TextPaint(
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: BasicPalette.white.color,
|
color: BasicPalette.white.color,
|
||||||
@ -71,43 +150,41 @@ class LookAtExample extends FlameGame {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
parent!.add(
|
||||||
world.add(
|
textBox = TextBoxComponent(
|
||||||
TextComponent(
|
text: '-',
|
||||||
text: 'nativeAngle = pi',
|
position: position + Vector2(0, -150),
|
||||||
textRenderer: shaded,
|
|
||||||
anchor: Anchor.center,
|
anchor: Anchor.center,
|
||||||
position: _chopper1.absolutePosition + Vector2(0, -70),
|
align: Anchor.topCenter,
|
||||||
),
|
textRenderer: shaded,
|
||||||
);
|
boxConfig: const TextBoxConfig(
|
||||||
|
maxWidth: 600,
|
||||||
world.add(
|
),
|
||||||
TextComponent(
|
|
||||||
text: 'nativeAngle = 0',
|
|
||||||
textRenderer: shaded,
|
|
||||||
anchor: Anchor.center,
|
|
||||||
position: _chopper2.absolutePosition + Vector2(0, -70),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return super.onLoad();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class _TapWorld extends World with TapCallbacks {
|
|
||||||
final CircleComponent _targetComponent = CircleComponent(
|
|
||||||
radius: 5,
|
|
||||||
anchor: Anchor.center,
|
|
||||||
paint: BasicPalette.black.paint(),
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onTapDown(TapDownEvent event) {
|
void update(double dt) {
|
||||||
if (!_targetComponent.isMounted) {
|
final angleTo = chopper.angleTo(game.world.target.position);
|
||||||
add(_targetComponent);
|
textBox.text = '''
|
||||||
}
|
nativeAngle = ${chopper.nativeAngle.toStringAsFixed(2)}
|
||||||
_targetComponent.position = event.localPosition;
|
angleTo = ${angleTo.toStringAsFixed(2)}
|
||||||
final choppers = children.query<SpriteAnimationComponent>();
|
absoluteAngle = ${chopper.absoluteAngle.toStringAsFixed(2)}
|
||||||
for (final chopper in choppers) {
|
absoluteScale = ${_asSigns(chopper.absoluteScale)} (${_asSigns(absoluteScale)} * ${_asSigns(chopper.scale)})
|
||||||
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:math' as math;
|
||||||
import 'dart:ui' hide Offset;
|
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flame/camera.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/anchor.dart';
|
||||||
import 'package:flame/src/components/core/component.dart';
|
import 'package:flame/src/components/core/component.dart';
|
||||||
import 'package:flame/src/components/mixins/coordinate_transform.dart';
|
import 'package:flame/src/components/mixins/coordinate_transform.dart';
|
||||||
import 'package:flame/src/effects/provider_interfaces.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/notifying_vector2.dart';
|
||||||
import 'package:flame/src/game/transform2d.dart';
|
import 'package:flame/src/game/transform2d.dart';
|
||||||
import 'package:flame/src/rendering/decorator.dart';
|
import 'package:flame/src/rendering/decorator.dart';
|
||||||
@ -148,7 +146,7 @@ class PositionComponent extends Component
|
|||||||
@override
|
@override
|
||||||
double get angle => transform.angle;
|
double get angle => transform.angle;
|
||||||
@override
|
@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 scale factor of this component. The scale can be different along
|
||||||
/// the X and Y dimensions. A scale greater than 1 makes the component
|
/// 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
|
/// The resulting angle after all the ancestors and the components own angles
|
||||||
/// has been applied.
|
/// and scales have been applied.
|
||||||
double get absoluteAngle {
|
double get absoluteAngle {
|
||||||
// TODO(spydon): take scale into consideration
|
var angle = 0.0;
|
||||||
return ancestors(includeSelf: true)
|
var totalScaleX = 1.0;
|
||||||
.whereType<ReadOnlyAngleProvider>()
|
var totalScaleY = 1.0;
|
||||||
.map((c) => c.angle)
|
|
||||||
.sum;
|
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
|
/// The resulting scale after all the ancestors and the components own scale
|
||||||
/// has been applied.
|
/// has been applied.
|
||||||
Vector2 get absoluteScale {
|
Vector2 get absoluteScale => scale.clone()..multiply(_parentAbsoluteScale);
|
||||||
return ancestors().whereType<PositionComponent>().fold<Vector2>(
|
|
||||||
scale.clone(),
|
Vector2 get _parentAbsoluteScale {
|
||||||
|
return ancestors().whereType<ReadOnlyScaleProvider>().fold<Vector2>(
|
||||||
|
Vector2.all(1.0),
|
||||||
(totalScale, c) => totalScale..multiply(c.scale),
|
(totalScale, c) => totalScale..multiply(c.scale),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -362,21 +385,41 @@ class PositionComponent extends Component
|
|||||||
Vector2 get absoluteCenter => absolutePositionOfAnchor(Anchor.center);
|
Vector2 get absoluteCenter => absolutePositionOfAnchor(Anchor.center);
|
||||||
|
|
||||||
/// Returns the angle formed by component's orientation vector and a vector
|
/// Returns the angle formed by component's orientation vector and a vector
|
||||||
/// starting at component's absolute position and ending at [target]. This
|
/// starting at component's absolute position and ending at [target]. I.e.
|
||||||
/// angle is measured in clockwise direction. [target] should be in absolute/world
|
/// how much the current component need to rotate to face the target. This
|
||||||
/// coordinate system.
|
/// angle is measured in clockwise direction. [target] should be in
|
||||||
|
/// absolute/world coordinate system.
|
||||||
///
|
///
|
||||||
/// Uses [nativeAngle] to decide the orientation direction of the component.
|
/// Uses [nativeAngle] to decide the orientation direction of the component.
|
||||||
/// See [lookAt] to make the component instantly rotate towards target.
|
/// See [lookAt] to make the component instantly rotate towards target.
|
||||||
///
|
///
|
||||||
/// Note: If target coincides with the current component, then it is treated
|
/// Note: If target coincides with the current component's position, then it
|
||||||
/// as being north.
|
/// is treated as being north.
|
||||||
double angleTo(Vector2 target) {
|
double angleTo(Vector2 target) {
|
||||||
return math.atan2(
|
final direction = target - absolutePosition;
|
||||||
target.x - absolutePosition.x,
|
if (direction.isZero()) {
|
||||||
absolutePosition.y - target.y,
|
// If the target coincides with the component's position, we treat it as
|
||||||
) -
|
// being north.
|
||||||
(nativeAngle + absoluteAngle);
|
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].
|
/// 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.
|
/// [target] should to be in absolute/world coordinate system.
|
||||||
///
|
///
|
||||||
/// See also: [angleTo]
|
/// See also: [angleTo]
|
||||||
void lookAt(Vector2 target) => angle += angleTo(target);
|
void lookAt(Vector2 target) {
|
||||||
|
angle += angleTo(target);
|
||||||
|
}
|
||||||
|
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flame/geometry.dart';
|
||||||
|
|
||||||
extension DoubleExtension on double {
|
extension DoubleExtension on double {
|
||||||
/// Converts +-[infinity] to +-[maxFinite].
|
/// Converts +-[infinity] to +-[maxFinite].
|
||||||
/// If it is already a finite value, that is returned.
|
/// If it is already a finite value, that is returned.
|
||||||
@ -10,4 +14,16 @@ extension DoubleExtension on double {
|
|||||||
return this;
|
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:math';
|
||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:canvas_test/canvas_test.dart';
|
import 'package:canvas_test/canvas_test.dart';
|
||||||
import 'package:flame/collisions.dart';
|
import 'package:flame/collisions.dart';
|
||||||
import 'package:flame/components.dart';
|
import 'package:flame/components.dart';
|
||||||
|
import 'package:flame/extensions.dart';
|
||||||
import 'package:flame/game.dart';
|
import 'package:flame/game.dart';
|
||||||
import 'package:flame/geometry.dart';
|
import 'package:flame/geometry.dart';
|
||||||
import 'package:flame_test/flame_test.dart';
|
import 'package:flame_test/flame_test.dart';
|
||||||
@ -903,14 +903,21 @@ void main() {
|
|||||||
final target = targets.elementAt(i);
|
final target = targets.elementAt(i);
|
||||||
final angle = expectedAngles.elementAt(i);
|
final angle = expectedAngles.elementAt(i);
|
||||||
|
|
||||||
|
final result = component.angleTo(target);
|
||||||
expectDouble(
|
expectDouble(
|
||||||
component.angleTo(target),
|
result,
|
||||||
angle - component.angle,
|
(angle - component.angle).toNormalizedAngle(),
|
||||||
epsilon: 1e-10,
|
epsilon: 1e-10,
|
||||||
|
reason: 'angleTo $i ($angle)',
|
||||||
);
|
);
|
||||||
|
|
||||||
component.lookAt(target);
|
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(-1, 0),
|
||||||
Vector2.all(-50),
|
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) {
|
for (var i = 0; i < targets.length; ++i) {
|
||||||
final target = targets.elementAt(i);
|
final target = targets.elementAt(i);
|
||||||
@ -931,12 +938,18 @@ void main() {
|
|||||||
|
|
||||||
expectDouble(
|
expectDouble(
|
||||||
component.angleTo(target),
|
component.angleTo(target),
|
||||||
angle - component.angle,
|
(angle - component.angle).toNormalizedAngle(),
|
||||||
epsilon: 1e-10,
|
epsilon: 1e-10,
|
||||||
|
reason: 'angleTo $i ($angle)',
|
||||||
);
|
);
|
||||||
|
|
||||||
component.lookAt(target);
|
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(
|
expectDouble(
|
||||||
component.angleTo(target),
|
component.angleTo(target),
|
||||||
angle - component.angle,
|
(angle - component.angle).toNormalizedAngle(),
|
||||||
epsilon: 1e-10,
|
epsilon: 1e-10,
|
||||||
|
reason: 'angleTo $i ($angle)',
|
||||||
);
|
);
|
||||||
|
|
||||||
component.lookAt(target);
|
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.nativeAngle = 3 * pi / 2;
|
||||||
component.lookAt(component.absolutePosition);
|
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));
|
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 _MyHitboxComponent extends PositionComponent with GestureHitboxes {}
|
||||||
|
|
||||||
class _MyDebugComponent extends PositionComponent {
|
class _MyDebugComponent extends PositionComponent {
|
||||||
|
_MyDebugComponent({this.name});
|
||||||
|
|
||||||
|
final String? name;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get debugMode => true;
|
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/components.dart';
|
||||||
import 'package:flame/effects.dart';
|
import 'package:flame/effects.dart';
|
||||||
|
import 'package:flame/extensions.dart';
|
||||||
import 'package:flame/geometry.dart';
|
import 'package:flame/geometry.dart';
|
||||||
import 'package:flame_test/flame_test.dart';
|
import 'package:flame_test/flame_test.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@ -100,7 +99,7 @@ void main() {
|
|||||||
|
|
||||||
expect(effect.controller.duration, tau);
|
expect(effect.controller.duration, tau);
|
||||||
game.update(tau);
|
game.update(tau);
|
||||||
expect(component.angle, closeTo(tau, 1e-15));
|
expect(component.angle, closeTo(tau.toNormalizedAngle(), 1e-15));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWithFlameGame('reset', (game) async {
|
testWithFlameGame('reset', (game) async {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:flame/components.dart';
|
import 'package:flame/components.dart';
|
||||||
import 'package:flame/effects.dart';
|
import 'package:flame/effects.dart';
|
||||||
|
import 'package:flame/extensions.dart';
|
||||||
import 'package:flame/geometry.dart';
|
import 'package:flame/geometry.dart';
|
||||||
import 'package:flame_test/flame_test.dart';
|
import 'package:flame_test/flame_test.dart';
|
||||||
import 'package:flutter_test/flutter_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.position.y, closeTo(200 - 8 * i, 1e-10));
|
||||||
expect(
|
expect(
|
||||||
component.angle,
|
component.angle,
|
||||||
closeTo(-asin(0.8) + component.nativeAngle, 1e-7),
|
closeTo(
|
||||||
|
(-asin(0.8) + component.nativeAngle).toNormalizedAngle(),
|
||||||
|
1e-7,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else if (i <= 35) {
|
} else if (i <= 35) {
|
||||||
expect(component.position.x, closeTo(290 + 8 * (i - 15), 1e-10));
|
expect(component.position.x, closeTo(290 + 8 * (i - 15), 1e-10));
|
||||||
expect(component.position.y, closeTo(80 + 6 * (i - 15), 1e-10));
|
expect(component.position.y, closeTo(80 + 6 * (i - 15), 1e-10));
|
||||||
expect(
|
expect(
|
||||||
component.angle,
|
component.angle,
|
||||||
closeTo(asin(0.6) + component.nativeAngle, 1e-7),
|
closeTo(
|
||||||
|
(asin(0.6) + component.nativeAngle).toNormalizedAngle(),
|
||||||
|
1e-7,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
expect(component.position.x, closeTo(450 - 10 * (i - 35), 1e-10));
|
expect(component.position.x, closeTo(450 - 10 * (i - 35), 1e-10));
|
||||||
expect(component.position.y, closeTo(200, 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);
|
game.update(0.1);
|
||||||
}
|
}
|
||||||
@ -243,19 +252,28 @@ void main() {
|
|||||||
expect(component.position.y, closeTo(200 - 8 * i, 1e-10));
|
expect(component.position.y, closeTo(200 - 8 * i, 1e-10));
|
||||||
expect(
|
expect(
|
||||||
component.angle,
|
component.angle,
|
||||||
closeTo(-asin(0.8) + component.nativeAngle, 1e-7),
|
closeTo(
|
||||||
|
(-asin(0.8) + component.nativeAngle).toNormalizedAngle(),
|
||||||
|
1e-7,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else if (i <= 35) {
|
} else if (i <= 35) {
|
||||||
expect(component.position.x, closeTo(290 + 8 * (i - 15), 1e-10));
|
expect(component.position.x, closeTo(290 + 8 * (i - 15), 1e-10));
|
||||||
expect(component.position.y, closeTo(80 + 6 * (i - 15), 1e-10));
|
expect(component.position.y, closeTo(80 + 6 * (i - 15), 1e-10));
|
||||||
expect(
|
expect(
|
||||||
component.angle,
|
component.angle,
|
||||||
closeTo(asin(0.6) + component.nativeAngle, 1e-7),
|
closeTo(
|
||||||
|
(asin(0.6) + component.nativeAngle).toNormalizedAngle(),
|
||||||
|
1e-7,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
expect(component.position.x, closeTo(450 - 10 * (i - 35), 1e-10));
|
expect(component.position.x, closeTo(450 - 10 * (i - 35), 1e-10));
|
||||||
expect(component.position.y, closeTo(200, 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);
|
game.update(0.1);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flame/components.dart';
|
import 'package:flame/components.dart';
|
||||||
|
import 'package:flame/extensions.dart';
|
||||||
import 'package:flame/src/effects/controllers/effect_controller.dart';
|
import 'package:flame/src/effects/controllers/effect_controller.dart';
|
||||||
import 'package:flame/src/effects/rotate_effect.dart';
|
import 'package:flame/src/effects/rotate_effect.dart';
|
||||||
import 'package:flame_test/flame_test.dart';
|
import 'package:flame_test/flame_test.dart';
|
||||||
@ -59,13 +60,13 @@ void main() {
|
|||||||
|
|
||||||
final effect = RotateEffect.by(1, EffectController(duration: 1));
|
final effect = RotateEffect.by(1, EffectController(duration: 1));
|
||||||
component.add(effect..removeOnFinish = false);
|
component.add(effect..removeOnFinish = false);
|
||||||
for (var i = 0; i < 5; i++) {
|
for (var i = 0.0; i < 5; i++) {
|
||||||
expect(component.angle, i);
|
expect(component.angle, i.toNormalizedAngle());
|
||||||
// After each reset the object will be rotated by 1 radian relative to
|
// After each reset the object will be rotated by 1 radian relative to
|
||||||
// its orientation at the start of the effect
|
// its orientation at the start of the effect
|
||||||
effect.reset();
|
effect.reset();
|
||||||
game.update(1);
|
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++) {
|
for (var i = 0; i < 10; i++) {
|
||||||
game.update(1);
|
game.update(1);
|
||||||
}
|
}
|
||||||
expect(component.angle, closeTo(5, 1e-15));
|
expect(component.angle, closeTo(5.0.toNormalizedAngle(), 1e-15));
|
||||||
expect(component.children.length, 0);
|
expect(component.children.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,53 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flame/src/extensions/double.dart';
|
import 'package:flame/src/extensions/double.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('DoubleExtension', () {
|
group('DoubleExtension', () {
|
||||||
test('Properly converts infinite values to maxFinite', () {
|
group('toFinite', () {
|
||||||
const infinity = double.infinity;
|
test('Properly converts infinite values to maxFinite', () {
|
||||||
expect(infinity.toFinite(), double.maxFinite);
|
const infinity = double.infinity;
|
||||||
const negativeInfinity = -double.infinity;
|
expect(infinity.toFinite(), double.maxFinite);
|
||||||
expect(negativeInfinity.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', () {
|
group('normalizedAngle', () {
|
||||||
expect(0.0.toFinite(), 0.0);
|
test('Does not convert value within [-pi, pi] range', () {
|
||||||
expect(double.maxFinite.toFinite(), double.maxFinite);
|
expect((pi / 2).toNormalizedAngle(), pi / 2);
|
||||||
expect((-double.maxFinite).toFinite(), -double.maxFinite);
|
});
|
||||||
|
|
||||||
|
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