diff --git a/examples/lib/stories/effects/move_effect_example.dart b/examples/lib/stories/effects/move_effect_example.dart index a0d5f9dc9..6c4284f6f 100644 --- a/examples/lib/stories/effects/move_effect_example.dart +++ b/examples/lib/stories/effects/move_effect_example.dart @@ -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, ), ), ); diff --git a/packages/flame/CHANGELOG.md b/packages/flame/CHANGELOG.md index b25ffa3c9..e2e21c769 100644 --- a/packages/flame/CHANGELOG.md +++ b/packages/flame/CHANGELOG.md @@ -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] diff --git a/packages/flame/lib/effects.dart b/packages/flame/lib/effects.dart index e6b1305c1..0db370dfe 100644 --- a/packages/flame/lib/effects.dart +++ b/packages/flame/lib/effects.dart @@ -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'; diff --git a/packages/flame/lib/src/effects/move_along_path_effect.dart b/packages/flame/lib/src/effects/move_along_path_effect.dart new file mode 100644 index 000000000..695ca33a2 --- /dev/null +++ b/packages/flame/lib/src/effects/move_along_path_effect.dart @@ -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); + } +} diff --git a/packages/flame/lib/src/effects/move_effect.dart b/packages/flame/lib/src/effects/move_effect.dart index 9306c6191..7044c4434 100644 --- a/packages/flame/lib/src/effects/move_effect.dart +++ b/packages/flame/lib/src/effects/move_effect.dart @@ -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(); - } -} diff --git a/packages/flame/test/effects/move_along_path_effect_test.dart b/packages/flame/test/effects/move_along_path_effect_test.dart new file mode 100644 index 000000000..02b236e47 --- /dev/null +++ b/packages/flame/test/effects/move_along_path_effect_test.dart @@ -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, + ); + }); + }); +} diff --git a/packages/flame/test/effects/move_effect_test.dart b/packages/flame/test/effects/move_effect_test.dart index 8efc5e7fe..ea2d61d9c 100644 --- a/packages/flame/test/effects/move_effect_test.dart +++ b/packages/flame/test/effects/move_effect_test.dart @@ -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, - ); - }); }); }