From abb497abe47f6366d27f44d25535924bd7de8a28 Mon Sep 17 00:00:00 2001 From: Pasha Stetsenko Date: Sun, 8 May 2022 11:36:56 -0700 Subject: [PATCH] feat: World bounds for a CameraComponent (#1605) --- doc/flame/camera_component.md | 3 + .../camera_follow_and_world_bounds.dart | 191 ++++++++++++++++++ .../stories/experimental/experimental.dart | 20 +- packages/flame/lib/experimental.dart | 2 + .../bounded_position_behavior.dart | 92 +++++++++ .../src/experimental/camera_component.dart | 22 ++ .../flame/lib/src/extensions/vector2.dart | 5 + .../bounded_position_behavior_test.dart | 74 +++++++ .../experimental/camera_component_test.dart | 40 ++++ 9 files changed, 443 insertions(+), 6 deletions(-) create mode 100644 examples/lib/stories/experimental/camera_follow_and_world_bounds.dart create mode 100644 packages/flame/lib/src/experimental/bounded_position_behavior.dart create mode 100644 packages/flame/test/experimental/bounded_position_behavior_test.dart diff --git a/doc/flame/camera_component.md b/doc/flame/camera_component.md index 2ce5f941f1b..03c000f00cc 100644 --- a/doc/flame/camera_component.md +++ b/doc/flame/camera_component.md @@ -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 diff --git a/examples/lib/stories/experimental/camera_follow_and_world_bounds.dart b/examples/lib/stories/experimental/camera_follow_and_world_bounds.dart new file mode 100644 index 00000000000..4ad657b123d --- /dev/null +++ b/examples/lib/stories/experimental/camera_follow_and_world_bounds.dart @@ -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 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 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 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); + } +} diff --git a/examples/lib/stories/experimental/experimental.dart b/examples/lib/stories/experimental/experimental.dart index 8bd153b6480..34a1b6fc785 100644 --- a/examples/lib/stories/experimental/experimental.dart +++ b/examples/lib/stories/experimental/experimental.dart @@ -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, + ); } diff --git a/packages/flame/lib/experimental.dart b/packages/flame/lib/experimental.dart index b82f7f0ab88..41790807490 100644 --- a/packages/flame/lib/experimental.dart +++ b/packages/flame/lib/experimental.dart @@ -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' diff --git a/packages/flame/lib/src/experimental/bounded_position_behavior.dart b/packages/flame/lib/src/experimental/bounded_position_behavior.dart new file mode 100644 index 00000000000..1e2fe789192 --- /dev/null +++ b/packages/flame/lib/src/experimental/bounded_position_behavior.dart @@ -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; + } + } +} diff --git a/packages/flame/lib/src/experimental/camera_component.dart b/packages/flame/lib/src/experimental/camera_component.dart index 7e2ac74c570..6c8b7029b0e 100644 --- a/packages/flame/lib/src/experimental/camera_component.dart +++ b/packages/flame/lib/src/experimental/camera_component.dart @@ -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'; @@ -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(); + if (bounds == null) { + boundedBehavior?.removeFromParent(); + } else if (boundedBehavior == null) { + viewfinder.add( + BoundedPositionBehavior(bounds: bounds, priority: 1000), + ); + } else { + boundedBehavior.bounds = bounds; + } + } } diff --git a/packages/flame/lib/src/extensions/vector2.dart b/packages/flame/lib/src/extensions/vector2.dart index 2a845121cc4..709d2adf9a0 100644 --- a/packages/flame/lib/src/extensions/vector2.dart +++ b/packages/flame/lib/src/extensions/vector2.dart @@ -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 diff --git a/packages/flame/test/experimental/bounded_position_behavior_test.dart b/packages/flame/test/experimental/bounded_position_behavior_test.dart new file mode 100644 index 00000000000..ad45d3cdf38 --- /dev/null +++ b/packages/flame/test/experimental/bounded_position_behavior_test.dart @@ -0,0 +1,74 @@ +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('BoundedPositionBehavior', () { + testWithFlameGame('target is the parent', (game) async { + final bounds = Rectangle.fromLTRB(0, 0, 200, 100); + final behavior = BoundedPositionBehavior(bounds: bounds); + final component = PositionComponent() + ..add(behavior) + ..addToParent(game); + await game.ready(); + + expect(behavior.target, component); + expect(behavior.bounds, bounds); + expect(behavior.precision, 0.5); + + expect(component.position, Vector2(0, 0)); + component.position.x -= 1; + game.update(0); + expect(component.position, Vector2(0, 0)); + component.position.y += 3; + game.update(0); + expect(component.position, Vector2(0, 3)); + component.position = Vector2(-1, 2); + game.update(0); + expect(component.position, Vector2(0, 3)); + }); + + test('bad precision', () { + final shape = Circle(Vector2.zero(), 10); + expect( + () => BoundedPositionBehavior(bounds: shape, precision: 0), + failsAssert('Precision must be positive: 0.0'), + ); + }); + + testWithFlameGame('bad parent', (game) async { + final shape = Circle(Vector2.zero(), 10); + final parent = Component()..addToParent(game); + await game.ready(); + parent.add(BoundedPositionBehavior(bounds: shape)); + expect( + () => game.update(0), + failsAssert('Can only apply this behavior to a PositionProvider'), + ); + }); + + testWithFlameGame('adjust target position on mount', (game) async { + final shape = Circle(Vector2.zero(), 10); + final target = PositionComponent(position: Vector2(100, 0)); + game.add(target); + target.add(BoundedPositionBehavior(bounds: shape)); + await game.ready(); + expect(target.position, closeToVector(10, 0, epsilon: 0.5)); + }); + + testWithFlameGame('adjust target position on shape change', (game) async { + final shape = Circle(Vector2.zero(), 10); + final target = PositionComponent(position: Vector2(10, 0)); + final behavior = BoundedPositionBehavior(bounds: shape, precision: 0.1); + game.add(target); + target.add(behavior); + await game.ready(); + expect(target.position, Vector2(10, 0)); + + behavior.bounds = Circle(Vector2.zero(), 5); + expect((behavior.bounds as Circle).radius, 5); + expect(target.position, closeToVector(5, 0, epsilon: 0.1)); + }); + }); +} diff --git a/packages/flame/test/experimental/camera_component_test.dart b/packages/flame/test/experimental/camera_component_test.dart index 2d53eb62007..8fc79d5b951 100644 --- a/packages/flame/test/experimental/camera_component_test.dart +++ b/packages/flame/test/experimental/camera_component_test.dart @@ -66,5 +66,45 @@ void main() { } expect(camera.viewfinder.children.length, 1); }); + + testWithFlameGame('setBound', (game) async { + final world = World()..addToParent(game); + final camera = CameraComponent(world: world)..addToParent(game); + await game.ready(); + + camera.setBounds(Rectangle.fromLTRB(0, 0, 400, 50)); + camera.viewfinder.position = Vector2(10, 10); + game.update(0); + expect(camera.viewfinder.position, Vector2(10, 10)); + camera.viewfinder.position = Vector2(-10, 10); + game.update(0); + expect(camera.viewfinder.position, closeToVector(0, 10, epsilon: 0.5)); + + camera.moveTo(Vector2(-20, 0), speed: 10); + for (var i = 0; i < 20; i++) { + expect(camera.viewfinder.position, closeToVector(0, 10, epsilon: 0.5)); + game.update(0.5); + } + + expect( + camera.viewfinder.firstChild(), + isNotNull, + ); + expect( + camera.viewfinder.firstChild()!.bounds, + isA(), + ); + camera.setBounds(Circle(Vector2.zero(), 100)); + expect( + camera.viewfinder.firstChild()!.bounds, + isA(), + ); + camera.setBounds(null); + game.update(0); + expect( + camera.viewfinder.firstChild(), + isNull, + ); + }); }); }