Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add RandomEffectController #1203

Merged
merged 11 commits into from
Dec 12, 2021
33 changes: 26 additions & 7 deletions doc/effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
spydon marked this conversation as resolved.
Show resolved Hide resolved
- [`RemoveEffect`](#removeeffect)

An `EffectController` is an object that describes how the effect should evolve over time. If you
Expand All @@ -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
Expand Down Expand Up @@ -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),
);
```

Expand All @@ -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
Expand Down Expand Up @@ -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/#/).
Expand Down
49 changes: 47 additions & 2 deletions examples/lib/stories/effects/scale_effect_example.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/game.dart';
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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);
}
}
1 change: 1 addition & 0 deletions packages/flame/lib/effects.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a feeling this could be useful in a general context, not only to random effect controller;
For example, on Gravity I introduced a method similar to Uniform Random Variable
If so, we could move it to its own file and add factory methods to create both private implementations.
Thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably as a follow-up though?

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;
}
Original file line number Diff line number Diff line change
@@ -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));
});
});
}