Skip to content

Commit

Permalink
feat: Add a method to adapt the camera bounds to the world (#2769)
Browse files Browse the repository at this point in the history
This PR adds a method to the CameraComponent class that allows it to set its bounds according to the current world. In fact, this was the behavior of the old camera object.
  • Loading branch information
Skyost committed Nov 8, 2023
1 parent 10df590 commit 87b69df
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 16 deletions.
4 changes: 3 additions & 1 deletion packages/flame/lib/src/camera/behaviors/follow_behavior.dart
Expand Up @@ -69,6 +69,8 @@ class FollowBehavior extends Component {
if (distance > _speed * dt) {
delta.scale(_speed * dt / distance);
}
owner.position = delta..add(owner.position);
if (delta.x != 0 || delta.y != 0) {
owner.position = delta..add(owner.position);
}
}
}
@@ -0,0 +1,117 @@
import 'dart:math';

import 'package:flame/extensions.dart';
import 'package:flame/src/camera/behaviors/bounded_position_behavior.dart';
import 'package:flame/src/camera/camera_component.dart';
import 'package:flame/src/camera/viewfinder.dart';
import 'package:flame/src/camera/viewport.dart';
import 'package:flame/src/components/core/component.dart';
import 'package:flame/src/components/mixins/parent_is_a.dart';
import 'package:flame/src/experimental/geometry/shapes/circle.dart';
import 'package:flame/src/experimental/geometry/shapes/rectangle.dart';
import 'package:flame/src/experimental/geometry/shapes/rounded_rectangle.dart';
import 'package:flame/src/experimental/geometry/shapes/shape.dart';

/// This behavior ensures that none of the viewport can go outside
/// of the bounds, when it is false only the viewfinder anchor is considered.
/// Note that it only works with [Rectangle], [RoundedRectangle] and [Circle]
/// shapes.
class ViewportAwareBoundsBehavior extends Component with ParentIsA<Viewfinder> {
Shape _boundsShape;
late Rect _visibleWorldRect;

ViewportAwareBoundsBehavior({
required Shape boundsShape,
}) : _boundsShape = boundsShape;

@override
void onLoad() {
_visibleWorldRect = parent.visibleWorldRect;
parent.transform.scale.addListener(_updateCameraBoundsIfNeeded);
viewport.transform.scale.addListener(_updateCameraBoundsIfNeeded);
}

@override
void onMount() {
super.onMount();
_updateCameraBounds();
}

@override
void onRemove() {
viewport.transform.scale.removeListener(_updateCameraBoundsIfNeeded);
parent.transform.scale.removeListener(_updateCameraBoundsIfNeeded);
}

/// Returns the bounds that do not take the viewport into account.
/// These bounds are automatically updated when [CameraComponent.setBounds]
/// is being called.
Shape get boundsShape => _boundsShape;

/// Changes the original camera bounds.
/// This setter is used when you call [CameraComponent.setBounds].
set boundsShape(Shape boundsShape) {
_boundsShape = boundsShape;
_updateCameraBounds();
}

/// Returns the camera viewport.
Viewport get viewport => parent.camera.viewport;

/// Calls [_updateCameraBounds] if the [_visibleWorldRect] differs from
/// viewfinder visible world rect.
void _updateCameraBoundsIfNeeded() {
if (_visibleWorldRect != parent.visibleWorldRect) {
_updateCameraBounds();
}
}

/// Triggers an update of the current camera bounds.
void _updateCameraBounds() {
_visibleWorldRect = parent.visibleWorldRect;
final boundedBehavior = parent.firstChild<BoundedPositionBehavior>();
boundedBehavior?.bounds = _calculateViewportAwareBounds();
}

/// This method calculates adapts the [_boundsShape] so that none
/// of the viewport can go outside of the bounds.
/// It returns the [_boundsShape] if it fails to calculates new bounds.
Shape _calculateViewportAwareBounds() {
final worldSize = Vector2(
_boundsShape
.support(
_boundsShape.nearestPoint(
_boundsShape.center + Vector2(1, 0),
),
)
.x,
_boundsShape
.support(
_boundsShape.nearestPoint(
_boundsShape.center + Vector2(0, 1),
),
)
.y,
);
final halfViewportSize = viewport.size / 2;
if (_boundsShape is Rectangle) {
return Rectangle.fromCenter(
center: _boundsShape.center,
size: worldSize - halfViewportSize,
);
} else if (_boundsShape is RoundedRectangle) {
final halfSize = (worldSize - halfViewportSize) / 2;
return RoundedRectangle.fromPoints(
_boundsShape.center - halfSize,
_boundsShape.center + halfSize,
(_boundsShape as RoundedRectangle).radius,
);
} else if (_boundsShape is Circle) {
return Circle(
_boundsShape.center,
worldSize.x - max(halfViewportSize.x, halfViewportSize.y),
);
}
return _boundsShape;
}
}
36 changes: 30 additions & 6 deletions packages/flame/lib/src/camera/camera_component.dart
@@ -1,6 +1,7 @@
import 'package:flame/extensions.dart';
import 'package:flame/src/camera/behaviors/bounded_position_behavior.dart';
import 'package:flame/src/camera/behaviors/follow_behavior.dart';
import 'package:flame/src/camera/behaviors/viewport_aware_bounds_behavior.dart';
import 'package:flame/src/camera/viewfinder.dart';
import 'package:flame/src/camera/viewport.dart';
import 'package:flame/src/camera/viewports/fixed_resolution_viewport.dart';
Expand All @@ -13,6 +14,9 @@ import 'package:flame/src/effects/move_by_effect.dart';
import 'package:flame/src/effects/move_effect.dart';
import 'package:flame/src/effects/move_to_effect.dart';
import 'package:flame/src/effects/provider_interfaces.dart';
import 'package:flame/src/experimental/geometry/shapes/circle.dart';
import 'package:flame/src/experimental/geometry/shapes/rectangle.dart';
import 'package:flame/src/experimental/geometry/shapes/rounded_rectangle.dart';
import 'package:flame/src/experimental/geometry/shapes/shape.dart';
import 'package:flame/src/game/flame_game.dart';

Expand Down Expand Up @@ -88,6 +92,7 @@ class CameraComponent extends Component {
/// than the size of the game canvas. If it is smaller, then the viewport's
/// position specifies where exactly it is placed on the canvas.
Viewport get viewport => _viewport;

set viewport(Viewport newViewport) {
_viewport.removeFromParent();
_viewport = newViewport;
Expand All @@ -105,6 +110,7 @@ class CameraComponent extends Component {
/// (i.e. how much of the world is seen through the viewport), and,
/// optionally, rotation.
Viewfinder get viewfinder => _viewfinder;

set viewfinder(Viewfinder newViewfinder) {
_viewfinder.removeFromParent();
_viewfinder = newViewfinder;
Expand Down Expand Up @@ -321,21 +327,39 @@ class CameraComponent extends Component {
/// 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) {
/// position will be restricted to always remain inside this region.
///
/// When [considerViewport] is true none of the viewport can go outside
/// of the bounds, when it is false only the viewfinder anchor is considered.
/// Note that this option only works with [Rectangle], [RoundedRectangle] and
/// [Circle] shapes.
void setBounds(Shape? bounds, {bool considerViewport = false}) {
final boundedBehavior = viewfinder.firstChild<BoundedPositionBehavior>();
final viewPortAwareBoundsBehavior =
viewfinder.firstChild<ViewportAwareBoundsBehavior>();
if (bounds == null) {
boundedBehavior?.removeFromParent();
} else if (boundedBehavior == null) {
viewPortAwareBoundsBehavior?.removeFromParent();
return;
}
if (boundedBehavior == null) {
viewfinder.add(
BoundedPositionBehavior(bounds: bounds, priority: 1000),
);
} else {
boundedBehavior.bounds = bounds;
}
if (considerViewport) {
if (viewPortAwareBoundsBehavior == null) {
viewfinder.add(
ViewportAwareBoundsBehavior(boundsShape: bounds),
);
} else {
viewPortAwareBoundsBehavior.boundsShape = bounds;
}
} else {
viewPortAwareBoundsBehavior?.removeFromParent();
}
}

/// Returns true if this camera is able to see the [component].
Expand Down
10 changes: 7 additions & 3 deletions packages/flame/lib/src/camera/viewport.dart
@@ -1,5 +1,4 @@
import 'dart:ui';

import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flame/src/anchor.dart';
import 'package:flame/src/camera/camera_component.dart';
Expand Down Expand Up @@ -28,6 +27,9 @@ abstract class Viewport extends Component
final Vector2 _size = Vector2.zero();
bool _isInitialized = false;

@internal
final Transform2D transform = Transform2D();

/// Position of the viewport's anchor in the parent's coordinate frame.
///
/// Changing this position will move the viewport around the screen, but will
Expand Down Expand Up @@ -146,5 +148,7 @@ abstract class Viewport extends Component
return (output?..setValues(x, y)) ?? Vector2(x, y);
}

void transformCanvas(Canvas canvas) {}
void transformCanvas(Canvas canvas) {
canvas.transform2D(transform);
}
}
Expand Up @@ -3,8 +3,6 @@ import 'dart:math';
import 'package:flame/camera.dart';
import 'package:flame/extensions.dart';
import 'package:flame/src/effects/provider_interfaces.dart';
import 'package:flame/src/game/transform2d.dart';
import 'package:meta/meta.dart';

/// [FixedAspectRatioViewport] is a rectangular viewport which auto-expands to
/// take as much space as possible within the canvas, while maintaining a fixed
Expand All @@ -26,9 +24,6 @@ class FixedResolutionViewport extends FixedAspectRatioViewport
@override
Vector2 get virtualSize => resolution;

@internal
final Transform2D transform = Transform2D();

@override
Vector2 get scale => transform.scale;

Expand Down Expand Up @@ -66,7 +61,7 @@ class FixedResolutionViewport extends FixedAspectRatioViewport
@override
void transformCanvas(Canvas canvas) {
canvas.translate(size.x / 2, size.y / 2);
canvas.transform2D(transform);
super.transformCanvas(canvas);
canvas.translate(-(size.x / 2) / scale.x, -(size.y / 2) / scale.y);
}
}
@@ -0,0 +1,29 @@
import 'package:flame/camera.dart';
import 'package:flame/experimental.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('ViewportAwareBoundsBehavior', () {
testWithFlameGame('setBounds considering viewport', (game) async {
final world = World()..addToParent(game);
final camera = CameraComponent(world: world)..addToParent(game);
await game.ready();
final bounds = Rectangle.fromLTRB(0, 0, 400, 50);

camera.setBounds(bounds);
game.update(0);
expect((_getBounds(camera) as Rectangle).toRect(), bounds.toRect());

camera.setBounds(bounds, considerViewport: true);
game.update(0);
expect(
(_getBounds(camera) as Rectangle).toRect(),
Rectangle.fromLTRB(200.0, -100.0, 200.0, 150.0).toRect(),
);
});
});
}

Shape _getBounds(CameraComponent camera) =>
camera.viewfinder.firstChild<BoundedPositionBehavior>()!.bounds;

0 comments on commit 87b69df

Please sign in to comment.