mirror of
https://github.com/flame-engine/flame.git
synced 2025-11-01 19:12:31 +08:00
MoveAlongPathEffect (#1172)
* MoveAlongPathEffect separated from MoveEffect * rename _lastPosition -> _lastOffset * Added absolute= flag in MoveAlongPathEffect * added test for absolute path * add oriented= flag * added test * changelog Co-authored-by: Lukas Klingsbo <lukas.klingsbo@gmail.com>
This commit is contained in:
@ -89,14 +89,16 @@ class MoveEffectExample extends FlameGame {
|
||||
for (var i = 0; i < 40; i++) {
|
||||
add(
|
||||
CircleComponent(radius: 5)
|
||||
..position = Vector2(0, -1000)
|
||||
..add(
|
||||
MoveEffect.along(
|
||||
MoveAlongPathEffect(
|
||||
path1,
|
||||
EffectController(
|
||||
duration: 10,
|
||||
startDelay: i * 0.2,
|
||||
infinite: true,
|
||||
),
|
||||
absolute: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -108,13 +110,14 @@ class MoveEffectExample extends FlameGame {
|
||||
RectangleComponent.square(size: 10)
|
||||
..paint = (Paint()..color = Colors.tealAccent)
|
||||
..add(
|
||||
MoveEffect.along(
|
||||
MoveAlongPathEffect(
|
||||
path2,
|
||||
EffectController(
|
||||
duration: 6,
|
||||
startDelay: i * 0.3,
|
||||
infinite: true,
|
||||
),
|
||||
oriented: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
- Fix render order of components and add tests
|
||||
- `isHud` renamed to `respectCamera`
|
||||
- Fix `HitboxCircle` when component is flipped
|
||||
- `MoveAlongPathEffect` can now be absolute, and can auto-orient the object along the path
|
||||
- `ScaleEffect.by` now applies multiplicatively instead of additively
|
||||
|
||||
## [1.0.0-releasecandidate.18]
|
||||
|
||||
@ -11,6 +11,7 @@ export 'src/effects/controllers/reverse_curved_effect_controller.dart';
|
||||
export 'src/effects/controllers/reverse_linear_effect_controller.dart';
|
||||
export 'src/effects/controllers/sequence_effect_controller.dart';
|
||||
export 'src/effects/effect.dart';
|
||||
export 'src/effects/move_along_path_effect.dart';
|
||||
export 'src/effects/move_effect.dart';
|
||||
export 'src/effects/opacity_effect.dart';
|
||||
export 'src/effects/remove_effect.dart';
|
||||
|
||||
98
packages/flame/lib/src/effects/move_along_path_effect.dart
Normal file
98
packages/flame/lib/src/effects/move_along_path_effect.dart
Normal file
@ -0,0 +1,98 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:vector_math/vector_math_64.dart';
|
||||
|
||||
import 'controllers/effect_controller.dart';
|
||||
import 'transform2d_effect.dart';
|
||||
|
||||
/// This effect will move the target along the specified path, which may
|
||||
/// contain curved segments, but must be simply-connected.
|
||||
///
|
||||
/// If `absolute` is false (default), the `path` argument will be taken as
|
||||
/// relative to the target's position at the start of the effect. It is
|
||||
/// recommended in this case to have a path that starts at the origin in order
|
||||
/// to avoid sudden jumps in the target's position.
|
||||
///
|
||||
/// If `absolute` flag is true, then the `path` will be assumed to be given in
|
||||
/// absolute coordinate space and the target will be placed at the beginning of
|
||||
/// the path when the effect starts.
|
||||
///
|
||||
/// The `oriented` flag controls the direction of the target as it follows the
|
||||
/// path. If this flag is false (default), the target keeps its original
|
||||
/// orientation. If the flag is true, the target is automatically rotated as it
|
||||
/// follows the path so that it is always oriented tangent to the path.
|
||||
class MoveAlongPathEffect extends Transform2DEffect {
|
||||
MoveAlongPathEffect(
|
||||
Path path,
|
||||
EffectController controller, {
|
||||
bool absolute = false,
|
||||
bool oriented = false,
|
||||
}) : _isAbsolute = absolute,
|
||||
_followDirection = oriented,
|
||||
super(controller) {
|
||||
final metrics = path.computeMetrics().toList();
|
||||
if (metrics.length != 1) {
|
||||
throw ArgumentError(
|
||||
'Only single-contour paths are allowed in MoveAlongPathEffect',
|
||||
);
|
||||
}
|
||||
_pathMetric = metrics[0];
|
||||
_pathLength = _pathMetric.length;
|
||||
assert(_pathLength > 0);
|
||||
}
|
||||
|
||||
/// If true, the path is considered _absolute_, i.e. the component will be
|
||||
/// put onto the start of the path and then follow that path. If false, the
|
||||
/// path is considered _relative_, i.e. this path is added as an offset to
|
||||
/// the current position of the target.
|
||||
final bool _isAbsolute;
|
||||
|
||||
/// If true, then not only the target's position will follow the path, but
|
||||
/// also the target's angle of rotation.
|
||||
final bool _followDirection;
|
||||
|
||||
/// The path that the target will follow.
|
||||
late final PathMetric _pathMetric;
|
||||
|
||||
/// Pre-computed length of the path.
|
||||
late final double _pathLength;
|
||||
|
||||
/// Position offset that was applied to the target on the previous iteration.
|
||||
/// This is needed in order to make updates to `target.position` incremental
|
||||
/// (which in turn is necessary in order to allow multiple effects to be able
|
||||
/// to apply to the same target simultaneously).
|
||||
late Vector2 _lastOffset;
|
||||
|
||||
/// Target's angle of rotation on the previous iteration.
|
||||
late double _lastAngle;
|
||||
|
||||
@override
|
||||
void onStart() {
|
||||
_lastOffset = Vector2.zero();
|
||||
_lastAngle = 0;
|
||||
if (_isAbsolute) {
|
||||
final start = _pathMetric.getTangentForOffset(0)!;
|
||||
target.position.x = _lastOffset.x = start.position.dx;
|
||||
target.position.y = _lastOffset.y = start.position.dy;
|
||||
if (_followDirection) {
|
||||
target.angle = _lastAngle = -start.angle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void apply(double progress) {
|
||||
final distance = progress * _pathLength;
|
||||
final tangent = _pathMetric.getTangentForOffset(distance)!;
|
||||
final offset = tangent.position;
|
||||
target.position.x += offset.dx - _lastOffset.x;
|
||||
target.position.y += offset.dy - _lastOffset.y;
|
||||
_lastOffset.x = offset.dx;
|
||||
_lastOffset.y = offset.dy;
|
||||
if (_followDirection) {
|
||||
target.angle += -tangent.angle - _lastAngle;
|
||||
_lastAngle = -tangent.angle;
|
||||
}
|
||||
super.apply(progress);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,3 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:vector_math/vector_math_64.dart';
|
||||
|
||||
import 'controllers/effect_controller.dart';
|
||||
@ -16,11 +14,6 @@ import 'transform2d_effect.dart';
|
||||
/// - [MoveEffect.to] will move the target in a straight line to the specified
|
||||
/// coordinates;
|
||||
///
|
||||
/// - [MoveEffect.along] will move the target along the specified path, which
|
||||
/// may contain curved segments, but must be simply-connected. The `path`
|
||||
/// argument is taken as relative to the target's position at the start of
|
||||
/// the effect.
|
||||
///
|
||||
/// This effect applies incremental changes to the component's position, and
|
||||
/// requires that any other effect or update logic applied to the same component
|
||||
/// also used incremental updates.
|
||||
@ -32,9 +25,6 @@ class MoveEffect extends Transform2DEffect {
|
||||
factory MoveEffect.to(Vector2 destination, EffectController controller) =>
|
||||
_MoveToEffect(destination, controller);
|
||||
|
||||
factory MoveEffect.along(Path path, EffectController controller) =>
|
||||
_MoveAlongPathEffect(path, controller);
|
||||
|
||||
Vector2 _offset;
|
||||
|
||||
@override
|
||||
@ -58,41 +48,3 @@ class _MoveToEffect extends MoveEffect {
|
||||
_offset = _destination - target.position;
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementation class for [MoveEffect.along].
|
||||
class _MoveAlongPathEffect extends MoveEffect {
|
||||
_MoveAlongPathEffect(Path path, EffectController controller)
|
||||
: _lastPosition = Vector2.zero(),
|
||||
super.by(Vector2.zero(), controller) {
|
||||
final metrics = path.computeMetrics().toList();
|
||||
if (metrics.length != 1) {
|
||||
throw ArgumentError(
|
||||
'Only single-contour paths are allowed in MoveEffect.along',
|
||||
);
|
||||
}
|
||||
_pathMetric = metrics[0];
|
||||
_pathLength = _pathMetric.length;
|
||||
assert(_pathLength > 0);
|
||||
}
|
||||
|
||||
late final PathMetric _pathMetric;
|
||||
late final double _pathLength;
|
||||
late Vector2 _lastPosition;
|
||||
|
||||
@override
|
||||
void apply(double progress) {
|
||||
final distance = progress * _pathLength;
|
||||
final tangent = _pathMetric.getTangentForOffset(distance)!;
|
||||
final offset = tangent.position;
|
||||
target.position.x += offset.dx - _lastPosition.x;
|
||||
target.position.y += offset.dy - _lastPosition.y;
|
||||
_lastPosition.setValues(offset.dx, offset.dy);
|
||||
super.apply(progress);
|
||||
}
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
super.reset();
|
||||
_lastPosition = Vector2.zero();
|
||||
}
|
||||
}
|
||||
|
||||
127
packages/flame/test/effects/move_along_path_effect_test.dart
Normal file
127
packages/flame/test/effects/move_along_path_effect_test.dart
Normal file
@ -0,0 +1,127 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame/effects.dart';
|
||||
import 'package:flame/game.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('MoveAlongPathEffect', () {
|
||||
test('relative path', () {
|
||||
const tau = Transform2D.tau;
|
||||
const x0 = 32.5;
|
||||
const y0 = 14.88;
|
||||
final game = FlameGame();
|
||||
game.onGameResize(Vector2(100, 100));
|
||||
final object = PositionComponent()..position = Vector2(x0, y0);
|
||||
game.add(object);
|
||||
game.update(0);
|
||||
|
||||
object.add(
|
||||
MoveAlongPathEffect(
|
||||
Path()
|
||||
..addOval(Rect.fromCircle(center: const Offset(6, 10), radius: 50)),
|
||||
LinearEffectController(1),
|
||||
),
|
||||
);
|
||||
game.update(0);
|
||||
for (var i = 0; i < 100; i++) {
|
||||
final a = tau * i / 100;
|
||||
// Apparently, in Flutter circle paths are not truly circles, but only
|
||||
// appear circle-ish to an unsuspecting observer. Which is why the
|
||||
// precision in `closeTo()` is so low: only 0.1 pixels.
|
||||
expect(object.position.x, closeTo(x0 + 6 + 50 * cos(a), 0.1));
|
||||
expect(object.position.y, closeTo(y0 + 10 + 50 * sin(a), 0.1));
|
||||
game.update(0.01);
|
||||
}
|
||||
});
|
||||
|
||||
test('absolute path', () {
|
||||
final game = FlameGame()..onGameResize(Vector2(100, 100));
|
||||
final component = PositionComponent()..position = Vector2(17, -5);
|
||||
game.add(component);
|
||||
game.update(0);
|
||||
|
||||
component.add(
|
||||
MoveAlongPathEffect(
|
||||
Path()
|
||||
..moveTo(1000, 300)
|
||||
..lineTo(1200, 500),
|
||||
EffectController(duration: 1),
|
||||
absolute: true,
|
||||
),
|
||||
);
|
||||
game.update(0);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
expect(component.position.x, closeTo(1000 + 200 * (i / 10), 1e-10));
|
||||
expect(component.position.y, closeTo(300 + 200 * (i / 10), 1e-10));
|
||||
game.update(0.1);
|
||||
}
|
||||
});
|
||||
|
||||
test('absolute oriented path', () {
|
||||
final game = FlameGame()..onGameResize(Vector2(100, 100));
|
||||
final component = PositionComponent(
|
||||
position: Vector2(17, -5),
|
||||
angle: -30.5,
|
||||
);
|
||||
game.add(component);
|
||||
game.update(0);
|
||||
|
||||
component.add(
|
||||
MoveAlongPathEffect(
|
||||
Path() // pythagorean triangle, perimeter=600
|
||||
..moveTo(200, 200)
|
||||
..lineTo(290, 80)
|
||||
..lineTo(450, 200)
|
||||
..lineTo(200, 200),
|
||||
EffectController(duration: 6),
|
||||
absolute: true,
|
||||
oriented: true,
|
||||
),
|
||||
);
|
||||
game.update(0);
|
||||
for (var i = 0; i < 60; i++) {
|
||||
if (i <= 15) {
|
||||
expect(component.position.x, closeTo(200 + 6 * i, 1e-10));
|
||||
expect(component.position.y, closeTo(200 - 8 * i, 1e-10));
|
||||
expect(component.angle, closeTo(-asin(0.8), 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), 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, 1e-7));
|
||||
}
|
||||
game.update(0.1);
|
||||
}
|
||||
});
|
||||
|
||||
test('errors', () {
|
||||
final controller = LinearEffectController(0);
|
||||
expect(
|
||||
() => MoveAlongPathEffect(Path(), controller),
|
||||
throwsArgumentError,
|
||||
);
|
||||
|
||||
final path2 = Path()
|
||||
..moveTo(10, 10)
|
||||
..lineTo(10, 10);
|
||||
expect(
|
||||
() => MoveAlongPathEffect(path2, controller),
|
||||
throwsArgumentError,
|
||||
);
|
||||
|
||||
final path3 = Path()
|
||||
..addOval(const Rect.fromLTWH(0, 0, 1, 1))
|
||||
..addOval(const Rect.fromLTWH(2, 2, 1, 1));
|
||||
expect(
|
||||
() => MoveAlongPathEffect(path3, controller),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -1,6 +1,3 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame/game.dart';
|
||||
import 'package:flame/src/effects/controllers/linear_effect_controller.dart';
|
||||
@ -44,56 +41,5 @@ void main() {
|
||||
expect(object.position.x, closeTo(5, 1e-15));
|
||||
expect(object.position.y, closeTo(-1, 1e-15));
|
||||
});
|
||||
|
||||
test('#along', () {
|
||||
const tau = Transform2D.tau;
|
||||
final game = FlameGame();
|
||||
game.onGameResize(Vector2(100, 100));
|
||||
final object = PositionComponent()..position = Vector2(3, 4);
|
||||
game.add(object);
|
||||
game.update(0);
|
||||
|
||||
object.add(
|
||||
MoveEffect.along(
|
||||
Path()
|
||||
..addOval(Rect.fromCircle(center: const Offset(6, 10), radius: 50)),
|
||||
LinearEffectController(1),
|
||||
),
|
||||
);
|
||||
game.update(0);
|
||||
for (var i = 0; i < 100; i++) {
|
||||
final a = tau * i / 100;
|
||||
// Apparently, in Flutter circle paths are not truly circles, but only
|
||||
// appear circle-ish to an unsuspecting observer. Which is why the
|
||||
// precision in `closeTo()` is so low: only 0.1 pixels.
|
||||
expect(object.position.x, closeTo(3 + 6 + 50 * cos(a), 0.1));
|
||||
expect(object.position.y, closeTo(4 + 10 + 50 * sin(a), 0.1));
|
||||
game.update(0.01);
|
||||
}
|
||||
});
|
||||
|
||||
test('#along wrong arguments', () {
|
||||
final controller = LinearEffectController(0);
|
||||
expect(
|
||||
() => MoveEffect.along(Path(), controller),
|
||||
throwsArgumentError,
|
||||
);
|
||||
|
||||
final path2 = Path()
|
||||
..moveTo(10, 10)
|
||||
..lineTo(10, 10);
|
||||
expect(
|
||||
() => MoveEffect.along(path2, controller),
|
||||
throwsArgumentError,
|
||||
);
|
||||
|
||||
final path3 = Path()
|
||||
..addOval(const Rect.fromLTWH(0, 0, 1, 1))
|
||||
..addOval(const Rect.fromLTWH(2, 2, 1, 1));
|
||||
expect(
|
||||
() => MoveEffect.along(path3, controller),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user