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
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(
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(

View File

@ -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]

View File

@ -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';

View File

@ -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.

View File

@ -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<T extends FlameGame> extends PositionComponent
with HasGameRef<T> {
@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.

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
/// 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]);

View File

@ -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();
}
}
}

View File

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

View File

@ -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();

View File

@ -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);

View File

@ -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));

View File

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