diff --git a/doc/flame/camera_component.md b/doc/flame/camera_component.md index 3bbc5c15a18..3efefe35f3d 100644 --- a/doc/flame/camera_component.md +++ b/doc/flame/camera_component.md @@ -28,6 +28,15 @@ Then, a [](#cameracomponent) class that "looks at" the `World`. The flexibility of rendering the world at any place on the screen, and also control the viewing location and angle. +If you add children to the `Viewport` they will appear as static HUDs in +front of the world and if you add children to the `Viewfinder` they will appear +statically in front of the viewport. + +To add static components behind the world you can add them to the `backdrop` +component, or replace the `backdrop` component. This is for example useful if +you want to have a static `ParallaxComponent` beneath a world that you can move +around it. + ## World diff --git a/examples/lib/stories/camera_and_viewport/camera_and_viewport.dart b/examples/lib/stories/camera_and_viewport/camera_and_viewport.dart index 5c10b71d195..d1e41d7fe0f 100644 --- a/examples/lib/stories/camera_and_viewport/camera_and_viewport.dart +++ b/examples/lib/stories/camera_and_viewport/camera_and_viewport.dart @@ -6,6 +6,7 @@ import 'package:examples/stories/camera_and_viewport/camera_follow_and_world_bou import 'package:examples/stories/camera_and_viewport/coordinate_systems_example.dart'; import 'package:examples/stories/camera_and_viewport/fixed_resolution_example.dart'; import 'package:examples/stories/camera_and_viewport/follow_component_example.dart'; +import 'package:examples/stories/camera_and_viewport/static_components_example.dart'; import 'package:examples/stories/camera_and_viewport/zoom_example.dart'; import 'package:flame/game.dart'; @@ -56,6 +57,21 @@ void addCameraAndViewportStories(Dashbook dashbook) { codeLink: baseLink('camera_and_viewport/fixed_resolution_example.dart'), info: FixedResolutionExample.description, ) + ..add( + 'HUDs and static components', + (context) { + return GameWidget( + game: StaticComponentsExample( + viewportResolution: Vector2( + context.numberProperty('viewport width', 500), + context.numberProperty('viewport height', 500), + ), + ), + ); + }, + codeLink: baseLink('camera_and_viewport/static_components_example.dart'), + info: StaticComponentsExample.description, + ) ..add( 'Coordinate Systems', (context) => const CoordinateSystemsWidget(), diff --git a/examples/lib/stories/camera_and_viewport/static_components_example.dart b/examples/lib/stories/camera_and_viewport/static_components_example.dart new file mode 100644 index 00000000000..b14e21fb104 --- /dev/null +++ b/examples/lib/stories/camera_and_viewport/static_components_example.dart @@ -0,0 +1,141 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/events.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/game.dart'; +import 'package:flame/input.dart'; +import 'package:flame/parallax.dart'; + +class StaticComponentsExample extends FlameGame + with ScrollDetector, ScaleDetector { + static const description = ''' + This example shows a parallax which is attached to the viewport (behind the + world), four Flame logos that are added to the world, and a player added to + the world which is also followed by the camera when you click somewhere. + The text components that are added are self-explanatory. + '''; + + late final ParallaxComponent myParallax; + + StaticComponentsExample({ + required Vector2 viewportResolution, + }) : super( + camera: CameraComponent.withFixedResolution( + width: viewportResolution.x, + height: viewportResolution.y, + ), + world: _StaticComponentWorld(), + ); + + @override + Future onLoad() async { + myParallax = MyParallaxComponent()..parallax?.baseVelocity.setZero(); + camera.backdrop.addAll([ + myParallax, + TextComponent( + text: 'Center backdrop Component', + position: camera.viewport.size / 2 + Vector2(0, 30), + anchor: Anchor.center, + ), + ]); + camera.viewfinder.addAll([ + TextComponent( + text: 'Corner Viewfinder Component', + position: camera.viewport.size - Vector2.all(10), + anchor: Anchor.bottomRight, + ), + ]); + camera.viewport.addAll( + [ + TextComponent( + text: 'Corner Viewport Component', + position: Vector2.all(10), + ), + TextComponent( + text: 'Center Viewport Component', + position: camera.viewport.size / 2, + anchor: Anchor.center, + ), + ], + ); + } +} + +class _StaticComponentWorld extends World + with + HasGameReference, + TapCallbacks, + DoubleTapCallbacks { + late SpriteComponent player; + @override + Future onLoad() async { + final playerSprite = await game.loadSprite('layers/player.png'); + final flameSprite = await game.loadSprite('flame.png'); + final visibleSize = game.camera.visibleWorldRect.toVector2(); + add(player = SpriteComponent(sprite: playerSprite, anchor: Anchor.center)); + addAll([ + SpriteComponent( + sprite: flameSprite, + anchor: Anchor.center, + position: -visibleSize / 8, + size: Vector2(20, 30), + ), + SpriteComponent( + sprite: flameSprite, + anchor: Anchor.center, + position: visibleSize / 8, + size: Vector2(20, 30), + ), + SpriteComponent( + sprite: flameSprite, + anchor: Anchor.center, + position: (visibleSize / 8)..multiply(Vector2(-1, 1)), + size: Vector2(20, 30), + ), + SpriteComponent( + sprite: flameSprite, + anchor: Anchor.center, + position: (visibleSize / 8)..multiply(Vector2(1, -1)), + size: Vector2(20, 30), + ), + ]); + game.camera.follow(player, maxSpeed: 100); + } + + @override + void onTapDown(TapDownEvent event) { + const moveDuration = 1.0; + final deltaX = (event.localPosition - player.position).x; + player.add( + MoveToEffect( + event.localPosition, + EffectController( + duration: moveDuration, + ), + onComplete: () => game.myParallax.parallax?.baseVelocity.setZero(), + ), + ); + final moveSpeedX = deltaX / moveDuration; + game.myParallax.parallax?.baseVelocity.setValues(moveSpeedX, 0); + } +} + +class MyParallaxComponent extends ParallaxComponent { + @override + Future onLoad() async { + parallax = await game.loadParallax( + [ + ParallaxImageData('parallax/bg.png'), + ParallaxImageData('parallax/mountain-far.png'), + ParallaxImageData('parallax/mountains.png'), + ParallaxImageData('parallax/trees.png'), + ParallaxImageData('parallax/foreground-trees.png'), + ], + baseVelocity: Vector2(0, 0), + velocityMultiplierDelta: Vector2(1.8, 1.0), + filterQuality: FilterQuality.none, + ); + } +} diff --git a/packages/flame/lib/src/camera/camera_component.dart b/packages/flame/lib/src/camera/camera_component.dart index 04817714125..456ae989e64 100644 --- a/packages/flame/lib/src/camera/camera_component.dart +++ b/packages/flame/lib/src/camera/camera_component.dart @@ -45,15 +45,17 @@ class CameraComponent extends Component { this.world, Viewport? viewport, Viewfinder? viewfinder, + Component? backdrop, List? hudComponents, }) : _viewport = (viewport ?? MaxViewport())..addAll(hudComponents ?? []), _viewfinder = viewfinder ?? Viewfinder(), + _backdrop = backdrop ?? Component(), // The priority is set to the max here to avoid some bugs for the users, // if they for example would add any components that modify positions // before the CameraComponent, since it then will render the positions // of the last tick each tick. super(priority: 0x7fffffff) { - addAll([_viewport, _viewfinder]); + addAll([_backdrop, _viewport, _viewfinder]); } /// Create a camera that shows a portion of the game world of fixed size @@ -69,6 +71,7 @@ class CameraComponent extends Component { required double width, required double height, World? world, + Component? backdrop, List? hudComponents, }) { return CameraComponent( @@ -76,6 +79,7 @@ class CameraComponent extends Component { viewport: FixedAspectRatioViewport(aspectRatio: width / height) ..addAll(hudComponents ?? []), viewfinder: Viewfinder()..visibleGameSize = Vector2(width, height), + backdrop: backdrop, ); } @@ -122,6 +126,18 @@ class CameraComponent extends Component { /// this variable is a mere reference to it. World? world; + /// The [backdrop] component is rendered statically behind the world. + /// + /// Here you can add things like the parallax component which should be static + /// when the camera moves around. + Component get backdrop => _backdrop; + Component _backdrop; + set backdrop(Component newBackdrop) { + _backdrop.removeFromParent(); + add(newBackdrop); + _backdrop = newBackdrop; + } + /// The axis-aligned bounding rectangle of a [world] region which is currently /// visible through the viewport. /// @@ -150,8 +166,8 @@ class CameraComponent extends Component { /// Renders the [world] as seen through this camera. /// - /// If the world is not mounted yet, only the viewport HUD elements will be - /// rendered. + /// If the world is not mounted yet, only the viewport and viewfinder elements + /// will be rendered. @override void renderTree(Canvas canvas) { canvas.save(); @@ -159,6 +175,7 @@ class CameraComponent extends Component { viewport.position.x - viewport.anchor.x * viewport.size.x, viewport.position.y - viewport.anchor.y * viewport.size.y, ); + backdrop.renderTree(canvas); // Render the world through the viewport if ((world?.isMounted ?? false) && currentCameras.length < maxCamerasDepth) { @@ -168,14 +185,15 @@ class CameraComponent extends Component { currentCameras.add(this); canvas.transform(viewfinder.transform.transformMatrix.storage); world!.renderFromCamera(canvas); - viewfinder.renderTree(canvas); } finally { currentCameras.removeLast(); } canvas.restore(); } - // Now render the HUD elements + // Render the viewport elements, which will be in front of the world. viewport.renderTree(canvas); + // Render the viewfinder elements, which will be in front of the viewport. + viewfinder.renderTree(canvas); canvas.restore(); } diff --git a/packages/flame/lib/src/camera/viewfinder.dart b/packages/flame/lib/src/camera/viewfinder.dart index 30eb5e80f72..0c77580fe43 100644 --- a/packages/flame/lib/src/camera/viewfinder.dart +++ b/packages/flame/lib/src/camera/viewfinder.dart @@ -15,6 +15,9 @@ import 'package:vector_math/vector_math_64.dart'; /// The viewfinder contains the game point that is currently at the /// "cross-hairs" of the viewport ([position]), the [zoom] level, and the /// [angle] of rotation of the camera. +/// +/// If you add children to the [Viewfinder] they will appear like HUDs i.e. +/// statically in front of the world. class Viewfinder extends Component implements AnchorProvider, AngleProvider, PositionProvider, ScaleProvider { /// Internal transform matrix used by the viewfinder. diff --git a/packages/flame/lib/src/camera/viewports/fixed_aspect_ratio_viewport.dart b/packages/flame/lib/src/camera/viewports/fixed_aspect_ratio_viewport.dart index bd9577a1fd2..a4aced611a9 100644 --- a/packages/flame/lib/src/camera/viewports/fixed_aspect_ratio_viewport.dart +++ b/packages/flame/lib/src/camera/viewports/fixed_aspect_ratio_viewport.dart @@ -31,7 +31,9 @@ class FixedAspectRatioViewport extends Viewport { @override void onGameResize(Vector2 size) { - super.onGameResize(size); + if (isLoaded) { + super.onGameResize(size); + } _handleResize(size); } diff --git a/packages/flame/test/_goldens/camera_component_order_test.png b/packages/flame/test/_goldens/camera_component_order_test.png new file mode 100644 index 00000000000..39f68ce9e7c Binary files /dev/null and b/packages/flame/test/_goldens/camera_component_order_test.png differ diff --git a/packages/flame/test/_goldens/circular_viewport_test3.png b/packages/flame/test/_goldens/circular_viewport_test3.png index c102ec459d6..05c2905c85a 100644 Binary files a/packages/flame/test/_goldens/circular_viewport_test3.png and b/packages/flame/test/_goldens/circular_viewport_test3.png differ diff --git a/packages/flame/test/_goldens/circular_viewport_test4.png b/packages/flame/test/_goldens/circular_viewport_test4.png new file mode 100644 index 00000000000..c102ec459d6 Binary files /dev/null and b/packages/flame/test/_goldens/circular_viewport_test4.png differ diff --git a/packages/flame/test/_goldens/circular_viewport_test5.png b/packages/flame/test/_goldens/circular_viewport_test5.png new file mode 100644 index 00000000000..5a69db6bfbd Binary files /dev/null and b/packages/flame/test/_goldens/circular_viewport_test5.png differ diff --git a/packages/flame/test/camera/camera_component_test.dart b/packages/flame/test/camera/camera_component_test.dart index 4646c830139..ac14b48dbcf 100644 --- a/packages/flame/test/camera/camera_component_test.dart +++ b/packages/flame/test/camera/camera_component_test.dart @@ -1,13 +1,15 @@ import 'dart:math'; -import 'dart:ui'; import 'package:flame/camera.dart'; import 'package:flame/components.dart'; import 'package:flame/experimental.dart'; import 'package:flame/extensions.dart'; import 'package:flame_test/flame_test.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'camera_test_helpers.dart'; + void main() { group('CameraComponent', () { testGolden( @@ -305,6 +307,42 @@ void main() { expect(camera.canSee(component), isFalse); }); + + testGolden( + 'Correct order of rendering', + (game) async { + final world = World(); + final camera = CameraComponent(world: world); + game.addAll([world, camera]); + camera.viewfinder.position = Vector2.all(4); + camera.backdrop.add( + CrossHair( + size: Vector2.all(28), + position: camera.viewport.size / 2 + Vector2.all(4), + color: Colors.teal, + ), + ); + camera.viewfinder.add( + CrossHair( + size: Vector2.all(20), + position: camera.viewport.size / 2 + Vector2(-6, 0), + color: Colors.white, + ), + ); + world.add( + CrossHair(size: Vector2.all(14), color: Colors.green), + ); + camera.viewport.add( + CrossHair( + size: Vector2.all(8), + position: camera.viewport.size / 2 + Vector2(4, -4), + color: Colors.red, + ), + ); + }, + goldenFile: '../_goldens/camera_component_order_test.png', + size: Vector2(50, 50), + ); }); group('CameraComponent.canSee', () { diff --git a/packages/flame/test/camera/camera_test_helpers.dart b/packages/flame/test/camera/camera_test_helpers.dart new file mode 100644 index 00000000000..40da4452ceb --- /dev/null +++ b/packages/flame/test/camera/camera_test_helpers.dart @@ -0,0 +1,20 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; + +class CrossHair extends PositionComponent { + CrossHair({super.size, super.position, this.color = const Color(0xFFFF0000)}) + : super(anchor: Anchor.center); + + final Color color; + Paint get _paint => Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 + ..color = color; + + @override + void render(Canvas canvas) { + canvas.drawLine(Offset(size.x / 2, 0), Offset(size.x / 2, size.y), _paint); + canvas.drawLine(Offset(0, size.y / 2), Offset(size.x, size.y / 2), _paint); + } +} diff --git a/packages/flame/test/camera/viewports/circular_viewport_test.dart b/packages/flame/test/camera/viewports/circular_viewport_test.dart index edc60d5ea89..e64d03222a2 100644 --- a/packages/flame/test/camera/viewports/circular_viewport_test.dart +++ b/packages/flame/test/camera/viewports/circular_viewport_test.dart @@ -5,6 +5,8 @@ import 'package:flame/components.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../camera_test_helpers.dart'; + void main() { group('CircularViewport', () { // This should produce a white ellipse on a black background. The ellipse @@ -41,7 +43,7 @@ void main() { size: Vector2(200, 100), ); - // Renders magenta border around the viewport's edge + // Renders magenta border around the viewport's edge behind the world. testGolden( 'circular viewport with debug mode', (game) async { @@ -73,7 +75,7 @@ void main() { camera.viewport.position = Vector2(5, 5); camera.viewport.size = Vector2(40, 40); }, - goldenFile: '../../_goldens/circular_viewport_test2.png', + goldenFile: '../../_goldens/circular_viewport_test3.png', size: Vector2(50, 50), ); @@ -85,11 +87,28 @@ void main() { final viewport = CircularViewport(20)..position = Vector2(5, 5); final camera = CameraComponent(world: world, viewport: viewport); viewport.add( - _CrossHair(size: Vector2.all(16), position: viewport.size / 2), + CrossHair(size: Vector2.all(16), position: viewport.size / 2), ); game.addAll([world, camera]); }, - goldenFile: '../../_goldens/circular_viewport_test3.png', + goldenFile: '../../_goldens/circular_viewport_test4.png', + size: Vector2(50, 50), + ); + + // Renders magenta border around the viewfinder's edge behind the world. + // Should not be visible. + testGolden( + 'circular viewport with debug mode', + (game) async { + final world = _MyWorld(); + final camera = CameraComponent( + world: world, + viewport: CircularViewport(20)..position = Vector2(5, 5), + viewfinder: Viewfinder()..debugMode = true, + ); + game.addAll([world, camera]); + }, + goldenFile: '../../_goldens/circular_viewport_test5.png', size: Vector2(50, 50), ); @@ -149,18 +168,3 @@ class _MyWorld extends World { canvas.drawColor(const Color(0xFFFFFFFF), BlendMode.src); } } - -class _CrossHair extends PositionComponent { - _CrossHair({super.size, super.position}) : super(anchor: Anchor.center); - - final _paint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 2.0 - ..color = const Color(0xFFFF0000); - - @override - void render(Canvas canvas) { - canvas.drawLine(Offset(size.x / 2, 0), Offset(size.x / 2, size.y), _paint); - canvas.drawLine(Offset(0, size.y / 2), Offset(size.x, size.y / 2), _paint); - } -} diff --git a/packages/flame/test/components/component_test.dart b/packages/flame/test/components/component_test.dart index 0c8c54451a4..ae71ccf5070 100644 --- a/packages/flame/test/components/component_test.dart +++ b/packages/flame/test/components/component_test.dart @@ -936,9 +936,9 @@ void main() { expect(game.hasLifecycleEvents, true); expect(game.world.descendants().length, 3); - // Remember that CameraComponent, Viewport, Viewfinder and World are - // added by default. - expect(game.descendants().length, 7); + // Remember that CameraComponent, Viewport, Viewfinder, Backdrop and + // World are added by default. + expect(game.descendants().length, 8); }, );