diff --git a/packages/flame/lib/src/camera/camera_component.dart b/packages/flame/lib/src/camera/camera_component.dart index 8b0998f77a7..3d1761dce70 100644 --- a/packages/flame/lib/src/camera/camera_component.dart +++ b/packages/flame/lib/src/camera/camera_component.dart @@ -284,25 +284,55 @@ class CameraComponent extends Component { if ((world?.isMounted ?? false) && currentCameras.length < maxCamerasDepth) { if (checkContains(viewport, viewportPoint)) { - currentCameras.add(this); final worldPoint = transformContext(viewfinder, viewportPoint); if (worldPoint == null) { return; } - yield* viewfinder.componentsAtLocation( - worldPoint, - nestedContexts, - transformContext, - checkContains, + yield* _onCameraStack( + viewfinder.componentsAtLocation( + worldPoint, + nestedContexts, + transformContext, + checkContains, + ), ); - yield* world!.componentsAtLocation( - worldPoint, - nestedContexts, - transformContext, - checkContains, + yield* _onCameraStack( + world!.componentsAtLocation( + worldPoint, + nestedContexts, + transformContext, + checkContains, + ), ); + } + } + } + + /// Iterates [components] with this camera pushed onto [currentCameras], so + /// that a camera nested within the world still sees the correct depth. + /// + /// The camera is pushed and popped around every step rather than around the + /// whole iteration. This is a lazy iterable, so the caller is free to stop + /// early, for example by breaking out of a loop over [componentsAtPoint] as + /// soon as the tapped component is found. That leaves the generator + /// suspended at a `yield`, and the code after it never runs. Popping there + /// would therefore be skipped, and since [currentCameras] is static the + /// entry would be leaked for the rest of the process; after + /// [maxCamerasDepth] such leaks no camera renders its world any more. + Iterable _onCameraStack(Iterable components) sync* { + final iterator = components.iterator; + while (true) { + currentCameras.add(this); + final bool hasNext; + try { + hasNext = iterator.moveNext(); + } finally { currentCameras.removeLast(); } + if (!hasNext) { + return; + } + yield iterator.current; } } diff --git a/packages/flame/test/camera/camera_component_test.dart b/packages/flame/test/camera/camera_component_test.dart index 5e71d0c48fe..de3b7889cf9 100644 --- a/packages/flame/test/camera/camera_component_test.dart +++ b/packages/flame/test/camera/camera_component_test.dart @@ -1,4 +1,5 @@ import 'dart:math'; +import 'dart:ui' show PictureRecorder; import 'package:flame/camera.dart'; import 'package:flame/components.dart'; @@ -240,6 +241,39 @@ void main() { ); }); + testWithFlameGame( + 'componentsAtPoint that is not fully iterated keeps the camera ' + 'rendering', + (game) async { + game.camera.viewport = FixedSizeViewport(600, 400) + ..anchor = Anchor.center + ..position = Vector2(400, 300) + ..priority = -1; + game.camera.viewfinder.position = Vector2(100, 50); + final component = _RenderCounter( + size: Vector2(300, 100), + position: Vector2(50, 30), + ); + game.world.add(component); + await game.ready(); + + // Stopping as soon as the wanted component turns up is what a game + // acting on whatever was tapped does. + for (var i = 0; i < CameraComponent.maxCamerasDepth * 2; i++) { + for (final candidate in game.componentsAtPoint(Vector2(400, 300))) { + if (candidate == component) { + break; + } + } + expect(CameraComponent.currentCameras, isEmpty); + } + + component.renderCount = 0; + game.render(Canvas(PictureRecorder())); + expect(component.renderCount, 1); + }, + ); + testWithFlameGame('visibleWorldRect', (game) async { await game.ready(); final camera = game.camera; @@ -613,6 +647,13 @@ class _SolidBackground extends Component with HasPaint { void render(Canvas canvas) => canvas.drawColor(color, BlendMode.src); } +class _RenderCounter extends PositionComponent { + _RenderCounter({super.size, super.position}); + int renderCount = 0; + @override + void render(Canvas canvas) => renderCount++; +} + class _PostProcessChecker extends PostProcess { bool isLoaded = false;