diff --git a/examples/lib/stories/effects/effects.dart b/examples/lib/stories/effects/effects.dart index 05d827c7a..d101a59b2 100644 --- a/examples/lib/stories/effects/effects.dart +++ b/examples/lib/stories/effects/effects.dart @@ -9,6 +9,7 @@ import 'move_effect.dart'; import 'opacity_effect.dart'; import 'remove_effect_example.dart'; import 'rotate_effect.dart'; +import 'rotate_effect_example.dart'; import 'scale_effect.dart'; import 'sequence_effect.dart'; import 'size_effect.dart'; @@ -69,6 +70,12 @@ void addEffectsStories(Dashbook dashbook) { (_) => GameWidget(game: ColorEffectGame()), codeLink: baseLink('effects/color_effect.dart'), ) + ..add( + 'Rotate Effect (v2)', + (_) => GameWidget(game: RotateEffectExample()), + codeLink: baseLink('effects/rotate_effect_example.dart'), + info: RotateEffectExample.description, + ) ..add( 'Remove Effect', (_) => GameWidget(game: RemoveEffectExample()), diff --git a/examples/lib/stories/effects/rotate_effect_example.dart b/examples/lib/stories/effects/rotate_effect_example.dart new file mode 100644 index 000000000..97319c551 --- /dev/null +++ b/examples/lib/stories/effects/rotate_effect_example.dart @@ -0,0 +1,201 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame/src/effects2/rotate_effect.dart'; // ignore: implementation_imports +import 'package:flame/src/effects2/standard_effect_controller.dart'; // ignore: implementation_imports +import 'package:flutter/animation.dart'; + +class RotateEffectExample extends FlameGame { + static const description = ''' + The outer rim rotates at a different speed forward and reverse, and + uses the "ease" animation curve. + + The compass arrow has 3 rotation effects applied to it at the same + time: one effect rotates the arrow at a constant speed, and two more + add small amounts of wobble, creating quasi-chaotic movement. + '''; + + @override + void onMount() { + camera.viewport = FixedResolutionViewport(Vector2(400, 600)); + final compass = Compass(200)..position = Vector2(200, 300); + add(compass); + + compass.rim.add( + RotateEffect.by( + 1.0, + StandardEffectController( + duration: 6, + reverseDuration: 3, + curve: Curves.ease, + infinite: true, + ), + ), + ); + compass.arrow + ..add(RotateEffect.to( + Transform2D.tau, + StandardEffectController( + duration: 20, + infinite: true, + ))) + ..add(RotateEffect.by( + Transform2D.tau * 0.015, + StandardEffectController( + duration: 0.1, + reverseDuration: 0.1, + infinite: true, + ))) + ..add(RotateEffect.by( + Transform2D.tau * 0.021, + StandardEffectController( + duration: 0.13, + reverseDuration: 0.13, + infinite: true, + ))); + } +} + +class Compass extends PositionComponent { + Compass(double size) + : _radius = size / 2, + super( + size: Vector2.all(size), + anchor: Anchor.center, + ); + + late PositionComponent arrow; + late PositionComponent rim; + + final double _radius; + final _bgPaint = Paint()..color = const Color(0xffeacb31); + final _marksPaint = Paint() + ..color = const Color(0xFF7F6D36) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + late Path _marksPath; + + @override + Future onLoad() async { + super.onLoad(); + _marksPath = Path(); + for (var i = 0; i < 12; i++) { + final angle = Transform2D.tau * (i / 12); + // Note: rim takes up 0.1radius, so the lengths must be > than that + final markLength = (i % 3 == 0) ? _radius * 0.2 : _radius * 0.15; + _marksPath + ..moveTo( + _radius + _radius * sin(angle), + _radius + _radius * cos(angle), + ); + _marksPath + ..lineTo( + _radius + (_radius - markLength) * sin(angle), + _radius + (_radius - markLength) * cos(angle), + ); + } + + arrow = CompassArrow(width: _radius * 0.3, radius: _radius * 0.7) + ..position = Vector2(_radius, _radius); + rim = CompassRim(radius: _radius, width: _radius * 0.1) + ..position = Vector2(_radius, _radius); + add(arrow); + add(rim); + } + + @override + void render(Canvas canvas) { + super.render(canvas); + canvas.drawCircle(Offset(_radius, _radius), _radius, _bgPaint); + canvas.drawPath(_marksPath, _marksPaint); + } +} + +class CompassArrow extends PositionComponent { + CompassArrow({required double width, required double radius}) + : assert(width <= radius), + _radius = radius, + _width = width, + super(size: Vector2(width, 2 * radius), anchor: Anchor.center); + + final double _radius; + final double _width; + late final Path _northPath; + late final Path _southPath; + final _northPaint = Paint()..color = const Color(0xff387fcb); + final _southPaint = Paint()..color = const Color(0xffa83636); + + @override + Future onLoad() async { + super.onLoad(); + _northPath = Path() + ..moveTo(0, _radius) + ..lineTo(_width / 2, 0) + ..lineTo(_width, _radius) + ..close(); + _southPath = Path() + ..moveTo(0, _radius) + ..lineTo(_width, _radius) + ..lineTo(_width / 2, 2 * _radius) + ..close(); + } + + @override + void render(Canvas canvas) { + super.render(canvas); + canvas.drawPath(_northPath, _northPaint); + canvas.drawPath(_southPath, _southPaint); + } +} + +class CompassRim extends PositionComponent { + CompassRim({required double radius, required double width}) + : assert(radius > width), + _radius = radius, + _width = width, + super( + size: Vector2.all(2 * radius), + anchor: Anchor.center, + ); + + static const int numberOfNotches = 144; + final double _radius; + final double _width; + late final Path _marksPath; + final _bgPaint = Paint() + ..style = PaintingStyle.stroke + ..color = const Color(0xffb6a241); + final _marksPaint = Paint() + ..style = PaintingStyle.stroke + ..color = const Color(0xff3d3b26); + + @override + Future onLoad() async { + super.onLoad(); + _bgPaint.strokeWidth = _width; + _marksPath = Path(); + final innerRadius = _radius - _width; + final midRadius = _radius - _width / 3; + for (var i = 0; i < numberOfNotches; i++) { + final angle = Transform2D.tau * (i / numberOfNotches); + _marksPath.moveTo( + _radius + innerRadius * sin(angle), + _radius + innerRadius * cos(angle), + ); + _marksPath.lineTo( + _radius + midRadius * sin(angle), + _radius + midRadius * cos(angle), + ); + } + } + + @override + void render(Canvas canvas) { + super.render(canvas); + canvas.drawCircle(Offset(_radius, _radius), _radius - _width / 2, _bgPaint); + canvas.drawCircle(Offset(_radius, _radius), _radius - _width, _marksPaint); + canvas.drawPath(_marksPath, _marksPaint); + } +} diff --git a/packages/flame/CHANGELOG.md b/packages/flame/CHANGELOG.md index 91d096c83..4a93f6b58 100644 --- a/packages/flame/CHANGELOG.md +++ b/packages/flame/CHANGELOG.md @@ -4,6 +4,7 @@ - Added `StandardEffectController` class - Refactored `Effect` class to use `EffectController`, added `Transform2DEffect` class - Clarified `TimerComponent` example + - Alternative implementation of `RotateEffect`, based on `Transform2DEffect` - Fix `onGameResize` margin bug in `HudMarginComponent` - `PositionComponent.size` now returns a `NotifyingVector2` - Possibility to manually remove `TimerComponent` diff --git a/packages/flame/lib/src/effects2/rotate_effect.dart b/packages/flame/lib/src/effects2/rotate_effect.dart new file mode 100644 index 000000000..f31def68d --- /dev/null +++ b/packages/flame/lib/src/effects2/rotate_effect.dart @@ -0,0 +1,50 @@ +import 'effect_controller.dart'; +import 'transform2d_effect.dart'; + +/// Rotate a component around its anchor. +/// +/// Two constructors are provided: +/// - [RotateEffect.by] will rotate the target by the specified `angle` +/// relative to its orientation at the onset of the effect. For example, +/// rotating by `angle = tau/4` will turn the component 90° clockwise +/// relative to its initial direction; +/// - [RotateEffect.to] will rotate the target to the fixed orientation +/// specified by the `angle`. For example, rotating to `angle = tau/4` will +/// turn the component to look East regardless of its initial bearing. +/// +/// This effect applies incremental changes to the component's angle, and +/// requires that any other effect or update logic applied to the same component +/// also used incremental updates. +class RotateEffect extends Transform2DEffect { + RotateEffect.by(double angle, EffectController controller) + : _angle = angle, + super(controller); + + factory RotateEffect.to(double angle, EffectController controller) { + return _RotateToEffect(angle, controller); + } + + /// The magnitude of the effect: how much the target should turn as the + /// progress goes from 0 to 1. + double _angle; + + @override + void apply(double progress) { + final dProgress = progress - previousProgress; + target.angle += _angle * dProgress; + super.apply(progress); + } +} + +class _RotateToEffect extends RotateEffect { + _RotateToEffect(double angle, EffectController controller) + : _destinationAngle = angle, + super.by(0, controller); + + final double _destinationAngle; + + @override + void onStart() { + _angle = _destinationAngle - target.angle; + } +} diff --git a/packages/flame/lib/src/effects2/transform2d_effect.dart b/packages/flame/lib/src/effects2/transform2d_effect.dart index 85883b2ff..662885785 100644 --- a/packages/flame/lib/src/effects2/transform2d_effect.dart +++ b/packages/flame/lib/src/effects2/transform2d_effect.dart @@ -1,3 +1,5 @@ +import 'package:flutter/cupertino.dart'; + import '../components/position_component.dart'; import '../game/transform2d.dart'; import 'effect.dart'; @@ -17,6 +19,11 @@ abstract class Transform2DEffect extends Effect { late Transform2D target; + /// The effect's `progress` variable as it was the last time that the + /// `apply()` method was called. Mostly used by the derived classes. + double get previousProgress => _lastProgress; + double _lastProgress = 0; + @override void onMount() { super.onMount(); @@ -31,4 +38,17 @@ abstract class Transform2DEffect extends Effect { ); } } + + // In the derived class, call `super.apply()` last. + @mustCallSuper + @override + void apply(double progress) { + _lastProgress = progress; + } + + @override + void reset() { + super.reset(); + _lastProgress = 0; + } } diff --git a/packages/flame/test/effects2/rotate_effect_test.dart b/packages/flame/test/effects2/rotate_effect_test.dart new file mode 100644 index 000000000..f17c34096 --- /dev/null +++ b/packages/flame/test/effects2/rotate_effect_test.dart @@ -0,0 +1,157 @@ +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame/src/effects2/rotate_effect.dart'; +import 'package:flame/src/effects2/standard_effect_controller.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RotateEffect', () { + test('relative', () { + final game = FlameGame(); + game.onGameResize(Vector2(1, 1)); + final object = PositionComponent(); + game.add(object); + game.update(0); + + object.angle = 1; + object.add( + RotateEffect.by(1, StandardEffectController(duration: 1)), + ); + game.update(0); + expect(object.angle, 1); + expect(object.children.length, 1); + + game.update(0.5); + expect(object.angle, 1.5); + + game.update(0.5); + expect(object.angle, 2); + game.update(0); + expect(object.children.length, 0); + expect(object.angle, 2); + }); + + test('absolute', () { + final game = FlameGame(); + game.onGameResize(Vector2(1, 1)); + final object = PositionComponent(); + game.add(object); + game.update(0); + + object.angle = 1; + object.add( + RotateEffect.to(3, StandardEffectController(duration: 1)), + ); + game.update(0); + expect(object.angle, 1); + expect(object.children.length, 1); + + game.update(0.5); + expect(object.angle, 2); + + game.update(0.5); + expect(object.angle, 3); + game.update(0); + expect(object.children.length, 0); + expect(object.angle, 3); + }); + + test('reset relative', () { + final game = FlameGame()..onGameResize(Vector2(1, 1)); + final object = PositionComponent(); + game.add(object); + game.update(0); + + final effect = RotateEffect.by(1, StandardEffectController(duration: 1)); + object.add(effect..removeOnFinish = false); + for (var i = 0; i < 5; i++) { + expect(object.angle, i); + // After each reset the object will be rotated by 1 radian relative to + // its orientation at the start of the effect + effect.reset(); + game.update(1); + expect(object.angle, i + 1); + } + }); + + test('reset absolute', () { + final game = FlameGame()..onGameResize(Vector2(1, 1)); + final object = PositionComponent(); + game.add(object); + game.update(0); + + final effect = RotateEffect.to(1, StandardEffectController(duration: 1)); + object.add(effect..removeOnFinish = false); + for (var i = 0; i < 5; i++) { + object.angle = 1 + 4.0 * i; + // After each reset the object will be rotated to the value of + // `angle == 1`, regardless of its initial orientation. + effect.reset(); + game.update(1); + expect(object.angle, 1); + } + }); + + test('rotation composition', () { + final game = FlameGame()..onGameResize(Vector2(1, 1)); + final object = PositionComponent(); + game.add(object); + game.update(0); + + object.add( + RotateEffect.by(5, StandardEffectController(duration: 10)), + ); + object.add( + RotateEffect.by( + 0.5, + StandardEffectController( + duration: 1, + reverseDuration: 1, + repeatCount: 5, + ), + ), + ); + + game.update(1); + expect(object.angle, closeTo(1, 1e-15)); // 5*1/10 + 0.5*1 + game.update(1); + expect(object.angle, closeTo(1, 1e-15)); // 5*2/10 + 0.5*1 - 0.5*1 + for (var i = 0; i < 10; i++) { + game.update(1); + } + expect(object.angle, closeTo(5, 1e-15)); + expect(object.children.length, 0); + }); + + testRandom('a very long rotation', (Random rng) { + final game = FlameGame()..onGameResize(Vector2(1, 1)); + final object = PositionComponent(); + game.add(object); + game.update(0); + + final effect = RotateEffect.by( + 1.0, + StandardEffectController( + duration: 1, + reverseDuration: 1, + infinite: true, + ), + ); + object.add(effect); + + var totalTime = 0.0; + while (totalTime < 999.9) { + final dt = rng.nextDouble() * 0.02; + totalTime += dt; + game.update(dt); + } + game.update(1000 - totalTime); + // Typically, `object.angle` could accumulate numeric discrepancy on the + // order of 1e-11 .. 1e-12 by now. + expect(object.angle, closeTo(0, 1e-10)); + }); + }); +} diff --git a/packages/flame/test/effects2/transform2d_effect_test.dart b/packages/flame/test/effects2/transform2d_effect_test.dart index e2de46ca3..4218be8af 100644 --- a/packages/flame/test/effects2/transform2d_effect_test.dart +++ b/packages/flame/test/effects2/transform2d_effect_test.dart @@ -8,9 +8,6 @@ import 'package:flutter_test/flutter_test.dart'; class MyEffect extends Transform2DEffect { MyEffect(EffectController controller) : super(controller); - - @override - void apply(double progress) {} } void main() {