diff --git a/doc/effects.md b/doc/effects.md index bd472a1c6..ddbe544cb 100644 --- a/doc/effects.md +++ b/doc/effects.md @@ -31,7 +31,6 @@ the final value is provided by the user explicitly, and progression over time is There are multiple effects provided by Flame, and you can also [create your own](#creating-new-effects). The following effects are included: -- [`ColorEffect`](#coloreffect) - [`MoveEffect.by`](#moveeffectby) - [`MoveEffect.to`](#moveeffectto) - [`MoveAlongPathEffect`](#movealongpatheffect) @@ -42,6 +41,7 @@ There are multiple effects provided by Flame, and you can also - [`SizeEffect.by`](#sizeeffectby) - [`SizeEffect.to`](#sizeeffectto) - [`OpacityEffect`](#opacityeffect) +- [`ColorEffect`](#coloreffect) - [`RemoveEffect`](#removeeffect) An `EffectController` is an object that describes how the effect should evolve over time. If you @@ -60,6 +60,7 @@ There are multiple effect controllers provided by the Flame framework as well: - [`InfiniteEffectController`](#infiniteeffectcontroller) - [`SequenceEffectController`](#sequenceeffectcontroller) - [`DelayedEffectController`](#delayedeffectcontroller) +- [`RandomEffectController`](#randomeffectcontroller) ## Built-in effects @@ -234,12 +235,10 @@ the provided color between a provided range. Usage example: ```dart -myComponent.add( - ColorEffect( - const Color(0xFF00FF00), - const Offset(0.0, 0.8), - EffectController(duration: 1.5), - ), +final effect = ColorEffect( + const Color(0xFF00FF00), + const Offset(0.0, 0.8), + EffectController(duration: 1.5), ); ``` @@ -250,6 +249,7 @@ __Note :__Due to how this effect is implemented, and how Flutter's `ColorFilter` effect can't be mixed with other `ColorEffect`s, when more than one is added to the component, only the last one will have effect. + ## Creating new effects Although Flame provides a wide array of built-in effects, eventually you may find them to be @@ -459,6 +459,25 @@ final ec = DelayedEffectController(LinearEffectController(1), delay: 5); ``` +### `RandomEffectController` + +This controller wraps another controller and makes its duration random. The actual value for the +duration is re-generated upon each reset, which makes this controller particularly useful within +repeated contexts, such as [](#repeatedeffectcontroller) or [](#infiniteeffectcontroller). + +```dart +final effect = RandomEffectController.uniform( + LinearEffectController(0), // duration here is irrelevant + min: 0.5, + max: 1.5, +); +``` + +The user has the ability to control which `Random` source to use, as well as the exact distribution +of the produced random durations. Two distributions -- `.uniform` and `.exponential` are included, +any other can be implemented by the user. + + ## See also * [Examples of various effects](https://examples.flame-engine.org/#/). diff --git a/examples/lib/stories/effects/scale_effect_example.dart b/examples/lib/stories/effects/scale_effect_example.dart index d280936cb..75f7d9b2c 100644 --- a/examples/lib/stories/effects/scale_effect_example.dart +++ b/examples/lib/stories/effects/scale_effect_example.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flame/components.dart'; import 'package:flame/effects.dart'; import 'package:flame/game.dart'; @@ -7,10 +9,10 @@ import 'package:flutter/material.dart'; class ScaleEffectExample extends FlameGame with TapDetector { static const String description = ''' - The `ScaleEffect` scales up the canvas before drawing the components and its - children. In this example you can tap the screen and the component will scale up or down, depending on its current state. + + The star pulsates randomly using a RandomEffectController. '''; late RectangleComponent square; @@ -30,6 +32,26 @@ class ScaleEffectExample extends FlameGame with TapDetector { ); square.add(childSquare); add(square); + + add( + Star() + ..position = Vector2(200, 100) + ..add( + ScaleEffect.to( + Vector2.all(1.2), + InfiniteEffectController( + SequenceEffectController([ + LinearEffectController(0.1), + ReverseLinearEffectController(0.1), + RandomEffectController.exponential( + PauseEffectController(1, progress: 0), + beta: 1, + ), + ]), + ), + ), + ), + ); } @override @@ -49,3 +71,26 @@ class ScaleEffectExample extends FlameGame with TapDetector { ); } } + +class Star extends PositionComponent { + Star() { + const smallR = 15.0; + const bigR = 30.0; + const tau = 2 * pi; + shape = Path()..moveTo(bigR, 0); + for (var i = 1; i < 10; i++) { + final r = i.isEven ? bigR : smallR; + final a = i / 10 * tau; + shape.lineTo(r * cos(a), r * sin(a)); + } + shape.close(); + } + + late final Path shape; + late final Paint paint = Paint()..color = const Color(0xFFFFF127); + + @override + void render(Canvas canvas) { + canvas.drawPath(shape, paint); + } +} diff --git a/packages/flame/lib/effects.dart b/packages/flame/lib/effects.dart index 830c58a55..718b24d96 100644 --- a/packages/flame/lib/effects.dart +++ b/packages/flame/lib/effects.dart @@ -7,6 +7,7 @@ export 'src/effects/controllers/effect_controller.dart'; export 'src/effects/controllers/infinite_effect_controller.dart'; export 'src/effects/controllers/linear_effect_controller.dart'; export 'src/effects/controllers/pause_effect_controller.dart'; +export 'src/effects/controllers/random_effect_controller.dart'; export 'src/effects/controllers/repeated_effect_controller.dart'; export 'src/effects/controllers/reverse_curved_effect_controller.dart'; export 'src/effects/controllers/reverse_linear_effect_controller.dart'; diff --git a/packages/flame/lib/src/effects/controllers/effect_controller.dart b/packages/flame/lib/src/effects/controllers/effect_controller.dart index 95611c35f..9098ad51b 100644 --- a/packages/flame/lib/src/effects/controllers/effect_controller.dart +++ b/packages/flame/lib/src/effects/controllers/effect_controller.dart @@ -123,8 +123,8 @@ abstract class EffectController { /// Is the effect's duration random or fixed? bool get isRandom => false; - /// Total duration of the effect. If the effect is either infinite or random, - /// this will return `null`. + /// Total duration of the effect. If the duration cannot be determined, this + /// will return `null`. double? get duration; /// Has the effect started running? Some effects use a "delay" parameter to diff --git a/packages/flame/lib/src/effects/controllers/random_effect_controller.dart b/packages/flame/lib/src/effects/controllers/random_effect_controller.dart new file mode 100644 index 000000000..d9800b848 --- /dev/null +++ b/packages/flame/lib/src/effects/controllers/random_effect_controller.dart @@ -0,0 +1,132 @@ +import 'dart:math'; + +import 'duration_effect_controller.dart'; +import 'effect_controller.dart'; + +/// An [EffectController] that wraps another effect controller [child] and +/// randomizes its duration after each reset. +/// +/// This effect controller works best in contexts were it has a chance to be +/// executed multiple times, such as within a `RepeatedEffectController`, or +/// `InfiniteEffectController`, etc. +/// +/// The child's duration is randomized first at construction, and then at each +/// reset (`setToStart`). Thus, the child has a concrete well-defined duration +/// at any point in time. +class RandomEffectController extends EffectController { + RandomEffectController(this.child, this.randomGenerator) + : assert(!child.isInfinite, 'Child cannot be infinite'), + super.empty() { + _initializeDuration(); + } + + /// Factory constructor that uses a random variable uniformly distributed on + /// `[min, max)`. + factory RandomEffectController.uniform( + DurationEffectController child, { + required double min, + required double max, + Random? random, + }) { + assert(min >= 0, 'Min value cannot be negative: $min'); + assert(min < max, 'Max value must exceed min: max=$max, min=$min'); + return RandomEffectController( + child, + _UniformRandomVariable(min, max, random), + ); + } + + /// Factory constructor that employs a random variable distributed + /// exponentially with rate parameter `beta`. The produced random values will + /// have the average duration of `beta`. + factory RandomEffectController.exponential( + DurationEffectController child, { + required double beta, + Random? random, + }) { + assert(beta > 0, 'Beta must be positive: $beta'); + return RandomEffectController( + child, + _ExponentialRandomVariable(beta, random), + ); + } + + final DurationEffectController child; + final RandomVariable randomGenerator; + + @override + bool get isInfinite => false; + + @override + bool get isRandom => true; + + @override + bool get completed => child.completed; + + @override + double? get duration => child.duration; + + @override + double get progress => child.progress; + + @override + double advance(double dt) => child.advance(dt); + + @override + double recede(double dt) => child.recede(dt); + + @override + void setToEnd() => child.setToEnd(); + + @override + void setToStart() { + child.setToStart(); + _initializeDuration(); + } + + void _initializeDuration() { + final duration = randomGenerator.nextValue(); + assert( + duration >= 0, + 'Random generator produced a negative value: $duration', + ); + child.duration = duration; + } +} + +/// [RandomVariable] is an object capable of producing random values with the +/// prescribed distribution function. Each distribution is implemented within +/// its own derived class. +abstract class RandomVariable { + RandomVariable(Random? random) : _random = random ?? _defaultRandom; + + /// Internal random number generator. + final Random _random; + static final Random _defaultRandom = Random(); + + /// Produces the next value for this random variable. + double nextValue(); +} + +/// Random variable distributed uniformly between [min] and [max]. +class _UniformRandomVariable extends RandomVariable { + _UniformRandomVariable(this.min, this.max, Random? random) : super(random); + + final double min; + final double max; + + @override + double nextValue() => _random.nextDouble() * (max - min) + min; +} + +/// Exponentially distributed random variable with rate parameter [beta]. +class _ExponentialRandomVariable extends RandomVariable { + _ExponentialRandomVariable(this.beta, Random? random) : super(random); + + /// Rate parameter of the exponential distribution. This will be the average + /// of all returned values + final double beta; + + @override + double nextValue() => -log(1 - _random.nextDouble()) * beta; +} diff --git a/packages/flame/test/effects/controllers/random_effect_controller_test.dart b/packages/flame/test/effects/controllers/random_effect_controller_test.dart new file mode 100644 index 000000000..225451733 --- /dev/null +++ b/packages/flame/test/effects/controllers/random_effect_controller_test.dart @@ -0,0 +1,85 @@ +import 'dart:math'; + +import 'package:flame/effects.dart'; +import 'package:test/test.dart'; + +class MyRandom implements Random { + double value = 0.5; + + @override + double nextDouble() => value; + + @override + bool nextBool() => true; + + @override + int nextInt(int max) => 1; +} + +class MyRandomVariable extends RandomVariable { + MyRandomVariable() : super(null); + double value = 1.23; + + @override + double nextValue() => value; +} + +void main() { + group('RandomEffectController', () { + test('custom random', () { + final randomVariable = MyRandomVariable(); + final ec = RandomEffectController( + LinearEffectController(1000), + randomVariable, + ); + + expect(ec.duration, 1.23); + expect(ec.isRandom, true); + expect(ec.isInfinite, false); + expect(ec.progress, 0); + expect(ec.started, true); + expect(ec.completed, false); + expect(ec.advance(1), 0); + expect(ec.advance(0.23), 0); + expect(ec.completed, true); + expect(ec.advance(1), 1); + expect(ec.duration, 1.23); + }); + + test('.uniform', () { + final random = MyRandom(); + final ec = RandomEffectController.uniform( + LinearEffectController(1000), + min: 0, + max: 10, + random: random, + ); + expect(random.nextDouble(), 0.5); + expect(ec.duration, 5); + random.value = 0; + ec.setToStart(); + expect(ec.duration, 0); + random.value = 1; + ec.setToStart(); + expect(ec.duration, 10); + }); + + test('.exponential', () { + const n = 1000; + final random = MyRandom(); + final ec = RandomEffectController.exponential( + LinearEffectController(1e6), + beta: 42, + random: random, + ); + var sum = 0.0; + for (var i = 0; i < n; i++) { + random.value = i / n; + ec.setToStart(); + expect(ec.duration! >= 0, true); + sum += ec.duration!; + } + expect(sum / n, closeTo(42, 400 / n)); + }); + }); +}