From 07ab8ce9c9fa1a3a792aed28c3d5344a5676cd42 Mon Sep 17 00:00:00 2001 From: Luan Nico Date: Wed, 8 Dec 2021 04:07:36 -0500 Subject: [PATCH] respectCamera and respectViewport proposal: coordinate system enum (#1167) * Coordinate system * Fixess * . * . * Fix tests * Rename CoordinateSystem to PositioningType * Update docs for PositioningType * Use switch instead of if-else * PositioningType -> PositionType * Don't do unnecessary transforms * Added tests for PositionType.widget * Update doc/components.md Co-authored-by: Luan Nico * Added PositionType tests Co-authored-by: Lukas Klingsbo Co-authored-by: Lukas Klingsbo --- doc/components.md | 20 ++++++++ .../input/joystick_advanced_example.dart | 4 +- packages/flame/CHANGELOG.md | 2 +- packages/flame/lib/components.dart | 1 + .../flame/lib/src/components/component.dart | 21 ++++---- .../input/hud_margin_component.dart | 3 +- .../lib/src/components/positioning_type.dart | 15 ++++++ .../flame/lib/src/game/camera/camera.dart | 4 +- .../lib/src/game/camera/camera_wrapper.dart | 38 ++++++++------ .../flame/lib/src/game/camera/viewport.dart | 22 +++++---- packages/flame/lib/src/game/mixins/game.dart | 2 +- packages/flame/lib/src/gestures/events.dart | 38 ++++++++------ .../test/game/camera_and_viewport_test.dart | 41 ++++++++++++++++ .../test/game/component_rendering_test.dart | 49 ++++++++++++++----- 14 files changed, 195 insertions(+), 65 deletions(-) create mode 100644 packages/flame/lib/src/components/positioning_type.dart diff --git a/doc/components.md b/doc/components.md index 84d590e73..951bfdcb1 100644 --- a/doc/components.md +++ b/doc/components.md @@ -114,6 +114,26 @@ void update(double dt) { } ``` +### Positioning types +If you want to create a HUD (Head-up display) or another component that isn't positioned in relation +to the game coordinates, you can change the `PositionType` of the component. +The default `PositionType` is `positionType = PositionType.game` and that can be changed to +either `PositionType.viewport` or `PositionType.widget` depending on how you want to position +the component. + + - `PositionType.game` (Default) - Respects camera and viewport. + - `PositionType.viewport` - Respects viewport only (ignores camera). + - `PositionType.widget` - Position in relation to the coordinate system of the Flutter game + widget (i.e. the raw canvas). + +Most of your components will probably be positioned according to `PositionType.game`, since you +want them to respect the `Camera` and the `Viewport`. But quite often you want for example buttons +and text to always show on the screen, no matter if you move the camera, then you want to use +`PositionType.viewport`. In some rare cases you want to use `PositionType.widget` to position +your widgets, when you don't want the component to respect the camera nor the viewport; this could +for example be for controls or joysticks that would be unergonomic to use if they had to stay within +the viewport. + ## PositionComponent This class represent a positioned object on the screen, being a floating rectangle or a rotating diff --git a/examples/lib/stories/input/joystick_advanced_example.dart b/examples/lib/stories/input/joystick_advanced_example.dart index 03201c6f0..ad729d03e 100644 --- a/examples/lib/stories/input/joystick_advanced_example.dart +++ b/examples/lib/stories/input/joystick_advanced_example.dart @@ -163,11 +163,11 @@ class JoystickAdvancedExample extends FlameGame speedText = TextComponent( text: 'Speed: 0', textRenderer: _regular, - )..respectCamera = false; + )..positionType = PositionType.viewport; directionText = TextComponent( text: 'Direction: idle', textRenderer: _regular, - )..respectCamera = false; + )..positionType = PositionType.viewport; final speedWithMargin = HudMarginComponent( margin: const EdgeInsets.only( diff --git a/packages/flame/CHANGELOG.md b/packages/flame/CHANGELOG.md index e32137748..d9d24307a 100644 --- a/packages/flame/CHANGELOG.md +++ b/packages/flame/CHANGELOG.md @@ -9,10 +9,10 @@ - Introduce `updateTree` to follow the `renderTree` convention - Fix `Parallax.load` with different loading times - Fix render order of components and add tests - - `isHud` renamed to `respectCamera` - Fix `HitboxCircle` when component is flipped - `MoveAlongPathEffect` can now be absolute, and can auto-orient the object along the path - `ScaleEffect.by` now applies multiplicatively instead of additively + - `isHud` replaced with `PositionType` - Remove web fallback for `drawAtlas` in SpriteBatch, but added flag `useAtlas` to activate it ## [1.0.0-releasecandidate.18] diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index b11c439c7..994a86b78 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -16,6 +16,7 @@ export 'src/components/nine_tile_box_component.dart'; export 'src/components/parallax_component.dart'; export 'src/components/particle_component.dart'; export 'src/components/position_component.dart'; +export 'src/components/positioning_type.dart'; export 'src/components/shape_component.dart'; export 'src/components/sprite_animation_component.dart'; export 'src/components/sprite_animation_group_component.dart'; diff --git a/packages/flame/lib/src/components/component.dart b/packages/flame/lib/src/components/component.dart index a53e9432f..bde0eba92 100644 --- a/packages/flame/lib/src/components/component.dart +++ b/packages/flame/lib/src/components/component.dart @@ -9,6 +9,7 @@ import '../../input.dart'; import '../extensions/vector2.dart'; import '../game/mixins/loadable.dart'; import 'cache/value_cache.dart'; +import 'positioning_type.dart'; /// This represents a Component for your game. /// @@ -18,15 +19,12 @@ import 'cache/value_cache.dart'; /// called automatically once the component is added to the component tree in /// your game (with `game.add`). class Component with Loadable { - /// Whether this component should respect the camera or not. - /// - /// Components that have this property set to false will ignore the - /// `FlameGame.camera` when rendered (so their position coordinates are - /// considered relative only to the viewport instead). + /// What coordinate system this component should respect (i.e. should it + /// observe camera, viewport, or use the raw canvas). /// /// Do note that this currently only works if the component is added directly /// to the root `FlameGame`. - bool respectCamera = true; + PositionType positionType = PositionType.game; /// Whether this component has been prepared and is ready to be added to the /// game loop. @@ -157,9 +155,14 @@ class Component with Loadable { @protected Vector2 eventPosition(PositionInfo info) { - return respectCamera - ? info.eventPosition.game - : info.eventPosition.viewportOnly; + switch (positionType) { + case PositionType.game: + return info.eventPosition.game; + case PositionType.viewport: + return info.eventPosition.viewport; + case PositionType.widget: + return info.eventPosition.widget; + } } /// Remove the component from its parent in the next tick. diff --git a/packages/flame/lib/src/components/input/hud_margin_component.dart b/packages/flame/lib/src/components/input/hud_margin_component.dart index 578ea30bb..a02833011 100644 --- a/packages/flame/lib/src/components/input/hud_margin_component.dart +++ b/packages/flame/lib/src/components/input/hud_margin_component.dart @@ -4,6 +4,7 @@ import 'package:meta/meta.dart'; import '../../../components.dart'; import '../../../extensions.dart'; import '../../../game.dart'; +import '../positioning_type.dart'; /// The [HudMarginComponent] positions itself by a margin to the edge of the /// screen instead of by an absolute position on the screen or on the game, so @@ -18,7 +19,7 @@ import '../../../game.dart'; class HudMarginComponent extends PositionComponent with HasGameRef { @override - bool respectCamera = false; + PositionType positionType = PositionType.viewport; /// Instead of setting a position of the [HudMarginComponent] a margin /// from the edges of the viewport can be used instead. diff --git a/packages/flame/lib/src/components/positioning_type.dart b/packages/flame/lib/src/components/positioning_type.dart new file mode 100644 index 000000000..f851b5e73 --- /dev/null +++ b/packages/flame/lib/src/components/positioning_type.dart @@ -0,0 +1,15 @@ +/// Used to define which coordinate system a given top-level component respects. +/// +/// Normally components live in the "game" coordinate system, which just means +/// they respect both the camera and viewport. +enum PositionType { + /// Default type. Respects camera and viewport (applied on top of widget). + game, + + /// Respects viewport only (ignores camera) (applied on top of widget). + viewport, + + /// Position in relation to the coordinate system of the Flutter game widget + /// (i.e. the raw canvas). + widget, +} diff --git a/packages/flame/lib/src/game/camera/camera.dart b/packages/flame/lib/src/game/camera/camera.dart index 2817d59a9..2a8f98e10 100644 --- a/packages/flame/lib/src/game/camera/camera.dart +++ b/packages/flame/lib/src/game/camera/camera.dart @@ -38,8 +38,8 @@ import '../projector.dart'; /// /// Note: in the context of the FlameGame, the camera effectively translates /// the position where components are rendered with relation to the Viewport. -/// Components marked as `respectCamera = false` are always rendered in screen -/// coordinates, bypassing the camera altogether. +/// Components marked as `positionType = PositionType.viewport;` are +/// always rendered in screen coordinates, bypassing the camera altogether. class Camera extends Projector { Camera() : _viewport = DefaultViewport() { _combinedProjector = Projector.compose([this, _viewport]); diff --git a/packages/flame/lib/src/game/camera/camera_wrapper.dart b/packages/flame/lib/src/game/camera/camera_wrapper.dart index 4733f6dca..db559b04f 100644 --- a/packages/flame/lib/src/game/camera/camera_wrapper.dart +++ b/packages/flame/lib/src/game/camera/camera_wrapper.dart @@ -22,24 +22,32 @@ class CameraWrapper { } void render(Canvas canvas) { - camera.viewport.render(canvas, (_canvas) { - var hasCamera = false; // so we don't apply unnecessary transformations - world.forEach((component) { - if (component.respectCamera && !hasCamera) { - canvas.save(); - camera.apply(canvas); - hasCamera = true; - } else if (!component.respectCamera && hasCamera) { + PositionType? _previousType; + canvas.save(); + world.forEach((component) { + final sameType = component.positionType == _previousType; + if (!sameType) { + if (_previousType != null && _previousType != PositionType.widget) { canvas.restore(); - hasCamera = false; + canvas.save(); + } + switch (component.positionType) { + case PositionType.game: + camera.viewport.apply(canvas); + camera.apply(canvas); + break; + case PositionType.viewport: + camera.viewport.apply(canvas); + break; + case PositionType.widget: } - canvas.save(); - component.renderTree(canvas); - canvas.restore(); - }); - if (hasCamera) { - canvas.restore(); } + component.renderTree(canvas); + _previousType = component.positionType; }); + + if (_previousType != PositionType.widget) { + canvas.restore(); + } } } diff --git a/packages/flame/lib/src/game/camera/viewport.dart b/packages/flame/lib/src/game/camera/viewport.dart index 7f91feee6..2f7bd7fb7 100644 --- a/packages/flame/lib/src/game/camera/viewport.dart +++ b/packages/flame/lib/src/game/camera/viewport.dart @@ -52,9 +52,18 @@ abstract class Viewport extends Projector { /// size changes. void resize(Vector2 newCanvasSize); + /// Applies to the Canvas all necessary transformations to apply this + /// viewport. + void apply(Canvas c); + /// This transforms the canvas so that the coordinate system is viewport- /// -aware. All your rendering logic should be put inside the lambda. - void render(Canvas c, void Function(Canvas c) renderGame); + void render(Canvas c, void Function(Canvas) renderGame) { + c.save(); + apply(c); + renderGame(c); + c.restore(); + } /// This returns the effective size, after viewport transformation. /// This is not the game widget size but for all intents and purposes, @@ -75,9 +84,7 @@ abstract class Viewport extends Projector { /// This basically no-ops the viewport. class DefaultViewport extends Viewport { @override - void render(Canvas c, void Function(Canvas c) renderGame) { - renderGame(c); - } + void apply(Canvas c) {} @override void resize(Vector2 newCanvasSize) { @@ -122,7 +129,7 @@ class DefaultViewport extends Viewport { /// transformation whatsoever, and if the a device with a different ratio is /// used it will try to adapt the best as possible. class FixedResolutionViewport extends Viewport { - /// By default, the viewport will clip anything rendered outside. + /// By default, this viewport will clip anything rendered outside. /// Use this variable to control that behaviour. bool noClip; @@ -171,14 +178,11 @@ class FixedResolutionViewport extends Viewport { } @override - void render(Canvas c, void Function(Canvas) renderGame) { - c.save(); + void apply(Canvas c) { if (!noClip) { c.clipRect(_clipRect); } c.transform(_transform.storage); - renderGame(c); - c.restore(); } @override diff --git a/packages/flame/lib/src/game/mixins/game.dart b/packages/flame/lib/src/game/mixins/game.dart index 73a0e9f42..d11c26583 100644 --- a/packages/flame/lib/src/game/mixins/game.dart +++ b/packages/flame/lib/src/game/mixins/game.dart @@ -163,7 +163,7 @@ mixin Game on Loadable { Projector projector = IdentityProjector(); /// This is the projector used by components that don't respect the camera - /// (`respectCamera = false`). + /// (`positionType = PositionType.viewport;`). /// This can be overridden on your [Game] implementation. Projector viewportProjector = IdentityProjector(); diff --git a/packages/flame/lib/src/gestures/events.dart b/packages/flame/lib/src/gestures/events.dart index 671055942..0d4f3b0f8 100644 --- a/packages/flame/lib/src/gestures/events.dart +++ b/packages/flame/lib/src/gestures/events.dart @@ -3,11 +3,17 @@ import 'package:flutter/gestures.dart'; import '../../extensions.dart'; import '../game/mixins/game.dart'; -/// [EventPosition] converts position based events to three different coordinate systems (global, local and game). +/// [EventPosition] converts position based events to three different coordinate +/// systems (global, local and game). /// -/// global: coordinate system relative to the entire app; same as `globalPosition` in Flutter -/// widget: coordinate system relative to the GameWidget widget; same as `localPosition` in Flutter -/// game: same as `widget` but also applies any transformations from the camera or viewport to the coordinate system +/// global: coordinate system relative to the entire app; same as +/// `globalPosition` in Flutter. +/// widget: coordinate system relative to the GameWidget widget; same as +/// `localPosition` in Flutter. +/// viewport: same as `widget` but also applies any transformations from the +/// viewport to the coordinate system. +/// game: same as `widget` but also applies any transformations from the camera +/// and viewport to the coordinate system. class EventPosition { final Game _game; final Offset _globalPosition; @@ -18,20 +24,24 @@ class EventPosition { /// Coordinates of the event relative to the game widget position/size late final Vector2 widget = _game.convertGlobalToLocalCoordinate(global); - /// Coordinates of the event relative to the game position/size but applying only viewport transformations (not camera). - late final Vector2 viewportOnly = - _game.viewportProjector.unprojectVector(widget); + /// Coordinates of the event relative to the game position/size but applying + /// only viewport transformations (not camera). + late final Vector2 viewport = _game.viewportProjector.unprojectVector(widget); - /// Coordinates of the event relative to the game position/size and transformations + /// Coordinates of the event relative to the game position/size and + /// transformations late final Vector2 game = _game.projector.unprojectVector(widget); EventPosition(this._game, this._globalPosition); } -/// [EventDelta] converts deltas based events to two different values (game and global). +/// [EventDelta] converts deltas based events to two different values +/// (game and global). /// -/// [global]: this is the raw value received by the event without any scale applied to it; this is always the same as local because Flutter doesn't apply any scaling. -/// [game]: the scalled value applied all the game transformations. +/// [global]: this is the raw value received by the event without any scale +/// applied to it; this is always the same as local because Flutter doesn't +/// apply any scaling. +/// [game]: the scaled value with all the game transformations applied. class EventDelta { final Game _game; final Offset _delta; @@ -39,9 +49,9 @@ class EventDelta { /// Raw value relative to the game transformations late final Vector2 global = _delta.toVector2(); - /// Scaled value relative to the game viewport only transformations (not camera). - late final Vector2 viewportOnly = - _game.viewportProjector.unscaleVector(global); + /// Scaled value relative to the game viewport only transformations (not + /// camera). + late final Vector2 viewport = _game.viewportProjector.unscaleVector(global); /// Scaled value relative to the game transformations late final Vector2 game = _game.projector.unscaleVector(global); diff --git a/packages/flame/test/game/camera_and_viewport_test.dart b/packages/flame/test/game/camera_and_viewport_test.dart index 19fcdeeef..4d005bac7 100644 --- a/packages/flame/test/game/camera_and_viewport_test.dart +++ b/packages/flame/test/game/camera_and_viewport_test.dart @@ -23,6 +23,47 @@ class _TestComponent extends PositionComponent { } void main() { + group('widget', () { + flameGame.test( + 'viewport does not affect component with PositionType.widget', + (game) async { + game.camera.viewport = FixedResolutionViewport(Vector2.all(50)); + game.onGameResize(Vector2.all(200.0)); + await game.ensureAdd( + _TestComponent(Vector2.zero())..positionType = PositionType.widget, + ); + + final canvas = MockCanvas(); + game.render(canvas); + expect( + canvas, + MockCanvas() + ..translate(0, 0) // transform in PositionComponent.renderTree + ..drawRect(const Rect.fromLTWH(0, 0, 1, 1)), + ); + }, + ); + + flameGame.test( + 'camera does not affect component with PositionType.widget', + (game) async { + await game.ensureAdd( + _TestComponent(Vector2.zero())..positionType = PositionType.widget, + ); + game.camera.snapTo(Vector2(100, 100)); + + final canvas = MockCanvas(); + game.render(canvas); + expect( + canvas, + MockCanvas() + ..translate(0, 0) // transform in PositionComponent.renderTree + ..drawRect(const Rect.fromLTWH(0, 0, 1, 1)), + ); + }, + ); + }); + group('viewport', () { flameGame.test('default viewport does not change size', (game) { game.onGameResize(Vector2(100.0, 200.0)); diff --git a/packages/flame/test/game/component_rendering_test.dart b/packages/flame/test/game/component_rendering_test.dart index f2b0d98ae..aad9af372 100644 --- a/packages/flame/test/game/component_rendering_test.dart +++ b/packages/flame/test/game/component_rendering_test.dart @@ -11,9 +11,9 @@ import 'package:flutter_test/flutter_test.dart'; class _MyComponent extends Component { @override - bool respectCamera; + PositionType positionType; - _MyComponent(int priority, {this.respectCamera = true}) + _MyComponent(int priority, {this.positionType = PositionType.game}) : super(priority: priority); @override @@ -27,7 +27,7 @@ class _MyComponent extends Component { void main() { group('components are rendered according to their priorities', () { flameGame.test( - 'only camera components', + 'PositionType.game', (game) async { await game.ensureAddAll([ _MyComponent(4), @@ -52,12 +52,12 @@ void main() { ); flameGame.test( - 'only HUD components', + 'PositionType.viewport', (game) async { await game.ensureAddAll([ - _MyComponent(4, respectCamera: false), - _MyComponent(1, respectCamera: false), - _MyComponent(2, respectCamera: false), + _MyComponent(4, positionType: PositionType.viewport), + _MyComponent(1, positionType: PositionType.viewport), + _MyComponent(2, positionType: PositionType.viewport), ]); final canvas = MockCanvas(); game.camera.snapTo(Vector2(12.0, 18.0)); @@ -73,16 +73,41 @@ void main() { }, ); + flameGame.test( + 'PositionType.widget', + (game) async { + await game.ensureAddAll([ + _MyComponent(5, positionType: PositionType.widget), + _MyComponent(1, positionType: PositionType.widget), + _MyComponent(2, positionType: PositionType.widget), + ]); + + final canvas = MockCanvas(); + game.camera.snapTo(Vector2(12.0, 18.0)); + game.render(canvas); + + expect( + canvas, + MockCanvas() + ..drawRect(const Rect.fromLTWH(1, 1, 1, 1)) + ..drawRect(const Rect.fromLTWH(2, 2, 1, 1)) + ..drawRect(const Rect.fromLTWH(5, 5, 1, 1)), + ); + }, + ); + flameGame.test( 'mixed', (game) async { await game.ensureAddAll([ _MyComponent(4), _MyComponent(1), - _MyComponent(2, respectCamera: false), - _MyComponent(5, respectCamera: false), - _MyComponent(3, respectCamera: false), + _MyComponent(7, positionType: PositionType.viewport), + _MyComponent(5, positionType: PositionType.viewport), + _MyComponent(3, positionType: PositionType.viewport), _MyComponent(0), + _MyComponent(6, positionType: PositionType.widget), + _MyComponent(2, positionType: PositionType.widget), ]); final canvas = MockCanvas(); @@ -101,7 +126,9 @@ void main() { ..translate(-12.0, -18.0) ..drawRect(const Rect.fromLTWH(4, 4, 1, 1)) ..translate(0.0, 0.0) - ..drawRect(const Rect.fromLTWH(5, 5, 1, 1)), + ..drawRect(const Rect.fromLTWH(5, 5, 1, 1)) + ..drawRect(const Rect.fromLTWH(6, 6, 1, 1)) + ..drawRect(const Rect.fromLTWH(7, 7, 1, 1)), ); }, );