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:
DevKage
2022-09-19 00:27:07 +05:30
committed by GitHub
parent 57ca47c8cc
commit 720c3566b0
10 changed files with 445 additions and 12 deletions

View File

@ -338,6 +338,22 @@ The `angle` is the rotation angle around the anchor, represented as a double in
relative to the parent's angle. 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 ### Anchor
The `anchor` is where on the component that the position and rotation should be defined from (the The `anchor` is where on the component that the position and rotation should be defined from (the

View File

@ -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/composability_example.dart';
import 'package:examples/stories/components/debug_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/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:examples/stories/components/priority_example.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
@ -38,5 +40,17 @@ void addComponentsStories(Dashbook dashbook) {
(context) => GameWidget(game: ClipComponentExample()), (context) => GameWidget(game: ClipComponentExample()),
codeLink: baseLink('components/clip_component_example.dart'), codeLink: baseLink('components/clip_component_example.dart'),
info: ClipComponentExample.description, 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,
); );
} }

View 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),
),
);
}
}

View 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),
),
);
}
}

View File

@ -74,6 +74,7 @@ class PositionComponent extends Component
Vector2? size, Vector2? size,
Vector2? scale, Vector2? scale,
double? angle, double? angle,
this.nativeAngle = 0,
Anchor? anchor, Anchor? anchor,
super.children, super.children,
super.priority, super.priority,
@ -98,6 +99,15 @@ class PositionComponent extends Component
final NotifyingVector2 _size; final NotifyingVector2 _size;
Anchor _anchor; 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. /// The decorator is used to apply visual effects to a component.
/// ///
/// By default, the [PositionComponent] is equipped with a /// By default, the [PositionComponent] is equipped with a
@ -332,6 +342,33 @@ class PositionComponent extends Component
/// The absolute center of the component. /// The absolute center of the 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
/// 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 //#endregion
//#region Mutators //#region Mutators

View File

@ -30,6 +30,7 @@ class SpriteAnimationComponent extends PositionComponent
super.size, super.size,
super.scale, super.scale,
super.angle, super.angle,
super.nativeAngle,
super.anchor, super.anchor,
super.children, super.children,
super.priority, super.priority,

View File

@ -28,6 +28,7 @@ class SpriteAnimationGroupComponent<T> extends PositionComponent
super.size, super.size,
super.scale, super.scale,
super.angle, super.angle,
super.nativeAngle,
super.anchor, super.anchor,
super.children, super.children,
super.priority, super.priority,

View File

@ -26,6 +26,7 @@ class SpriteComponent extends PositionComponent
Vector2? size, Vector2? size,
super.scale, super.scale,
super.angle, super.angle,
super.nativeAngle,
super.anchor, super.anchor,
super.children, super.children,
super.priority, super.priority,

View File

@ -26,6 +26,7 @@ class SpriteGroupComponent<T> extends PositionComponent
super.size, super.size,
super.scale, super.scale,
super.angle, super.angle,
super.nativeAngle,
super.anchor, super.anchor,
super.children, super.children,
super.priority, super.priority,

View File

@ -387,25 +387,37 @@ void main() {
}); });
test('positionOf', () { test('positionOf', () {
final comp = PositionComponent() final component = PositionComponent()
..size = Vector2(50, 100) ..size = Vector2(50, 100)
..position = Vector2(500, 700) ..position = Vector2(500, 700)
..scale = Vector2(2, 1) ..scale = Vector2(2, 1)
..anchor = Anchor.center; ..anchor = Anchor.center;
expect(comp.positionOfAnchor(Anchor.center), Vector2(500, 700)); expect(component.positionOfAnchor(Anchor.center), Vector2(500, 700));
expect(comp.positionOfAnchor(Anchor.topLeft), Vector2(450, 650)); expect(component.positionOfAnchor(Anchor.topLeft), Vector2(450, 650));
expect(comp.positionOfAnchor(Anchor.topCenter), Vector2(500, 650)); expect(component.positionOfAnchor(Anchor.topCenter), Vector2(500, 650));
expect(comp.positionOfAnchor(Anchor.topRight), Vector2(550, 650)); expect(component.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( 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), Vector2(500, 750),
); );
expect(comp.positionOfAnchor(Anchor.bottomRight), Vector2(550, 750)); expect(
expect(comp.positionOf(Vector2(-3, 2)), Vector2(444, 652)); component.positionOfAnchor(Anchor.bottomRight),
expect(comp.positionOf(Vector2(7, 16)), Vector2(464, 666)); 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', () { test('local<->parent transforms', () {
@ -717,6 +729,108 @@ void main() {
expect(child.position, Vector2(3, 2)); expect(child.position, Vector2(3, 2));
expect(child.absolutePosition, Vector2(15, 21)); 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', () { group('Rendering', () {