mirror of
https://github.com/flame-engine/flame.git
synced 2025-11-03 12:28:03 +08:00
feat: Add lookAt method for PositionComponent (#1891)
This PR adds a new method called lookAt for PositionComponent. It is a convenience method which rotates the component to make it point towards/look at the given target position. Additionally, this PR also adds a angleTo method which can be used to get the calculated angle for lookAt. It will be useful if someone want to smoothly rotate towards target using effects or manual lerping.
This commit is contained in:
@ -338,6 +338,22 @@ The `angle` is the rotation angle around the anchor, represented as a double in
|
||||
relative to the parent's angle.
|
||||
|
||||
|
||||
### Native Angle
|
||||
|
||||
The `nativeAngle` is an angle in radians, measured clockwise, representing the default orientation of the component. It can be used to define the direction in which the component is facing when [angle](#angle) is zero.
|
||||
|
||||
It is specially helpful when making a sprite based component look at a specific target. If the original image of the sprite is not facing in the up/north direction, the calculated angle to make the component look at the target will need some offset to make it look correct. For such cases, `nativeAngle` can be used to let the component know what direction the original image is faces.
|
||||
|
||||
An example could be a bullet image pointing in east direction. In this case `nativeAngle` can be set to pi/2 radians. Following are some common directions and their correspondin native angle values.
|
||||
|
||||
Direction | Native Angle | In degrees
|
||||
----------|--------------|-------------
|
||||
Up/North | 0 | 0
|
||||
Down/South| pi or -pi | 180 or -180
|
||||
Left/West | -pi/2 | -90
|
||||
Right/East| pi/2 | 90
|
||||
|
||||
|
||||
### Anchor
|
||||
|
||||
The `anchor` is where on the component that the position and rotation should be defined from (the
|
||||
|
||||
@ -4,6 +4,8 @@ import 'package:examples/stories/components/clip_component_example.dart';
|
||||
import 'package:examples/stories/components/composability_example.dart';
|
||||
import 'package:examples/stories/components/debug_example.dart';
|
||||
import 'package:examples/stories/components/game_in_game_example.dart';
|
||||
import 'package:examples/stories/components/look_at_example.dart';
|
||||
import 'package:examples/stories/components/look_at_smooth_example.dart';
|
||||
import 'package:examples/stories/components/priority_example.dart';
|
||||
import 'package:flame/game.dart';
|
||||
|
||||
@ -38,5 +40,17 @@ void addComponentsStories(Dashbook dashbook) {
|
||||
(context) => GameWidget(game: ClipComponentExample()),
|
||||
codeLink: baseLink('components/clip_component_example.dart'),
|
||||
info: ClipComponentExample.description,
|
||||
)
|
||||
..add(
|
||||
'Look At',
|
||||
(_) => GameWidget(game: LookAtExample()),
|
||||
codeLink: baseLink('components/look_at_example.dart'),
|
||||
info: LookAtExample.description,
|
||||
)
|
||||
..add(
|
||||
'Look At Smooth',
|
||||
(_) => GameWidget(game: LookAtSmoothExample()),
|
||||
codeLink: baseLink('components/look_at_smooth_example.dart'),
|
||||
info: LookAtExample.description,
|
||||
);
|
||||
}
|
||||
|
||||
114
examples/lib/stories/components/look_at_example.dart
Normal file
114
examples/lib/stories/components/look_at_example.dart
Normal file
@ -0,0 +1,114 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame/extensions.dart';
|
||||
import 'package:flame/game.dart';
|
||||
import 'package:flame/input.dart';
|
||||
import 'package:flame/palette.dart';
|
||||
import 'package:flame/sprite.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LookAtExample extends FlameGame with TapDetector {
|
||||
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. '
|
||||
'It also shows how nativeAngle can be used to make the component '
|
||||
'oriented in the desired direction if the image is not facing the '
|
||||
'correct direction.';
|
||||
|
||||
late SpriteAnimationComponent _chopper1;
|
||||
late SpriteAnimationComponent _chopper2;
|
||||
|
||||
final CircleComponent _targetComponent = CircleComponent(
|
||||
radius: 5,
|
||||
anchor: Anchor.center,
|
||||
paint: BasicPalette.black.paint(),
|
||||
);
|
||||
|
||||
@override
|
||||
Color backgroundColor() => const Color.fromARGB(255, 96, 145, 112);
|
||||
|
||||
@override
|
||||
Future<void>? onLoad() async {
|
||||
camera.viewport = FixedResolutionViewport(Vector2(640, 360));
|
||||
final spriteSheet = SpriteSheet(
|
||||
image: await images.load('animations/chopper.png'),
|
||||
srcSize: Vector2.all(48),
|
||||
);
|
||||
|
||||
_spawnChoppers(spriteSheet);
|
||||
_spawnInfoText();
|
||||
|
||||
return super.onLoad();
|
||||
}
|
||||
|
||||
@override
|
||||
void onTapDown(TapDownInfo info) {
|
||||
if (!_targetComponent.isMounted) {
|
||||
add(_targetComponent);
|
||||
}
|
||||
_targetComponent.position = info.eventPosition.game;
|
||||
|
||||
_chopper1.lookAt(_targetComponent.absolutePosition);
|
||||
_chopper2.lookAt(_targetComponent.absolutePosition);
|
||||
|
||||
super.onTapDown(info);
|
||||
}
|
||||
|
||||
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.
|
||||
add(
|
||||
_chopper1 = SpriteAnimationComponent(
|
||||
nativeAngle: pi,
|
||||
size: Vector2.all(64),
|
||||
anchor: Anchor.center,
|
||||
animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05),
|
||||
position: Vector2(size.x * 0.3, size.y * 0.5),
|
||||
),
|
||||
);
|
||||
|
||||
// This chopper does not use correct nativeAngle, hence using
|
||||
// lookAt on it results in the sprite pointing in incorrect
|
||||
// direction visually.
|
||||
add(
|
||||
_chopper2 = SpriteAnimationComponent(
|
||||
size: Vector2.all(64),
|
||||
anchor: Anchor.center,
|
||||
animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05),
|
||||
position: Vector2(size.x * 0.6, size.y * 0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Just displays some information. No functional contribution to the example.
|
||||
void _spawnInfoText() {
|
||||
final _shaded = TextPaint(
|
||||
style: TextStyle(
|
||||
color: BasicPalette.white.color,
|
||||
fontSize: 20.0,
|
||||
shadows: const [
|
||||
Shadow(offset: Offset(1, 1), blurRadius: 1),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
add(
|
||||
TextComponent(
|
||||
text: 'nativeAngle = pi',
|
||||
textRenderer: _shaded,
|
||||
anchor: Anchor.center,
|
||||
position: _chopper1.absolutePosition + Vector2(0, -50),
|
||||
),
|
||||
);
|
||||
|
||||
add(
|
||||
TextComponent(
|
||||
text: 'nativeAngle = 0',
|
||||
textRenderer: _shaded,
|
||||
anchor: Anchor.center,
|
||||
position: _chopper2.absolutePosition + Vector2(0, -50),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
134
examples/lib/stories/components/look_at_smooth_example.dart
Normal file
134
examples/lib/stories/components/look_at_smooth_example.dart
Normal file
@ -0,0 +1,134 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame/effects.dart';
|
||||
import 'package:flame/extensions.dart';
|
||||
import 'package:flame/game.dart';
|
||||
import 'package:flame/input.dart';
|
||||
import 'package:flame/palette.dart';
|
||||
import 'package:flame/sprite.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LookAtSmoothExample extends FlameGame with TapDetector {
|
||||
static const description = 'This example demonstrates how a component can be '
|
||||
'made to smoothly rotate towards a target using the angleTo method. '
|
||||
'Tap anywhere to change the target point for both the choppers. '
|
||||
'It also shows how nativeAngle can be used to make the component '
|
||||
'oriented in the desired direction if the image is not facing the '
|
||||
'correct direction.';
|
||||
|
||||
bool _isRotating = false;
|
||||
late SpriteAnimationComponent _chopper1;
|
||||
late SpriteAnimationComponent _chopper2;
|
||||
|
||||
final CircleComponent _targetComponent = CircleComponent(
|
||||
radius: 5,
|
||||
anchor: Anchor.center,
|
||||
paint: BasicPalette.black.paint(),
|
||||
);
|
||||
|
||||
@override
|
||||
Color backgroundColor() => const Color.fromARGB(255, 96, 145, 112);
|
||||
|
||||
@override
|
||||
Future<void>? onLoad() async {
|
||||
camera.viewport = FixedResolutionViewport(Vector2(640, 360));
|
||||
final spriteSheet = SpriteSheet(
|
||||
image: await images.load('animations/chopper.png'),
|
||||
srcSize: Vector2.all(48),
|
||||
);
|
||||
|
||||
_spawnChoppers(spriteSheet);
|
||||
_spawnInfoText();
|
||||
|
||||
return super.onLoad();
|
||||
}
|
||||
|
||||
@override
|
||||
void onTapDown(TapDownInfo info) {
|
||||
if (!_targetComponent.isMounted) {
|
||||
add(_targetComponent);
|
||||
}
|
||||
|
||||
// Ignore if choppers are already rotating.
|
||||
if (!_isRotating) {
|
||||
_isRotating = true;
|
||||
_targetComponent.position = info.eventPosition.game;
|
||||
|
||||
_chopper1.add(
|
||||
RotateEffect.by(
|
||||
_chopper1.angleTo(_targetComponent.absolutePosition),
|
||||
LinearEffectController(1),
|
||||
onComplete: () => _isRotating = false,
|
||||
),
|
||||
);
|
||||
|
||||
_chopper2.add(
|
||||
RotateEffect.by(
|
||||
_chopper2.angleTo(_targetComponent.absolutePosition),
|
||||
LinearEffectController(1),
|
||||
onComplete: () => _isRotating = false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
super.onTapDown(info);
|
||||
}
|
||||
|
||||
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.
|
||||
add(
|
||||
_chopper1 = SpriteAnimationComponent(
|
||||
nativeAngle: pi,
|
||||
size: Vector2.all(64),
|
||||
anchor: Anchor.center,
|
||||
animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05),
|
||||
position: Vector2(size.x * 0.3, size.y * 0.5),
|
||||
),
|
||||
);
|
||||
|
||||
// This chopper does not use correct nativeAngle, hence using
|
||||
// lookAt on it results in the sprite pointing in incorrect
|
||||
// direction visually.
|
||||
add(
|
||||
_chopper2 = SpriteAnimationComponent(
|
||||
size: Vector2.all(64),
|
||||
anchor: Anchor.center,
|
||||
animation: spriteSheet.createAnimation(row: 0, stepTime: 0.05),
|
||||
position: Vector2(size.x * 0.6, size.y * 0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Just displays some information. No functional contribution to the example.
|
||||
void _spawnInfoText() {
|
||||
final _shaded = TextPaint(
|
||||
style: TextStyle(
|
||||
color: BasicPalette.white.color,
|
||||
fontSize: 20.0,
|
||||
shadows: const [
|
||||
Shadow(offset: Offset(1, 1), blurRadius: 1),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
add(
|
||||
TextComponent(
|
||||
text: 'nativeAngle = pi',
|
||||
textRenderer: _shaded,
|
||||
anchor: Anchor.center,
|
||||
position: _chopper1.absolutePosition + Vector2(0, -50),
|
||||
),
|
||||
);
|
||||
|
||||
add(
|
||||
TextComponent(
|
||||
text: 'nativeAngle = 0',
|
||||
textRenderer: _shaded,
|
||||
anchor: Anchor.center,
|
||||
position: _chopper2.absolutePosition + Vector2(0, -50),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -74,6 +74,7 @@ class PositionComponent extends Component
|
||||
Vector2? size,
|
||||
Vector2? scale,
|
||||
double? angle,
|
||||
this.nativeAngle = 0,
|
||||
Anchor? anchor,
|
||||
super.children,
|
||||
super.priority,
|
||||
@ -98,6 +99,15 @@ class PositionComponent extends Component
|
||||
final NotifyingVector2 _size;
|
||||
Anchor _anchor;
|
||||
|
||||
/// The angle where this component is looking at when it is in
|
||||
/// the default state, i.e. when [angle] is equal to zero.
|
||||
/// For example, a nativeAngle of
|
||||
/// 0 implies up/north direction
|
||||
/// pi/2 implies right/east direction
|
||||
/// pi implies down/south direction
|
||||
/// -pi/2 implies left/west direction
|
||||
double nativeAngle;
|
||||
|
||||
/// The decorator is used to apply visual effects to a component.
|
||||
///
|
||||
/// By default, the [PositionComponent] is equipped with a
|
||||
@ -332,6 +342,33 @@ class PositionComponent extends Component
|
||||
/// The absolute center of the 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.
|
||||
///
|
||||
/// 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.
|
||||
double angleTo(Vector2 target) {
|
||||
return math.atan2(
|
||||
target.x - absolutePosition.x,
|
||||
absolutePosition.y - target.y,
|
||||
) -
|
||||
(nativeAngle + absoluteAngle);
|
||||
}
|
||||
|
||||
/// Rotates/snaps the component to look at the [target].
|
||||
///
|
||||
/// This method sets the [angle] so that the component's orientation
|
||||
/// vector (as determined by the [nativeAngle]) is pointing at the target.
|
||||
/// [target] should to be in absolute/world coordinate system.
|
||||
///
|
||||
/// See also: [angleTo]
|
||||
void lookAt(Vector2 target) => angle += angleTo(target);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Mutators
|
||||
|
||||
@ -30,6 +30,7 @@ class SpriteAnimationComponent extends PositionComponent
|
||||
super.size,
|
||||
super.scale,
|
||||
super.angle,
|
||||
super.nativeAngle,
|
||||
super.anchor,
|
||||
super.children,
|
||||
super.priority,
|
||||
|
||||
@ -28,6 +28,7 @@ class SpriteAnimationGroupComponent<T> extends PositionComponent
|
||||
super.size,
|
||||
super.scale,
|
||||
super.angle,
|
||||
super.nativeAngle,
|
||||
super.anchor,
|
||||
super.children,
|
||||
super.priority,
|
||||
|
||||
@ -26,6 +26,7 @@ class SpriteComponent extends PositionComponent
|
||||
Vector2? size,
|
||||
super.scale,
|
||||
super.angle,
|
||||
super.nativeAngle,
|
||||
super.anchor,
|
||||
super.children,
|
||||
super.priority,
|
||||
|
||||
@ -26,6 +26,7 @@ class SpriteGroupComponent<T> extends PositionComponent
|
||||
super.size,
|
||||
super.scale,
|
||||
super.angle,
|
||||
super.nativeAngle,
|
||||
super.anchor,
|
||||
super.children,
|
||||
super.priority,
|
||||
|
||||
@ -387,25 +387,37 @@ void main() {
|
||||
});
|
||||
|
||||
test('positionOf', () {
|
||||
final comp = PositionComponent()
|
||||
final component = PositionComponent()
|
||||
..size = Vector2(50, 100)
|
||||
..position = Vector2(500, 700)
|
||||
..scale = Vector2(2, 1)
|
||||
..anchor = Anchor.center;
|
||||
expect(comp.positionOfAnchor(Anchor.center), Vector2(500, 700));
|
||||
expect(comp.positionOfAnchor(Anchor.topLeft), Vector2(450, 650));
|
||||
expect(comp.positionOfAnchor(Anchor.topCenter), Vector2(500, 650));
|
||||
expect(comp.positionOfAnchor(Anchor.topRight), Vector2(550, 650));
|
||||
expect(comp.positionOfAnchor(Anchor.centerLeft), Vector2(450, 700));
|
||||
expect(comp.positionOfAnchor(Anchor.centerRight), Vector2(550, 700));
|
||||
expect(comp.positionOfAnchor(Anchor.bottomLeft), Vector2(450, 750));
|
||||
expect(component.positionOfAnchor(Anchor.center), Vector2(500, 700));
|
||||
expect(component.positionOfAnchor(Anchor.topLeft), Vector2(450, 650));
|
||||
expect(component.positionOfAnchor(Anchor.topCenter), Vector2(500, 650));
|
||||
expect(component.positionOfAnchor(Anchor.topRight), Vector2(550, 650));
|
||||
expect(
|
||||
comp.positionOfAnchor(Anchor.bottomCenter),
|
||||
component.positionOfAnchor(Anchor.centerLeft),
|
||||
Vector2(450, 700),
|
||||
);
|
||||
expect(
|
||||
component.positionOfAnchor(Anchor.centerRight),
|
||||
Vector2(550, 700),
|
||||
);
|
||||
expect(
|
||||
component.positionOfAnchor(Anchor.bottomLeft),
|
||||
Vector2(450, 750),
|
||||
);
|
||||
expect(
|
||||
component.positionOfAnchor(Anchor.bottomCenter),
|
||||
Vector2(500, 750),
|
||||
);
|
||||
expect(comp.positionOfAnchor(Anchor.bottomRight), Vector2(550, 750));
|
||||
expect(comp.positionOf(Vector2(-3, 2)), Vector2(444, 652));
|
||||
expect(comp.positionOf(Vector2(7, 16)), Vector2(464, 666));
|
||||
expect(
|
||||
component.positionOfAnchor(Anchor.bottomRight),
|
||||
Vector2(550, 750),
|
||||
);
|
||||
expect(component.positionOf(Vector2(-3, 2)), Vector2(444, 652));
|
||||
expect(component.positionOf(Vector2(7, 16)), Vector2(464, 666));
|
||||
});
|
||||
|
||||
test('local<->parent transforms', () {
|
||||
@ -717,6 +729,108 @@ void main() {
|
||||
expect(child.position, Vector2(3, 2));
|
||||
expect(child.absolutePosition, Vector2(15, 21));
|
||||
});
|
||||
|
||||
test('lookAt', () {
|
||||
final component = PositionComponent();
|
||||
|
||||
final targets = [
|
||||
Vector2(0, 1),
|
||||
Vector2.all(2),
|
||||
Vector2(-1, 0),
|
||||
Vector2.all(-50)
|
||||
];
|
||||
final expectedAngles = [pi, (3 * pi / 4), (-pi / 2), (-pi / 4)];
|
||||
|
||||
for (var i = 0; i < targets.length; ++i) {
|
||||
final target = targets.elementAt(i);
|
||||
final angle = expectedAngles.elementAt(i);
|
||||
|
||||
expectDouble(
|
||||
component.angleTo(target),
|
||||
angle - component.angle,
|
||||
epsilon: 1e-10,
|
||||
);
|
||||
|
||||
component.lookAt(target);
|
||||
expectDouble(component.angle, angle, epsilon: 1e-10);
|
||||
}
|
||||
});
|
||||
|
||||
test('lookAt with native angle', () {
|
||||
final component = PositionComponent(nativeAngle: pi / 2);
|
||||
|
||||
final targets = [
|
||||
Vector2(0, 1),
|
||||
Vector2.all(2),
|
||||
Vector2(-1, 0),
|
||||
Vector2.all(-50)
|
||||
];
|
||||
final expectedAngles = [pi / 2, (pi / 4), -pi, (-3 * pi / 4)];
|
||||
|
||||
for (var i = 0; i < targets.length; ++i) {
|
||||
final target = targets.elementAt(i);
|
||||
final angle = expectedAngles.elementAt(i);
|
||||
|
||||
expectDouble(
|
||||
component.angleTo(target),
|
||||
angle - component.angle,
|
||||
epsilon: 1e-10,
|
||||
);
|
||||
|
||||
component.lookAt(target);
|
||||
expectDouble(component.angle, angle, epsilon: 1e-10);
|
||||
}
|
||||
});
|
||||
|
||||
test('lookAt with nested components', () {
|
||||
late PositionComponent component;
|
||||
|
||||
PositionComponent(
|
||||
angle: pi / 2,
|
||||
children: [
|
||||
PositionComponent(
|
||||
angle: pi / 2,
|
||||
children: [
|
||||
component = PositionComponent(
|
||||
nativeAngle: -pi,
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
final targets = [
|
||||
Vector2(0, 1),
|
||||
Vector2.all(2),
|
||||
Vector2(-1, 0),
|
||||
Vector2.all(-50)
|
||||
];
|
||||
final expectedAngles = [pi, (3 * pi / 4), -pi / 2, (-pi / 4)];
|
||||
|
||||
for (var i = 0; i < targets.length; ++i) {
|
||||
final target = targets.elementAt(i);
|
||||
final angle = expectedAngles.elementAt(i);
|
||||
|
||||
expectDouble(
|
||||
component.angleTo(target),
|
||||
angle - component.angle,
|
||||
epsilon: 1e-10,
|
||||
);
|
||||
|
||||
component.lookAt(target);
|
||||
expectDouble(component.angle, angle, epsilon: 1e-10);
|
||||
}
|
||||
});
|
||||
|
||||
test('lookAt corner cases', () {
|
||||
final component = PositionComponent(position: Vector2(-20, 50));
|
||||
component.lookAt(component.absolutePosition);
|
||||
expectDouble(component.angle, 0, epsilon: 1e-10);
|
||||
|
||||
component.nativeAngle = 3 * pi / 2;
|
||||
component.lookAt(component.absolutePosition);
|
||||
expectDouble(component.angle, -component.nativeAngle, epsilon: 1e-10);
|
||||
});
|
||||
});
|
||||
|
||||
group('Rendering', () {
|
||||
|
||||
Reference in New Issue
Block a user