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: Added FollowBehavior and ability for the new Camera to follow a component #1561

Merged
merged 15 commits into from
Apr 25, 2022
49 changes: 40 additions & 9 deletions doc/flame/camera_component.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ one camera simultaneously.
In order to understand how this approach works, imagine that your game world is
an entity that exists _somewhere_ independently from your application. Imagine
that your game is merely a window through which you can look into that world.
That you can close that window at any moment, and the game world would still be
That you can close that window at any moment, and the game world would still be
there. Or, on the contrary, you can open multiple windows that all look at the
same world (or different worlds) at the same time.

With this mindset, we can now understand how camera-as-a-component works.

First, there is the [](#world) class, which contains all components that are
inside your game world. The `World` component can be mounted anywhere, for
First, there is the [](#world) class, which contains all components that are
inside your game world. The `World` component can be mounted anywhere, for
example at the root of your game class.

Then, a [](#cameracomponent) class that "looks at" the `World`. The
Then, a [](#cameracomponent) class that "looks at" the `World`. The
`CameraComponent` has a `Viewport` and a `Viewfinder` inside, allowing both the
flexibility of rendering the world at any place on the screen, and also control
the viewing location and angle.
Expand All @@ -44,9 +44,9 @@ then mount B.

## CameraComponent

This is a component through which a `World` is rendered. It requires a
This is a component through which a `World` is rendered. It requires a
reference to a `World` instance during construction; however later the target
world can be replaced with another one. Multiple cameras can observe the same
world can be replaced with another one. Multiple cameras can observe the same
world at the same time.

A `CameraComponent` has two other components inside: a [](#viewport) and a
Expand Down Expand Up @@ -79,7 +79,7 @@ The following viewports are available:
- `MaxViewport` (default) -- this viewport expands to the maximum size allowed
by the game, i.e. it will be equal to the size of the game canvas.
- `FixedSizeViewport` -- a simple rectangular viewport with predefined size.
- `FixedAspectRatioViewport` -- a rectangular viewport which expands to fit
- `FixedAspectRatioViewport` -- a rectangular viewport which expands to fit
into the game canvas, but preserving its aspect ratio.
- `CircularViewport` -- a viewport in the shape of a circle, fixed size.

Expand All @@ -102,6 +102,37 @@ were part of the world (but on top). It is more useful to add behavioral
components to the viewfinder, for example [](effects.md) or other controllers.


## Camera controls

There are several ways to modify camera's settings at runtime:

1. Do it manually. You can always override the `CameraComponent.update()`
method (or the same method on the viewfinder or viewport) and within it
change the viewfinder's position or zoom as you see fit. This approach may
be viable in some circumstances, but in general it is not recommended.

2. Apply effects and/or behaviors to the camera's `Viewfinder` or `Viewport`.
The effects and behaviors are special kinds of components whose purpose is
to modify over time some property of a component that they attach to.

3. Use special camera functions such as `follow()` and `moveTo()`. Under the
hood, this approach uses the same effects/behaviors as in (2).

Camera has several methods for controlling its behavior:

- `Camera.follow()` will force the camera to follow the provided target.
Optionally you can limit the maximum speed of movement of the camera, or
allow it to move horizontally/vertically only.

- `Camera.stopFollowing()` will undo the effect of the previous call and stop
the camera at its current position.

- `Camera.moveTo()` can be used to move the camera to the designated point on
the world map. If the camera was already following another component or
moving towards another point, those behaviors would be automatically
cancelled.


## Comparison to the traditional camera

Compared to the normal [Camera](camera_and_viewport.md), the `CameraComponent`
Expand All @@ -113,8 +144,8 @@ Pros:
- Switching camera from one world to another can happen instantaneously,
without having to unmount one world and then mount another;
- Support rotation of the world view;
- (NYI) Effects can be applied either to the viewport, or to the viewfinder;
- (NYI) More flexible camera controllers.
- Effects can be applied either to the viewport, or to the viewfinder;
- More flexible camera controllers.

Cons (we are planning to eliminate these in the near future):
- Camera controls are not yet implemented;
Expand Down
1 change: 1 addition & 0 deletions packages/flame/lib/experimental.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export 'src/experimental/circular_viewport.dart' show CircularViewport;
export 'src/experimental/fixed_aspect_ratio_viewport.dart'
show FixedAspectRatioViewport;
export 'src/experimental/fixed_size_viewport.dart' show FixedSizeViewport;
export 'src/experimental/follow_behavior.dart' show FollowBehavior;
export 'src/experimental/max_viewport.dart' show MaxViewport;
export 'src/experimental/viewfinder.dart' show Viewfinder;
export 'src/experimental/viewport.dart' show Viewport;
Expand Down
20 changes: 20 additions & 0 deletions packages/flame/lib/src/effects/provider_interfaces.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ abstract class PositionProvider {
set position(Vector2 value);
}

/// This class allows constructing [PositionProvider]s on the fly, using the
/// callbacks for the position getter and setter. This class doesn't require
/// either the getter or the setter, if you do not intend to use those.
class PositionProviderImpl implements PositionProvider {
PositionProviderImpl({
Vector2 Function()? getValue,
void Function(Vector2)? setValue,
}) : _getter = getValue,
_setter = setValue;

final Vector2 Function()? _getter;
final void Function(Vector2)? _setter;

@override
Vector2 get position => _getter!();

@override
set position(Vector2 value) => _setter!(value);
}

/// Interface for a component that can be affected by scale effects.
abstract class ScaleProvider {
Vector2 get scale;
Expand Down
56 changes: 56 additions & 0 deletions packages/flame/lib/src/experimental/camera_component.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import 'dart:ui';

import 'package:meta/meta.dart';
import 'package:vector_math/vector_math_64.dart';

import '../components/component.dart';
import '../components/position_component.dart';
import '../effects/provider_interfaces.dart';
import 'follow_behavior.dart';
import 'max_viewport.dart';
import 'viewfinder.dart';
import 'viewport.dart';
Expand Down Expand Up @@ -115,4 +119,56 @@ class CameraComponent extends Component {
/// This variable helps prevent infinite recursion when a camera is set to
/// look at the world that contains that camera.
static int maxCamerasDepth = 4;

/// Makes the [viewfinder] follow the given [target].
///
/// The [target] here can be any read-only [PositionProvider]. For example, a
/// [PositionComponent] is the most common choice of target. Alternatively,
/// you can use [PositionProviderImpl] to construct the target dynamically.
///
/// This method adds a [FollowBehavior] to the viewfinder. If there is another
/// [FollowBehavior] currently applied to the viewfinder, it will be removed
/// first.
///
/// Parameters [maxSpeed], [horizontalOnly] and [verticalOnly] have the same
/// meaning as in the [FollowBehavior.new] constructor.
///
/// If [snap] is true, then the viewfinder's starting position will be set to
/// the target's current location. If [snap] is false, then the viewfinder
/// will move from its current position to the target's position at the given
/// speed.
void follow(
st-pasha marked this conversation as resolved.
Show resolved Hide resolved
PositionProvider target, {
double maxSpeed = double.infinity,
bool horizontalOnly = false,
bool verticalOnly = false,
bool snap = false,
}) {
stopFollowing();
viewfinder.add(
FollowBehavior(
target: target,
owner: viewfinder,
maxSpeed: maxSpeed,
horizontalOnly: horizontalOnly,
verticalOnly: verticalOnly,
),
);
if (snap) {
viewfinder.position = target.position;
}
}

/// Removes current [FollowBehavior]s from the viewfinder, if any.
void stopFollowing() {
viewfinder.children.whereType<FollowBehavior>().forEach(
(child) => child.removeFromParent(),
);
}

/// Moves the camera towards the specified world [point].
void moveTo(Vector2 point, {double speed = double.infinity}) {
final p = point.clone();
follow(PositionProviderImpl(getValue: () => p), maxSpeed: speed);
}
}
75 changes: 75 additions & 0 deletions packages/flame/lib/src/experimental/follow_behavior.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import '../components/component.dart';
import '../components/position_component.dart';
import '../effects/provider_interfaces.dart';
import 'viewfinder.dart';
import 'viewport.dart';

/// This behavior will make the [owner] follow the [target].
///
/// Here, both the owner and the target are [PositionProvider]s, which could be
/// either [PositionComponent]s, or camera's [Viewfinder]/[Viewport], or any
/// other objects, including custom implementations.
///
/// The [maxSpeed] parameter controls the maximum speed with which the [owner]
/// is allowed to move as it pursues the [target]. By default, the max speed is
/// infinite, allowing the owner to stay on top of the target all the time.
///
/// The flags [horizontalOnly]/[verticalOnly] allow constraining the [owner]'s
/// movement to the horizontal/vertical directions respectively.
class FollowBehavior extends Component {
st-pasha marked this conversation as resolved.
Show resolved Hide resolved
FollowBehavior({
required PositionProvider target,
PositionProvider? owner,
double maxSpeed = double.infinity,
this.horizontalOnly = false,
this.verticalOnly = false,
int? priority,
}) : _target = target,
_owner = owner,
_speed = maxSpeed,
assert(maxSpeed > 0, 'maxSpeed must be positive: $maxSpeed'),
assert(
!(horizontalOnly && verticalOnly),
'The behavior cannot be both horizontalOnly and verticalOnly',
),
super(priority: priority);

PositionProvider get target => _target;
final PositionProvider _target;

PositionProvider get owner => _owner!;
PositionProvider? _owner;

double get maxSpeed => _speed;
final double _speed;

final bool horizontalOnly;
final bool verticalOnly;

@override
void onMount() {
if (_owner == null) {
assert(
parent is PositionProvider,
'Can only apply this behavior to a PositionProvider',
);
_owner = parent! as PositionProvider;
}
}

@override
void update(double dt) {
final delta = target.position - owner.position;
if (horizontalOnly) {
delta.y = 0;
}
if (verticalOnly) {
delta.x = 0;
}
final distance = delta.length;
if (distance > _speed * dt) {
delta.scale(_speed * dt / distance);
}
owner.position = delta..add(owner.position);
}
}
70 changes: 70 additions & 0 deletions packages/flame/test/experimental/camera_component_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import 'package:flame/components.dart';
import 'package:flame/experimental.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('CameraComponent', () {
testWithFlameGame('simple camera follow', (game) async {
final world = World()..addToParent(game);
final camera = CameraComponent(world: world)..addToParent(game);
final player = PositionComponent()..addToParent(world);
camera.follow(player);
await game.ready();

expect(camera.viewfinder.children.length, 1);
expect(camera.viewfinder.children.first, isA<FollowBehavior>());
for (var i = 0; i < 20; i++) {
player.position.add(Vector2(i * 5.0, 20.0 - i));
game.update(0.01);
expect(camera.viewfinder.position, closeToVector(player.x, player.y));
}
});

testWithFlameGame('follow with snap', (game) async {
final world = World()..addToParent(game);
final player = PositionComponent()
..position = Vector2(100, 100)
..addToParent(world);
final camera = CameraComponent(world: world)
..follow(player, maxSpeed: 1, snap: true)
..addToParent(game);
await game.ready();

expect(camera.viewfinder.position, Vector2(100, 100));
});

testWithFlameGame('moveTo', (game) async {
final world = World()..addToParent(game);
final camera = CameraComponent(world: world)..addToParent(game);
await game.ready();

final point = Vector2(1000, 2000);
camera.moveTo(point);
game.update(0);
expect(camera.viewfinder.position, Vector2(1000, 2000));
// updating [point] doesn't affect the camera's target
point.x = 0;
game.update(1);
expect(camera.viewfinder.position, Vector2(1000, 2000));
});

testWithFlameGame('moveTo x 2', (game) async {
final world = World()..addToParent(game);
final camera = CameraComponent(world: world)..addToParent(game);
await game.ready();

camera.moveTo(Vector2(100, 0), speed: 5);
for (var i = 0; i < 10; i++) {
expect(camera.viewfinder.position, closeToVector(0.5 * i, 0));
game.update(0.1);
}
camera.moveTo(Vector2(5, 200), speed: 10);
for (var i = 0; i < 10; i++) {
expect(camera.viewfinder.position, closeToVector(5, 1.0 * i));
game.update(0.1);
}
expect(camera.viewfinder.children.length, 1);
});
});
}
Loading