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 <luanpotter27@gmail.com>

* Added PositionType tests

Co-authored-by: Lukas Klingsbo <me@lukas.fyi>
Co-authored-by: Lukas Klingsbo <lukas.klingsbo@gmail.com>
This commit is contained in:
Luan Nico
2021-12-08 04:07:36 -05:00
committed by GitHub
parent 5b7f31be19
commit 07ab8ce9c9
14 changed files with 195 additions and 65 deletions

View File

@ -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 ## PositionComponent
This class represent a positioned object on the screen, being a floating rectangle or a rotating This class represent a positioned object on the screen, being a floating rectangle or a rotating

View File

@ -163,11 +163,11 @@ class JoystickAdvancedExample extends FlameGame
speedText = TextComponent( speedText = TextComponent(
text: 'Speed: 0', text: 'Speed: 0',
textRenderer: _regular, textRenderer: _regular,
)..respectCamera = false; )..positionType = PositionType.viewport;
directionText = TextComponent( directionText = TextComponent(
text: 'Direction: idle', text: 'Direction: idle',
textRenderer: _regular, textRenderer: _regular,
)..respectCamera = false; )..positionType = PositionType.viewport;
final speedWithMargin = HudMarginComponent( final speedWithMargin = HudMarginComponent(
margin: const EdgeInsets.only( margin: const EdgeInsets.only(

View File

@ -9,10 +9,10 @@
- Introduce `updateTree` to follow the `renderTree` convention - Introduce `updateTree` to follow the `renderTree` convention
- Fix `Parallax.load` with different loading times - Fix `Parallax.load` with different loading times
- Fix render order of components and add tests - Fix render order of components and add tests
- `isHud` renamed to `respectCamera`
- Fix `HitboxCircle` when component is flipped - Fix `HitboxCircle` when component is flipped
- `MoveAlongPathEffect` can now be absolute, and can auto-orient the object along the path - `MoveAlongPathEffect` can now be absolute, and can auto-orient the object along the path
- `ScaleEffect.by` now applies multiplicatively instead of additively - `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 - Remove web fallback for `drawAtlas` in SpriteBatch, but added flag `useAtlas` to activate it
## [1.0.0-releasecandidate.18] ## [1.0.0-releasecandidate.18]

View File

@ -16,6 +16,7 @@ export 'src/components/nine_tile_box_component.dart';
export 'src/components/parallax_component.dart'; export 'src/components/parallax_component.dart';
export 'src/components/particle_component.dart'; export 'src/components/particle_component.dart';
export 'src/components/position_component.dart'; export 'src/components/position_component.dart';
export 'src/components/positioning_type.dart';
export 'src/components/shape_component.dart'; export 'src/components/shape_component.dart';
export 'src/components/sprite_animation_component.dart'; export 'src/components/sprite_animation_component.dart';
export 'src/components/sprite_animation_group_component.dart'; export 'src/components/sprite_animation_group_component.dart';

View File

@ -9,6 +9,7 @@ import '../../input.dart';
import '../extensions/vector2.dart'; import '../extensions/vector2.dart';
import '../game/mixins/loadable.dart'; import '../game/mixins/loadable.dart';
import 'cache/value_cache.dart'; import 'cache/value_cache.dart';
import 'positioning_type.dart';
/// This represents a Component for your game. /// 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 /// called automatically once the component is added to the component tree in
/// your game (with `game.add`). /// your game (with `game.add`).
class Component with Loadable { class Component with Loadable {
/// Whether this component should respect the camera or not. /// What coordinate system this component should respect (i.e. should it
/// /// observe camera, viewport, or use the raw canvas).
/// 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).
/// ///
/// Do note that this currently only works if the component is added directly /// Do note that this currently only works if the component is added directly
/// to the root `FlameGame`. /// 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 /// Whether this component has been prepared and is ready to be added to the
/// game loop. /// game loop.
@ -157,9 +155,14 @@ class Component with Loadable {
@protected @protected
Vector2 eventPosition(PositionInfo info) { Vector2 eventPosition(PositionInfo info) {
return respectCamera switch (positionType) {
? info.eventPosition.game case PositionType.game:
: info.eventPosition.viewportOnly; 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. /// Remove the component from its parent in the next tick.

View File

@ -4,6 +4,7 @@ import 'package:meta/meta.dart';
import '../../../components.dart'; import '../../../components.dart';
import '../../../extensions.dart'; import '../../../extensions.dart';
import '../../../game.dart'; import '../../../game.dart';
import '../positioning_type.dart';
/// The [HudMarginComponent] positions itself by a margin to the edge of the /// 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 /// screen instead of by an absolute position on the screen or on the game, so
@ -18,7 +19,7 @@ import '../../../game.dart';
class HudMarginComponent<T extends FlameGame> extends PositionComponent class HudMarginComponent<T extends FlameGame> extends PositionComponent
with HasGameRef<T> { with HasGameRef<T> {
@override @override
bool respectCamera = false; PositionType positionType = PositionType.viewport;
/// Instead of setting a position of the [HudMarginComponent] a margin /// Instead of setting a position of the [HudMarginComponent] a margin
/// from the edges of the viewport can be used instead. /// from the edges of the viewport can be used instead.

View File

@ -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,
}

View File

@ -38,8 +38,8 @@ import '../projector.dart';
/// ///
/// Note: in the context of the FlameGame, the camera effectively translates /// Note: in the context of the FlameGame, the camera effectively translates
/// the position where components are rendered with relation to the Viewport. /// the position where components are rendered with relation to the Viewport.
/// Components marked as `respectCamera = false` are always rendered in screen /// Components marked as `positionType = PositionType.viewport;` are
/// coordinates, bypassing the camera altogether. /// always rendered in screen coordinates, bypassing the camera altogether.
class Camera extends Projector { class Camera extends Projector {
Camera() : _viewport = DefaultViewport() { Camera() : _viewport = DefaultViewport() {
_combinedProjector = Projector.compose([this, _viewport]); _combinedProjector = Projector.compose([this, _viewport]);

View File

@ -22,24 +22,32 @@ class CameraWrapper {
} }
void render(Canvas canvas) { void render(Canvas canvas) {
camera.viewport.render(canvas, (_canvas) { PositionType? _previousType;
var hasCamera = false; // so we don't apply unnecessary transformations canvas.save();
world.forEach((component) { world.forEach((component) {
if (component.respectCamera && !hasCamera) { final sameType = component.positionType == _previousType;
canvas.save(); if (!sameType) {
camera.apply(canvas); if (_previousType != null && _previousType != PositionType.widget) {
hasCamera = true;
} else if (!component.respectCamera && hasCamera) {
canvas.restore(); 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();
}
} }
} }

View File

@ -52,9 +52,18 @@ abstract class Viewport extends Projector {
/// size changes. /// size changes.
void resize(Vector2 newCanvasSize); 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- /// This transforms the canvas so that the coordinate system is viewport-
/// -aware. All your rendering logic should be put inside the lambda. /// -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 returns the effective size, after viewport transformation.
/// This is not the game widget size but for all intents and purposes, /// 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. /// This basically no-ops the viewport.
class DefaultViewport extends Viewport { class DefaultViewport extends Viewport {
@override @override
void render(Canvas c, void Function(Canvas c) renderGame) { void apply(Canvas c) {}
renderGame(c);
}
@override @override
void resize(Vector2 newCanvasSize) { void resize(Vector2 newCanvasSize) {
@ -122,7 +129,7 @@ class DefaultViewport extends Viewport {
/// transformation whatsoever, and if the a device with a different ratio is /// transformation whatsoever, and if the a device with a different ratio is
/// used it will try to adapt the best as possible. /// used it will try to adapt the best as possible.
class FixedResolutionViewport extends Viewport { 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. /// Use this variable to control that behaviour.
bool noClip; bool noClip;
@ -171,14 +178,11 @@ class FixedResolutionViewport extends Viewport {
} }
@override @override
void render(Canvas c, void Function(Canvas) renderGame) { void apply(Canvas c) {
c.save();
if (!noClip) { if (!noClip) {
c.clipRect(_clipRect); c.clipRect(_clipRect);
} }
c.transform(_transform.storage); c.transform(_transform.storage);
renderGame(c);
c.restore();
} }
@override @override

View File

@ -163,7 +163,7 @@ mixin Game on Loadable {
Projector projector = IdentityProjector(); Projector projector = IdentityProjector();
/// This is the projector used by components that don't respect the camera /// 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. /// This can be overridden on your [Game] implementation.
Projector viewportProjector = IdentityProjector(); Projector viewportProjector = IdentityProjector();

View File

@ -3,11 +3,17 @@ import 'package:flutter/gestures.dart';
import '../../extensions.dart'; import '../../extensions.dart';
import '../game/mixins/game.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 /// global: coordinate system relative to the entire app; same as
/// widget: coordinate system relative to the GameWidget widget; same as `localPosition` in Flutter /// `globalPosition` in Flutter.
/// game: same as `widget` but also applies any transformations from the camera or viewport to the coordinate system /// 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 { class EventPosition {
final Game _game; final Game _game;
final Offset _globalPosition; final Offset _globalPosition;
@ -18,20 +24,24 @@ class EventPosition {
/// Coordinates of the event relative to the game widget position/size /// Coordinates of the event relative to the game widget position/size
late final Vector2 widget = _game.convertGlobalToLocalCoordinate(global); late final Vector2 widget = _game.convertGlobalToLocalCoordinate(global);
/// Coordinates of the event relative to the game position/size but applying only viewport transformations (not camera). /// Coordinates of the event relative to the game position/size but applying
late final Vector2 viewportOnly = /// only viewport transformations (not camera).
_game.viewportProjector.unprojectVector(widget); 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); late final Vector2 game = _game.projector.unprojectVector(widget);
EventPosition(this._game, this._globalPosition); 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. /// [global]: this is the raw value received by the event without any scale
/// [game]: the scalled value applied all the game transformations. /// 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 { class EventDelta {
final Game _game; final Game _game;
final Offset _delta; final Offset _delta;
@ -39,9 +49,9 @@ class EventDelta {
/// Raw value relative to the game transformations /// Raw value relative to the game transformations
late final Vector2 global = _delta.toVector2(); late final Vector2 global = _delta.toVector2();
/// Scaled value relative to the game viewport only transformations (not camera). /// Scaled value relative to the game viewport only transformations (not
late final Vector2 viewportOnly = /// camera).
_game.viewportProjector.unscaleVector(global); late final Vector2 viewport = _game.viewportProjector.unscaleVector(global);
/// Scaled value relative to the game transformations /// Scaled value relative to the game transformations
late final Vector2 game = _game.projector.unscaleVector(global); late final Vector2 game = _game.projector.unscaleVector(global);

View File

@ -23,6 +23,47 @@ class _TestComponent extends PositionComponent {
} }
void main() { 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', () { group('viewport', () {
flameGame.test('default viewport does not change size', (game) { flameGame.test('default viewport does not change size', (game) {
game.onGameResize(Vector2(100.0, 200.0)); game.onGameResize(Vector2(100.0, 200.0));

View File

@ -11,9 +11,9 @@ import 'package:flutter_test/flutter_test.dart';
class _MyComponent extends Component { class _MyComponent extends Component {
@override @override
bool respectCamera; PositionType positionType;
_MyComponent(int priority, {this.respectCamera = true}) _MyComponent(int priority, {this.positionType = PositionType.game})
: super(priority: priority); : super(priority: priority);
@override @override
@ -27,7 +27,7 @@ class _MyComponent extends Component {
void main() { void main() {
group('components are rendered according to their priorities', () { group('components are rendered according to their priorities', () {
flameGame.test( flameGame.test(
'only camera components', 'PositionType.game',
(game) async { (game) async {
await game.ensureAddAll([ await game.ensureAddAll([
_MyComponent(4), _MyComponent(4),
@ -52,12 +52,12 @@ void main() {
); );
flameGame.test( flameGame.test(
'only HUD components', 'PositionType.viewport',
(game) async { (game) async {
await game.ensureAddAll([ await game.ensureAddAll([
_MyComponent(4, respectCamera: false), _MyComponent(4, positionType: PositionType.viewport),
_MyComponent(1, respectCamera: false), _MyComponent(1, positionType: PositionType.viewport),
_MyComponent(2, respectCamera: false), _MyComponent(2, positionType: PositionType.viewport),
]); ]);
final canvas = MockCanvas(); final canvas = MockCanvas();
game.camera.snapTo(Vector2(12.0, 18.0)); 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( flameGame.test(
'mixed', 'mixed',
(game) async { (game) async {
await game.ensureAddAll([ await game.ensureAddAll([
_MyComponent(4), _MyComponent(4),
_MyComponent(1), _MyComponent(1),
_MyComponent(2, respectCamera: false), _MyComponent(7, positionType: PositionType.viewport),
_MyComponent(5, respectCamera: false), _MyComponent(5, positionType: PositionType.viewport),
_MyComponent(3, respectCamera: false), _MyComponent(3, positionType: PositionType.viewport),
_MyComponent(0), _MyComponent(0),
_MyComponent(6, positionType: PositionType.widget),
_MyComponent(2, positionType: PositionType.widget),
]); ]);
final canvas = MockCanvas(); final canvas = MockCanvas();
@ -101,7 +126,9 @@ void main() {
..translate(-12.0, -18.0) ..translate(-12.0, -18.0)
..drawRect(const Rect.fromLTWH(4, 4, 1, 1)) ..drawRect(const Rect.fromLTWH(4, 4, 1, 1))
..translate(0.0, 0.0) ..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)),
); );
}, },
); );