Skip to content

Commit

Permalink
feat: World bounds for a CameraComponent (#1605)
Browse files Browse the repository at this point in the history
  • Loading branch information
st-pasha authored May 8, 2022
1 parent b4ad498 commit abb497a
Show file tree
Hide file tree
Showing 9 changed files with 443 additions and 6 deletions.
3 changes: 3 additions & 0 deletions doc/flame/camera_component.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ Camera has several methods for controlling its behavior:
moving towards another point, those behaviors would be automatically
cancelled.

- `Camera.setBounds()` allows you to add limits to where the camera is allowed to go. These limits
are in the form of a `Shape`, which is commonly a rectangle, but can also be any other shape.


## Comparison to the traditional camera

Expand Down
191 changes: 191 additions & 0 deletions examples/lib/stories/experimental/camera_follow_and_world_bounds.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import 'dart:math';
import 'dart:ui';

import 'package:flame/components.dart';
import 'package:flame/experimental.dart';
import 'package:flame/game.dart';
import 'package:flame/input.dart';
import 'package:flutter/services.dart';

class CameraFollowAndWorldBoundsExample extends FlameGame
with HasKeyboardHandlerComponents {
static const description = '''
This example demonstrates camera following the player, but also obeying the
world bounds (which are set up to leave a small margin around the visible
part of the ground).
Use arrows or keys W,A,D to move the player around. The camera should follow
the player horizontally, but not jump with the player.
''';

@override
Future<void> onLoad() async {
final world = World()..addToParent(this);
final camera = CameraComponent(world: world);
final player = Player()..position = Vector2(250, 0);
camera
..viewfinder.visibleGameSize = Vector2(400, 100)
..follow(player, horizontalOnly: true)
..setBounds(Rectangle.fromLTRB(190, -50, 810, 50));
add(camera);
world.add(Ground());
world.add(player);
}
}

class Ground extends PositionComponent {
Ground()
: pebbles = [],
super(size: Vector2(1000, 30)) {
final random = Random();
for (var i = 0; i < 25; i++) {
pebbles.add(
Vector3(
random.nextDouble() * size.x,
random.nextDouble() * size.y / 3,
random.nextDouble() * 0.5 + 1,
),
);
}
}

final Paint groundPaint = Paint()
..shader = Gradient.linear(
Offset.zero,
const Offset(0, 30),
[const Color(0xFFC9C972), const Color(0x22FFFF88)],
);
final Paint pebblePaint = Paint()..color = const Color(0xFF685A2B);

final List<Vector3> pebbles;

@override
void render(Canvas canvas) {
canvas.drawRect(size.toRect(), groundPaint);
for (final pebble in pebbles) {
canvas.drawCircle(Offset(pebble.x, pebble.y), pebble.z, pebblePaint);
}
}
}

class Player extends PositionComponent with KeyboardHandler {
Player()
: body = Path()
..moveTo(10, 0)
..cubicTo(17, 0, 28, 20, 10, 20)
..cubicTo(-8, 20, 3, 0, 10, 0)
..close(),
eyes = Path()
..addOval(const Rect.fromLTWH(12.5, 9, 4, 6))
..addOval(const Rect.fromLTWH(6.5, 9, 4, 6)),
pupils = Path()
..addOval(const Rect.fromLTWH(14, 11, 2, 2))
..addOval(const Rect.fromLTWH(8, 11, 2, 2)),
velocity = Vector2.zero(),
super(size: Vector2(20, 20), anchor: Anchor.bottomCenter);

final Path body;
final Path eyes;
final Path pupils;
final Paint borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = const Color(0xffffc67c);
final Paint innerPaint = Paint()..color = const Color(0xff9c0051);
final Paint eyesPaint = Paint()..color = const Color(0xFFFFFFFF);
final Paint pupilsPaint = Paint()..color = const Color(0xFF000000);
final Paint shadowPaint = Paint()
..shader = Gradient.radial(
Offset.zero,
10,
[const Color(0x88000000), const Color(0x00000000)],
);

final Vector2 velocity;
final double runSpeed = 150.0;
final double jumpSpeed = 300.0;
final double gravity = 1000.0;
bool facingRight = true;
int nJumpsLeft = 2;

@override
void update(double dt) {
position.x += velocity.x * dt;
position.y += velocity.y * dt;
if (position.y > 0) {
position.y = 0;
velocity.y = 0;
nJumpsLeft = 2;
}
if (position.y < 0) {
velocity.y += gravity * dt;
}
if (position.x < 0) {
position.x = 0;
}
if (position.x > 1000) {
position.x = 1000;
}
}

@override
void render(Canvas canvas) {
{
final h = -position.y; // height above the ground
canvas.save();
canvas.translate(width / 2, height + 1 + h * 1.05);
canvas.scale(1 - h * 0.003, 0.3 - h * 0.001);
canvas.drawCircle(Offset.zero, 10, shadowPaint);
canvas.restore();
}
canvas.drawPath(body, innerPaint);
canvas.drawPath(body, borderPaint);
canvas.drawPath(eyes, eyesPaint);
canvas.drawPath(pupils, pupilsPaint);
}

@override
bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
final isKeyDown = event is RawKeyDownEvent;
final keyLeft = (event.logicalKey == LogicalKeyboardKey.arrowLeft) ||
(event.logicalKey == LogicalKeyboardKey.keyA);
final keyRight = (event.logicalKey == LogicalKeyboardKey.arrowRight) ||
(event.logicalKey == LogicalKeyboardKey.keyD);
final keyUp = (event.logicalKey == LogicalKeyboardKey.arrowUp) ||
(event.logicalKey == LogicalKeyboardKey.keyW);

if (isKeyDown) {
if (keyLeft) {
velocity.x = -runSpeed;
} else if (keyRight) {
velocity.x = runSpeed;
} else if (keyUp && nJumpsLeft > 0) {
velocity.y = -jumpSpeed;
nJumpsLeft -= 1;
}
} else {
final hasLeft = keysPressed.contains(LogicalKeyboardKey.arrowLeft) ||
keysPressed.contains(LogicalKeyboardKey.keyA);
final hasRight = keysPressed.contains(LogicalKeyboardKey.arrowRight) ||
keysPressed.contains(LogicalKeyboardKey.keyD);
if (hasLeft && hasRight) {
// Leave the current speed unchanged
} else if (hasLeft) {
velocity.x = -runSpeed;
} else if (hasRight) {
velocity.x = runSpeed;
} else {
velocity.x = 0;
}
}
if ((velocity.x > 0) && !facingRight) {
facingRight = true;
flipHorizontally();
}
if ((velocity.x < 0) && facingRight) {
facingRight = false;
flipHorizontally();
}
return super.onKeyEvent(event, keysPressed);
}
}
20 changes: 14 additions & 6 deletions examples/lib/stories/experimental/experimental.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@ import 'package:dashbook/dashbook.dart';
import 'package:flame/game.dart';

import '../../commons/commons.dart';
import 'camera_follow_and_world_bounds.dart';
import 'shapes.dart';

void addExperimentalStories(Dashbook dashbook) {
dashbook.storiesOf('Experimental').add(
'Shapes',
(_) => GameWidget(game: ShapesExample()),
codeLink: baseLink('experimental/shapes.dart'),
info: ShapesExample.description,
);
dashbook.storiesOf('Experimental')
..add(
'Shapes',
(_) => GameWidget(game: ShapesExample()),
codeLink: baseLink('experimental/shapes.dart'),
info: ShapesExample.description,
)
..add(
'Follow and World bounds',
(_) => GameWidget(game: CameraFollowAndWorldBoundsExample()),
codeLink: baseLink('experimental/camera_follow_and_world_bounds.dart'),
info: CameraFollowAndWorldBoundsExample.description,
);
}
2 changes: 2 additions & 0 deletions packages/flame/lib/experimental.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
/// After the components lived here for some time, and when we gain more
/// confidence in their robustness, they will be moved out into the main Flame
/// library.
export 'src/experimental/bounded_position_behavior.dart'
show BoundedPositionBehavior;
export 'src/experimental/camera_component.dart' show CameraComponent;
export 'src/experimental/circular_viewport.dart' show CircularViewport;
export 'src/experimental/fixed_aspect_ratio_viewport.dart'
Expand Down
92 changes: 92 additions & 0 deletions packages/flame/lib/src/experimental/bounded_position_behavior.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import '../components/component.dart';
import '../effects/provider_interfaces.dart';
import '../extensions/vector2.dart';
import 'geometry/shapes/shape.dart';

/// This behavior ensures that the target's position stays within the specified
/// [bounds].
///
/// On each game tick this behavior checks whether the target's position remains
/// within the bounds. If it does, then no adjustment are made. However, if this
/// component detects that the target has left the permitted region, it will
/// return it into the [bounds] by moving towards the last known good position
/// and stopping as close to the boundary as possible. The [precision] parameter
/// controls how close to the boundary we want to get before stopping.
///
/// Here [target] is typically the component to which this behavior is attached,
/// but it can also be set explicitly in the constructor. If the target is not
/// passed explicitly in the constructor, then the parent component must be a
/// [PositionProvider].
class BoundedPositionBehavior extends Component {
BoundedPositionBehavior({
required Shape bounds,
PositionProvider? target,
double precision = 0.5,
int? priority,
}) : assert(precision > 0, 'Precision must be positive: $precision'),
_bounds = bounds,
_target = target,
_previousPosition = Vector2.zero(),
_precision = precision,
super(priority: priority);

/// The region within which the target's position must be kept.
Shape get bounds => _bounds;
Shape _bounds;
set bounds(Shape newBounds) {
_bounds = newBounds;
if (!isValidPoint(_previousPosition)) {
_previousPosition.setFrom(_bounds.center);
update(0);
}
}

bool isValidPoint(Vector2 point) => _bounds.containsPoint(point);

PositionProvider get target => _target!;
PositionProvider? _target;

double get precision => _precision;
final double _precision;

/// Saved position from the last game tick.
final Vector2 _previousPosition;

@override
void onMount() {
if (_target == null) {
assert(
parent is PositionProvider,
'Can only apply this behavior to a PositionProvider',
);
_target = parent! as PositionProvider;
}
if (isValidPoint(target.position)) {
_previousPosition.setFrom(target.position);
} else {
_previousPosition.setFrom(_bounds.center);
update(0);
}
}

@override
void update(double dt) {
final currentPosition = _target!.position;
if (isValidPoint(currentPosition)) {
_previousPosition.setFrom(currentPosition);
} else {
var inBoundsPoint = _previousPosition;
var outOfBoundsPoint = currentPosition;
while (inBoundsPoint.taxicabDistanceTo(outOfBoundsPoint) > _precision) {
final newPoint = (inBoundsPoint + outOfBoundsPoint)..scale(0.5);
if (isValidPoint(newPoint)) {
inBoundsPoint = newPoint;
} else {
outOfBoundsPoint = newPoint;
}
}
_previousPosition.setFrom(inBoundsPoint);
_target!.position = inBoundsPoint;
}
}
}
22 changes: 22 additions & 0 deletions packages/flame/lib/src/experimental/camera_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import '../effects/controllers/effect_controller.dart';
import '../effects/move_effect.dart';
import '../effects/move_to_effect.dart';
import '../effects/provider_interfaces.dart';
import 'bounded_position_behavior.dart';
import 'follow_behavior.dart';
import 'geometry/shapes/shape.dart';
import 'max_viewport.dart';
import 'viewfinder.dart';
import 'viewport.dart';
Expand Down Expand Up @@ -197,4 +199,24 @@ class CameraComponent extends Component {
MoveToEffect(point, EffectController(speed: speed)),
);
}

/// Sets or clears the world bounds for the camera's viewfinder.
///
/// The bound is a [Shape], given in the world coordinates. The viewfinder's
/// position will be restricted to always remain inside this region. Note that
/// if you want the camera to never see the empty space outside of the world's
/// rendering area, then you should set up the bounds to be smaller than the
/// size of the world.
void setBounds(Shape? bounds) {
final boundedBehavior = viewfinder.firstChild<BoundedPositionBehavior>();
if (bounds == null) {
boundedBehavior?.removeFromParent();
} else if (boundedBehavior == null) {
viewfinder.add(
BoundedPositionBehavior(bounds: bounds, priority: 1000),
);
} else {
boundedBehavior.bounds = bounds;
}
}
}
5 changes: 5 additions & 0 deletions packages/flame/lib/src/extensions/vector2.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ extension Vector2Extension on Vector2 {
/// Whether the [Vector2] is the identity vector or not
bool isIdentity() => x == 1 && y == 1;

/// Distance to [other] vector, using the taxicab (L1) geometry.
double taxicabDistanceTo(Vector2 other) {
return (x - other.x).abs() + (y - other.y).abs();
}

/// Rotates the [Vector2] with [angle] in radians
/// rotates around [center] if it is defined
/// In a screen coordinate system (where the y-axis is flipped) it rotates in
Expand Down
Loading

0 comments on commit abb497a

Please sign in to comment.