From dccab7eb833d5475adc563aa18bbd653d7d4e216 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 23 Sep 2026 17:10:05 +0200 Subject: [PATCH 1/7] feat: Add WidgetComponent to render Flutter widgets in the component tree --- .github/.cspell/dart_dictionary.txt | 1 + doc/flame/components/utility_components.md | 61 +++ doc/flame/overlays.md | 5 + .../lib/stories/components/components.dart | 7 + .../components/widget_component_example.dart | 169 +++++++ packages/flame/lib/components.dart | 1 + .../lib/src/components/widget_component.dart | 145 ++++++ packages/flame/lib/src/game/game.dart | 28 ++ .../flame/lib/src/game/game_render_box.dart | 259 +++++++++- .../lib/src/game/game_widget/game_widget.dart | 8 + packages/flame/lib/src/game/proxy_canvas.dart | 412 ++++++++++++++++ .../components/widget_component_test.dart | 446 ++++++++++++++++++ .../flame/test/game/proxy_canvas_test.dart | 115 +++++ 13 files changed, 1649 insertions(+), 8 deletions(-) create mode 100644 examples/lib/stories/components/widget_component_example.dart create mode 100644 packages/flame/lib/src/components/widget_component.dart create mode 100644 packages/flame/lib/src/game/proxy_canvas.dart create mode 100644 packages/flame/test/components/widget_component_test.dart create mode 100644 packages/flame/test/game/proxy_canvas_test.dart diff --git a/.github/.cspell/dart_dictionary.txt b/.github/.cspell/dart_dictionary.txt index 3a0eef43893..ac0b9b72de6 100644 --- a/.github/.cspell/dart_dictionary.txt +++ b/.github/.cspell/dart_dictionary.txt @@ -5,3 +5,4 @@ dartdocs # plural of dartdoc endtemplate # Use @endtemplate to close a @template block in dartdoc pubspec # dependency and configuration file of every Dart project unawaited # dart:async helper to mark a Future as intentionally not awaited +superellipse # dart:ui RSuperellipse, used in Canvas.clipRSuperellipse diff --git a/doc/flame/components/utility_components.md b/doc/flame/components/utility_components.md index a3edb3b2567..a9352986537 100644 --- a/doc/flame/components/utility_components.md +++ b/doc/flame/components/utility_components.md @@ -171,6 +171,67 @@ Check the example app for details on how to use it. +## WidgetComponent + +A `WidgetComponent` hosts a Flutter widget inside the Flame component tree. The widget becomes a +real part of the Flutter widget tree under the `GameWidget`, so it is laid out, painted, hit tested +and focused like any other widget: buttons respond to taps, text fields receive keyboard input, and +inherited widgets such as `Theme`, `MediaQuery` and `Directionality` are available to it. At the +same time it is rendered in the middle of the Flame render pass, so it respects the component +`priority`, the camera transform, and the position, angle, scale and anchor of the component and all +of its ancestors. + +```dart +world.add( + WidgetComponent( + position: Vector2(100, 100), + size: Vector2(200, 60), + anchor: Anchor.center, + widget: ElevatedButton( + onPressed: () => print('pressed'), + child: const Text('Play'), + ), + ), +); +``` + +When `size` is given, the widget is laid out with tight constraints of that size, in the same way +as a `SizedBox` would. When it is omitted, the widget is laid out with loose constraints bounded by +the size of the game canvas, and the component adopts whatever size the widget ends up with. + +The hosted widget can be replaced at any time by assigning `widget`, which rebuilds the hosted +subtree in the same way as returning a new widget from a `build` method would. State inside the +widget, such as the text of a `TextField`, is kept as long as the widget types and keys line up, as +in any other Flutter rebuild. + +Widgets are hit tested before the components of the game, so a tap on a button inside a +`WidgetComponent` is not also delivered to a `TapCallbacks` component below it. Material widgets +such as `ElevatedButton` and `TextField` need a `Material` ancestor, so either put a `Material` +inside the hosted widget or make sure the `GameWidget` is inside a `MaterialApp` `Scaffold`. + +This differs from [overlays](../overlays.md), which are placed in a `Stack` on top of the whole +game and are not affected by the camera or by any component transforms. Use overlays for menus and +HUD elements that should stay fixed on the screen, and `WidgetComponent` for widgets that belong to +the game world, for example a speech bubble attached to a character or a form on an in-game +terminal. + +There are some limitations to be aware of: + +- The widget is only rendered by the `GameWidget` render pass. It is not included when the component + tree is rendered to a `Picture` or `Image` elsewhere, for example by the `Snapshot` mixin, by + `PostProcess`es or by the devtools component snapshot. +- Flame paints are not applied to the widget. Paint based effects such as `OpacityEffect` or + `ColorEffect` on the component or its ancestors do not affect the widget, only transforms and + rectangular clips (such as the camera viewport) do. +- A widget that needs its own compositing layer (for example one that contains a `RepaintBoundary`, + a scrollable list, or a platform view) splits the game's picture in two around it. Any `saveLayer` + that an ancestor component has active at that point is closed and reopened around the widget. + +Check the example app +[widget_component](https://github.com/flame-engine/flame/blob/main/examples/lib/stories/components/widget_component_example.dart) +for details on how to use it. + + ## ComponentsNotifier Most of the time just accessing children and their attributes is enough to build the logic of diff --git a/doc/flame/overlays.md b/doc/flame/overlays.md index 55ec6b3d457..e8525a8f91b 100644 --- a/doc/flame/overlays.md +++ b/doc/flame/overlays.md @@ -10,6 +10,11 @@ widgets in your tree. However, if you want to easily show widgets on top of your messages, menu screens or something of that nature, you can use the Widgets Overlay API to make things even easier. +Overlays are placed on top of the whole game and are not affected by the camera or by component +transforms. If you instead want a widget to be part of the game world, rendered in between other +components and following their position, angle and scale, use a +[`WidgetComponent`](components/utility_components.md#widgetcomponent). + `Game.overlays` enables any Flutter widget to be shown on top of a game instance. This makes it very easy to create things like a pause menu or an inventory screen for example. diff --git a/examples/lib/stories/components/components.dart b/examples/lib/stories/components/components.dart index 18c9e58d229..81a939be7f4 100644 --- a/examples/lib/stories/components/components.dart +++ b/examples/lib/stories/components/components.dart @@ -15,6 +15,7 @@ import 'package:examples/stories/components/priority_example.dart'; import 'package:examples/stories/components/skip_text_box_component_example.dart'; import 'package:examples/stories/components/spawn_component_example.dart'; import 'package:examples/stories/components/time_scale_example.dart'; +import 'package:examples/stories/components/widget_component_example.dart'; import 'package:flame/game.dart'; void addComponentsStories(Dashbook dashbook) { @@ -116,5 +117,11 @@ void addComponentsStories(Dashbook dashbook) { (_) => GameWidget(game: SkipTextBoxComponentExample()), codeLink: baseLink('components/skip_text_box_component_example.dart'), info: SkipTextBoxComponentExample.description, + ) + ..add( + 'Widget Component', + (_) => GameWidget(game: WidgetComponentExample()), + codeLink: baseLink('components/widget_component_example.dart'), + info: WidgetComponentExample.description, ); } diff --git a/examples/lib/stories/components/widget_component_example.dart b/examples/lib/stories/components/widget_component_example.dart new file mode 100644 index 00000000000..e11289988f5 --- /dev/null +++ b/examples/lib/stories/components/widget_component_example.dart @@ -0,0 +1,169 @@ +import 'dart:math'; + +import 'package:examples/commons/ember.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; + +class WidgetComponentExample extends FlameGame { + static const String description = ''' + In this example we showcase the `WidgetComponent`, which hosts a Flutter + widget inside the Flame component tree. The widgets are real parts of the + Flutter widget tree, so the button and the text field respond to taps and + keyboard input, while they are rendered with the position, angle, scale and + priority of their component, in between other Flame components. + + Press the button to spawn an Ember, and type in the text field to change the + label of the rotating card. + '''; + + int _spawned = 0; + final ValueNotifier _label = ValueNotifier('Flame'); + + @override + Future onLoad() async { + final button = WidgetComponent( + position: Vector2(size.x / 2, 80), + anchor: Anchor.center, + widget: Material( + color: Colors.transparent, + child: ElevatedButton.icon( + onPressed: _spawnEmber, + icon: const Icon(Icons.add), + label: const Text('Spawn an Ember'), + ), + ), + ); + + final textField = WidgetComponent( + position: Vector2(size.x / 2, 160), + size: Vector2(280, 56), + anchor: Anchor.center, + widget: Material( + color: Colors.transparent, + child: TextField( + onChanged: (value) => _label.value = value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + filled: true, + fillColor: Colors.white, + labelText: 'Card label', + ), + ), + ), + ); + + final card = WidgetComponent( + position: size / 2 + Vector2(0, 80), + size: Vector2(220, 120), + anchor: Anchor.center, + priority: 1, + widget: _LabelCard(label: _label), + ); + card.add( + RotateEffect.by( + 2 * pi, + EffectController(duration: 8, infinite: true), + ), + ); + card.add( + ScaleEffect.to( + Vector2.all(1.3), + EffectController( + duration: 2, + reverseDuration: 2, + infinite: true, + ), + ), + ); + + addAll([ + _BackgroundEmber(position: size / 2 + Vector2(0, 80)), + button, + textField, + card, + _ForegroundEmber(position: size / 2 + Vector2(0, 80)), + ]); + } + + void _spawnEmber() { + _spawned++; + final ember = Ember( + position: Vector2( + 60.0 + (_spawned * 70) % (size.x - 120), + size.y - 60, + ), + size: Vector2.all(40), + ); + ember.add( + MoveEffect.by( + Vector2(0, -30), + EffectController( + duration: 1, + reverseDuration: 1, + infinite: true, + ), + ), + ); + add(ember); + } +} + +/// An Ember rendered behind the card, to show that widgets are rendered in +/// between other components according to their priority. +class _BackgroundEmber extends Ember { + _BackgroundEmber({required super.position}) + : super(size: Vector2.all(160), priority: 0); +} + +/// An Ember rendered in front of the card, orbiting around it. +class _ForegroundEmber extends Ember { + _ForegroundEmber({required super.position}) + : super(size: Vector2.all(40), priority: 2); + + @override + Future onLoad() async { + await super.onLoad(); + add( + MoveAlongPathEffect( + Path()..addOval(Rect.fromCircle(center: Offset.zero, radius: 140)), + EffectController(duration: 6, infinite: true), + ), + ); + } +} + +class _LabelCard extends StatelessWidget { + const _LabelCard({required this.label}); + + final ValueNotifier label; + + @override + Widget build(BuildContext context) { + return Card( + color: Colors.orange.shade100, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.widgets, size: 32), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: label, + builder: (context, value, child) { + return Text( + value.isEmpty ? 'Flame' : value, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index 88aefc8f204..c674fd5fc04 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -54,6 +54,7 @@ export 'src/components/text_box_component.dart'; export 'src/components/text_component.dart'; export 'src/components/text_element_component.dart'; export 'src/components/timer_component.dart'; +export 'src/components/widget_component.dart'; export 'src/extensions/vector2.dart'; export 'src/geometry/circle_component.dart'; export 'src/geometry/polygon_component.dart'; diff --git a/packages/flame/lib/src/components/widget_component.dart b/packages/flame/lib/src/components/widget_component.dart new file mode 100644 index 00000000000..de9e23e4bda --- /dev/null +++ b/packages/flame/lib/src/components/widget_component.dart @@ -0,0 +1,145 @@ +import 'package:flame/src/components/position_component.dart'; +import 'package:flame/src/extensions/vector2.dart'; +import 'package:flame/src/game/game.dart'; +import 'package:flutter/widgets.dart'; + +/// A [PositionComponent] that hosts a Flutter [widget] inside the Flame +/// component tree. +/// +/// The widget becomes a real part of the Flutter element tree under the +/// `GameWidget`, so it is laid out, painted, hit tested and focused like any +/// other widget: buttons respond to taps, text fields receive keyboard input, +/// and inherited widgets such as `Theme`, `MediaQuery` and `Directionality` +/// are available to it. At the same time it is rendered in the middle of the +/// Flame render pass, so it respects the component [priority], the camera +/// transform and the position, angle, scale and anchor of this component and +/// all of its ancestors. +/// +/// ```dart +/// world.add( +/// WidgetComponent( +/// position: Vector2(100, 100), +/// size: Vector2(200, 60), +/// anchor: Anchor.center, +/// widget: ElevatedButton( +/// onPressed: () => print('pressed'), +/// child: const Text('Play'), +/// ), +/// ), +/// ); +/// ``` +/// +/// When [size] is given, the widget is laid out with tight constraints of that +/// size. When it is omitted, the widget is laid out with loose constraints +/// bounded by the size of the game canvas, and the component adopts whatever +/// size the widget ends up with. +/// +/// Limitations: +/// - The widget is only rendered by the `GameWidget` render pass. It is not +/// included when the component tree is rendered to a `Picture` or `Image` +/// elsewhere, for example by the `Snapshot` mixin, `PostProcess`es or the +/// devtools component snapshot. +/// - Flame paints and clips are not applied to the widget. Paint based effects +/// such as `OpacityEffect` or `ColorEffect` on this component or its +/// ancestors do not affect the widget, only transforms do. +/// - A widget that needs its own compositing layer (for example one that +/// contains a `RepaintBoundary`) splits the game's picture, which means that +/// any `saveLayer` an ancestor component has active at that point is closed +/// and reopened around it. +class WidgetComponent extends PositionComponent { + // ignore: use_super_parameters + WidgetComponent({ + required Widget widget, + Vector2? size, + super.position, + super.scale, + super.angle, + super.nativeAngle, + super.anchor, + super.children, + super.priority, + super.key, + }) : _widget = widget, // ignore: prefer_initializing_formals + _adoptsWidgetSize = size == null, + super(size: size) { + this.size.addListener(_onSizeChanged); + } + + Widget _widget; + final bool _adoptsWidgetSize; + bool _isAdoptingSize = false; + Game? _game; + + /// The Flutter widget hosted by this component. + /// + /// Assigning a new widget rebuilds the hosted subtree, in the same way as + /// returning a new widget from a `build` method would. + Widget get widget => _widget; + set widget(Widget value) { + if (identical(_widget, value)) { + return; + } + _widget = value; + _game?.refreshWidget(isInternalRefresh: false); + } + + /// Whether the component takes its [size] from the laid out widget, which is + /// the case when no size was passed to the constructor. + bool get adoptsWidgetSize => _adoptsWidgetSize; + + /// The [BoxConstraints] that the widget is laid out with. + BoxConstraints constraintsFor(Vector2 gameSize) { + if (_adoptsWidgetSize) { + return BoxConstraints.loose(Size(gameSize.x, gameSize.y)); + } + return BoxConstraints.tightFor(width: size.x, height: size.y); + } + + /// Called by the `GameRenderBox` after the widget has been laid out, so that + /// the component can adopt the widget's size without triggering a relayout. + void adoptWidgetSize(Size widgetSize) { + if (!_adoptsWidgetSize) { + return; + } + if (size.x == widgetSize.width && size.y == widgetSize.height) { + return; + } + _isAdoptingSize = true; + size = Vector2(widgetSize.width, widgetSize.height); + _isAdoptingSize = false; + } + + void _onSizeChanged() { + if (_isAdoptingSize) { + return; + } + final game = _game; + if (game != null && game.isAttached) { + game.renderBox.markNeedsLayout(); + } + } + + @override + void onMount() { + super.onMount(); + final game = findRootGame()!; + _game = game; + game.registerWidgetComponent(this); + } + + @override + void onRemove() { + _game?.unregisterWidgetComponent(this); + _game = null; + super.onRemove(); + } + + @override + void render(Canvas canvas) { + final game = _game; + if (game == null || !game.isAttached) { + return; + } + game.renderBox.paintWidgetComponent(this, canvas); + } +} diff --git a/packages/flame/lib/src/game/game.dart b/packages/flame/lib/src/game/game.dart index 72107cfd8b8..86e6191ce1f 100644 --- a/packages/flame/lib/src/game/game.dart +++ b/packages/flame/lib/src/game/game.dart @@ -414,6 +414,34 @@ abstract mixin class Game { /// ``` late final overlays = OverlayManager(this); + final List _widgetComponents = []; + + /// The [WidgetComponent]s that are currently mounted in this game, in the + /// order they were mounted. The `GameWidget` builds the widget of each of + /// them as a child of the game's render box. + @internal + List get widgetComponents => _widgetComponents; + + /// Registers a mounted [WidgetComponent] so that its widget is built into + /// the widget tree. Called by the component itself when it is mounted. + @internal + void registerWidgetComponent(WidgetComponent component) { + if (_widgetComponents.contains(component)) { + return; + } + _widgetComponents.add(component); + refreshWidget(isInternalRefresh: false); + } + + /// Removes a [WidgetComponent] that is no longer mounted. Called by the + /// component itself when it is removed. + @internal + void unregisterWidgetComponent(WidgetComponent component) { + if (_widgetComponents.remove(component)) { + refreshWidget(isInternalRefresh: false); + } + } + /// Used to change the mouse cursor of the GameWidget running this game. /// Setting the value to null will make the GameWidget defer the choice /// of the cursor to the closest region available on the tree. diff --git a/packages/flame/lib/src/game/game_render_box.dart b/packages/flame/lib/src/game/game_render_box.dart index 731c82f32f4..150498e6b4d 100644 --- a/packages/flame/lib/src/game/game_render_box.dart +++ b/packages/flame/lib/src/game/game_render_box.dart @@ -1,18 +1,28 @@ -import 'package:flame/extensions.dart'; -import 'package:flame/game.dart'; +import 'dart:ui' as ui; + +import 'package:flame/extensions.dart' hide Matrix4; +import 'package:flame/game.dart' hide Matrix4; +import 'package:flame/src/components/widget_component.dart'; import 'package:flame/src/game/game_loop.dart'; +import 'package:flame/src/game/proxy_canvas.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart' hide WidgetBuilder; +import 'package:meta/meta.dart'; /// A [RenderObjectWidget] that renders the [GameRenderBox]. /// /// This is the widget that is used by the [GameWidget] to ACTUALLY /// render the game. -class RenderGameWidget extends LeafRenderObjectWidget { +/// +/// Its [children] are the widgets hosted by the [WidgetComponent]s that are +/// currently mounted in the game, each wrapped in a +/// [WidgetComponentParentDataWidget]. +class RenderGameWidget extends MultiChildRenderObjectWidget { const RenderGameWidget({ required this.game, required this.addRepaintBoundary, required this.behavior, + super.children, super.key, }); @@ -40,7 +50,46 @@ class RenderGameWidget extends LeafRenderObjectWidget { } } -class GameRenderBox extends RenderBox with WidgetsBindingObserver { +/// Parent data for the children of [GameRenderBox], linking each child render +/// box to the [WidgetComponent] that hosts it. +class WidgetComponentParentData extends ContainerBoxParentData { + WidgetComponent? component; + + /// The transform from the child's coordinates to the local coordinates of + /// the [GameRenderBox], as of the last time the child was painted. + Matrix4? paintTransform; +} + +/// Wraps the widget of a [WidgetComponent] so that the [GameRenderBox] knows +/// which component a child render box belongs to. +@internal +class WidgetComponentParentDataWidget + extends ParentDataWidget { + const WidgetComponentParentDataWidget({ + required this.component, + required super.child, + super.key, + }); + + final WidgetComponent component; + + @override + void applyParentData(RenderObject renderObject) { + final parentData = renderObject.parentData! as WidgetComponentParentData; + if (parentData.component != component) { + parentData.component = component; + renderObject.parent?.markNeedsLayout(); + } + } + + @override + Type get debugTypicalAncestorWidgetClass => RenderGameWidget; +} + +class GameRenderBox extends RenderBox + with + ContainerRenderObjectMixin, + WidgetsBindingObserver { GameRenderBox( this._game, this.buildContext, { @@ -88,12 +137,60 @@ class GameRenderBox extends RenderBox with WidgetsBindingObserver { HitTestBehavior behavior; + final Map _childByComponent = {}; + + /// The widgets that were painted during the last paint, in paint order. + final List<_PaintedWidget> _paintedWidgets = []; + + PaintingContext? _paintingContext; + ProxyCanvas? _canvas; + + /// The transform from this render box's local coordinates to the coordinate + /// space of the canvas the game was rendered on during the last paint, + /// inverted so that widget transforms can be brought back to local space. + Matrix4? _inverseBaseTransform; + Matrix4? _baseTransform; + + /// The components whose widgets were painted during the last paint, in + /// paint order. + @visibleForTesting + List get paintedWidgetComponents { + return [for (final painted in _paintedWidgets) painted.component]; + } + @override bool get sizedByParent => true; @override Size computeDryLayout(BoxConstraints constraints) => constraints.biggest; + @override + void setupParentData(RenderBox child) { + if (child.parentData is! WidgetComponentParentData) { + child.parentData = WidgetComponentParentData(); + } + } + + @override + void performLayout() { + _childByComponent.clear(); + _paintedWidgets.clear(); + final gameSize = size.toVector2(); + var child = firstChild; + while (child != null) { + final parentData = child.parentData! as WidgetComponentParentData; + final component = parentData.component; + if (component == null) { + child.layout(BoxConstraints.tight(Size.zero)); + } else { + _childByComponent[component] = child; + child.layout(component.constraintsFor(gameSize), parentUsesSize: true); + component.adoptWidgetSize(child.size); + } + child = parentData.nextSibling; + } + } + @override void attach(PipelineOwner owner) { super.attach(owner); @@ -142,12 +239,138 @@ class GameRenderBox extends RenderBox with WidgetsBindingObserver { return game.containsEventHandlerAt(position.toVector2()); } + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + final baseTransform = _baseTransform; + if (baseTransform == null) { + return false; + } + final canvasPosition = MatrixUtils.transformPoint(baseTransform, position); + for (var i = _paintedWidgets.length - 1; i >= 0; i--) { + final painted = _paintedWidgets[i]; + if (!painted.clip.contains(canvasPosition)) { + continue; + } + final isHit = result.addWithPaintTransform( + transform: painted.localTransform, + position: position, + hitTest: (result, transformed) { + return painted.child.hitTest(result, position: transformed); + }, + ); + if (isHit) { + return true; + } + } + return false; + } + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + final paintTransform = + (child.parentData! as WidgetComponentParentData).paintTransform; + if (paintTransform != null) { + transform.multiply(paintTransform); + } + } + @override void paint(PaintingContext context, Offset offset) { - context.canvas.save(); - context.canvas.translate(offset.dx, offset.dy); - game.render(context.canvas); - context.canvas.restore(); + _paintedWidgets.clear(); + if (firstChild == null) { + _baseTransform = null; + _inverseBaseTransform = null; + context.canvas.save(); + context.canvas.translate(offset.dx, offset.dy); + game.render(context.canvas); + context.canvas.restore(); + return; + } + + final canvas = ProxyCanvas(context.canvas); + _paintingContext = context; + _canvas = canvas; + canvas.save(); + canvas.translate(offset.dx, offset.dy); + final baseTransform = Matrix4.fromFloat64List(canvas.getTransform()); + _baseTransform = baseTransform; + _inverseBaseTransform = Matrix4.tryInvert(baseTransform); + try { + game.render(canvas); + } finally { + canvas.restore(); + _paintingContext = null; + _canvas = null; + } + } + + /// Paints the widget hosted by [component] at the current transform of + /// [canvas], as part of the game render pass. + /// + /// Does nothing when [canvas] is not the canvas of the current paint, which + /// is the case when the component tree is being rendered somewhere else, + /// for example into a snapshot. + @internal + void paintWidgetComponent(WidgetComponent component, ui.Canvas canvas) { + final context = _paintingContext; + final proxyCanvas = _canvas; + final inverseBaseTransform = _inverseBaseTransform; + if (context == null || + proxyCanvas == null || + inverseBaseTransform == null || + !identical(canvas, proxyCanvas)) { + return; + } + final child = _childByComponent[component]; + if (child == null) { + return; + } + + final transform = Matrix4.fromFloat64List(canvas.getTransform()); + final clip = canvas.getDestinationClipBounds(); + final localTransform = inverseBaseTransform.multiplied(transform); + (child.parentData! as WidgetComponentParentData).paintTransform = + localTransform; + _paintedWidgets.add( + _PaintedWidget( + component: component, + child: child, + clip: clip, + localTransform: localTransform, + ), + ); + + if (!child.needsCompositing) { + context.paintChild(child, Offset.zero); + return; + } + + if (_isClippedByFlame(clip)) { + context.pushClipRect(true, Offset.zero, clip, (context, offset) { + context.pushTransform(true, offset, transform, _paintChildOf(child)); + }); + } else { + context.pushTransform(true, Offset.zero, transform, _paintChildOf(child)); + } + proxyCanvas.swap(context.canvas); + } + + /// Whether [clip], given in the coordinate space of the canvas that the game + /// is rendered on, cuts into the area of this render box. + bool _isClippedByFlame(Rect clip) { + final bounds = MatrixUtils.transformRect( + _baseTransform!, + Offset.zero & size, + ); + const tolerance = 0.001; + return clip.left > bounds.left + tolerance || + clip.top > bounds.top + tolerance || + clip.right < bounds.right - tolerance || + clip.bottom < bounds.bottom - tolerance; + } + + static PaintingContextCallback _paintChildOf(RenderBox child) { + return (context, offset) => context.paintChild(child, offset); } void _bindLifecycleListener() { @@ -163,3 +386,23 @@ class GameRenderBox extends RenderBox with WidgetsBindingObserver { game.lifecycleStateChange(state); } } + +class _PaintedWidget { + _PaintedWidget({ + required this.component, + required this.child, + required this.clip, + required this.localTransform, + }); + + final WidgetComponent component; + final RenderBox child; + + /// The clip that was active when the widget was painted, in the coordinate + /// space of the canvas the game was rendered on. + final Rect clip; + + /// The transform from the widget's coordinates to the local coordinates of + /// the [GameRenderBox]. + final Matrix4 localTransform; +} diff --git a/packages/flame/lib/src/game/game_widget/game_widget.dart b/packages/flame/lib/src/game/game_widget/game_widget.dart index 235752c94cd..420a95a4b47 100644 --- a/packages/flame/lib/src/game/game_widget/game_widget.dart +++ b/packages/flame/lib/src/game/game_widget/game_widget.dart @@ -384,6 +384,14 @@ class GameWidgetState extends State> { game: currentGame, addRepaintBoundary: widget.addRepaintBoundary, behavior: widget.behavior, + children: [ + for (final component in currentGame.widgetComponents) + WidgetComponentParentDataWidget( + key: ObjectKey(component), + component: component, + child: component.widget, + ), + ], ); internalGameWidget = currentGame.gestureDetectors.build( diff --git a/packages/flame/lib/src/game/proxy_canvas.dart b/packages/flame/lib/src/game/proxy_canvas.dart new file mode 100644 index 00000000000..353b03c3d95 --- /dev/null +++ b/packages/flame/lib/src/game/proxy_canvas.dart @@ -0,0 +1,412 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:meta/meta.dart'; + +/// A [Canvas] that forwards every call to an underlying canvas, and that can +/// swap that underlying canvas mid-frame while preserving the current save +/// stack, transforms and clips. +/// +/// Flame renders the whole component tree into a single canvas object that is +/// passed down the tree. When a `WidgetComponent` needs to composite a Flutter +/// layer in the middle of that tree, the picture that is being recorded has to +/// be ended and a new one started, which means a new canvas object. This class +/// hides that swap from the rest of the component tree: after [swap], the new +/// canvas is brought to the same state (save count, transforms and clips) that +/// the old one was in, so that pending `restore` calls in the component tree +/// keep balancing. +@internal +class ProxyCanvas implements Canvas { + ProxyCanvas(this._canvas) : _baseSaveCount = _canvas.getSaveCount() { + _levels.add(_SaveLevel.plain()); + } + + Canvas _canvas; + int _baseSaveCount; + final List<_SaveLevel> _levels = []; + + /// The canvas that currently receives all the calls. + Canvas get inner => _canvas; + + /// Replaces the underlying canvas with [canvas], replaying the current save + /// stack, transforms and clips onto it first. + void swap(Canvas canvas) { + _baseSaveCount = canvas.getSaveCount(); + for (var i = 0; i < _levels.length; i++) { + final level = _levels[i]; + if (i > 0) { + if (level.isLayer) { + canvas.saveLayer(level.layerBounds, level.layerPaint!); + } else { + canvas.save(); + } + } + for (final op in level.ops) { + op.apply(canvas); + } + } + _canvas = canvas; + } + + _SaveLevel get _current => _levels.last; + + @override + void save() { + _canvas.save(); + _levels.add(_SaveLevel.plain()); + } + + @override + void saveLayer(Rect? bounds, Paint paint) { + _canvas.saveLayer(bounds, paint); + _levels.add(_SaveLevel.layer(bounds, paint)); + } + + @override + void restore() { + if (_levels.length > 1) { + _levels.removeLast(); + } + _canvas.restore(); + } + + @override + void restoreToCount(int count) { + _canvas.restoreToCount(count); + final targetLevels = (count - _baseSaveCount + 1).clamp(1, _levels.length); + _levels.removeRange(targetLevels, _levels.length); + } + + @override + int getSaveCount() => _canvas.getSaveCount(); + + @override + void translate(double dx, double dy) { + _canvas.translate(dx, dy); + _current.ops.add(_TranslateOp(dx, dy)); + } + + @override + void scale(double sx, [double? sy]) { + _canvas.scale(sx, sy); + _current.ops.add(_ScaleOp(sx, sy)); + } + + @override + void rotate(double radians) { + _canvas.rotate(radians); + _current.ops.add(_RotateOp(radians)); + } + + @override + void skew(double sx, double sy) { + _canvas.skew(sx, sy); + _current.ops.add(_SkewOp(sx, sy)); + } + + @override + void transform(Float64List matrix4) { + _canvas.transform(matrix4); + _current.ops.add(_TransformOp(Float64List.fromList(matrix4))); + } + + @override + Float64List getTransform() => _canvas.getTransform(); + + @override + void clipRect( + Rect rect, { + ClipOp clipOp = ClipOp.intersect, + bool doAntiAlias = true, + }) { + _canvas.clipRect(rect, clipOp: clipOp, doAntiAlias: doAntiAlias); + _current.ops.add( + _ClipRectOp(rect, clipOp: clipOp, doAntiAlias: doAntiAlias), + ); + } + + @override + void clipRRect(RRect rrect, {bool doAntiAlias = true}) { + _canvas.clipRRect(rrect, doAntiAlias: doAntiAlias); + _current.ops.add(_ClipRRectOp(rrect, doAntiAlias: doAntiAlias)); + } + + @override + void clipRSuperellipse( + RSuperellipse shape, { + bool doAntiAlias = true, + }) { + _canvas.clipRSuperellipse(shape, doAntiAlias: doAntiAlias); + _current.ops.add( + _ClipRSuperellipseOp(shape, doAntiAlias: doAntiAlias), + ); + } + + @override + void clipPath(Path path, {bool doAntiAlias = true}) { + _canvas.clipPath(path, doAntiAlias: doAntiAlias); + _current.ops.add(_ClipPathOp(path, doAntiAlias: doAntiAlias)); + } + + @override + Rect getLocalClipBounds() => _canvas.getLocalClipBounds(); + + @override + Rect getDestinationClipBounds() => _canvas.getDestinationClipBounds(); + + @override + void drawColor(Color color, BlendMode blendMode) { + _canvas.drawColor(color, blendMode); + } + + @override + void drawLine(Offset p1, Offset p2, Paint paint) { + _canvas.drawLine(p1, p2, paint); + } + + @override + void drawPaint(Paint paint) { + _canvas.drawPaint(paint); + } + + @override + void drawRect(Rect rect, Paint paint) { + _canvas.drawRect(rect, paint); + } + + @override + void drawRRect(RRect rrect, Paint paint) { + _canvas.drawRRect(rrect, paint); + } + + @override + void drawDRRect(RRect outer, RRect inner, Paint paint) { + _canvas.drawDRRect(outer, inner, paint); + } + + @override + void drawRSuperellipse(RSuperellipse shape, Paint paint) { + _canvas.drawRSuperellipse(shape, paint); + } + + @override + void drawOval(Rect rect, Paint paint) { + _canvas.drawOval(rect, paint); + } + + @override + void drawCircle(Offset c, double radius, Paint paint) { + _canvas.drawCircle(c, radius, paint); + } + + @override + void drawArc( + Rect rect, + double startAngle, + double sweepAngle, + bool useCenter, + Paint paint, + ) { + _canvas.drawArc(rect, startAngle, sweepAngle, useCenter, paint); + } + + @override + void drawPath(Path path, Paint paint) { + _canvas.drawPath(path, paint); + } + + @override + void drawImage(Image image, Offset offset, Paint paint) { + _canvas.drawImage(image, offset, paint); + } + + @override + void drawImageRect(Image image, Rect src, Rect dst, Paint paint) { + _canvas.drawImageRect(image, src, dst, paint); + } + + @override + void drawImageNine(Image image, Rect center, Rect dst, Paint paint) { + _canvas.drawImageNine(image, center, dst, paint); + } + + @override + void drawPicture(Picture picture) { + _canvas.drawPicture(picture); + } + + @override + void drawParagraph(Paragraph paragraph, Offset offset) { + _canvas.drawParagraph(paragraph, offset); + } + + @override + void drawPoints(PointMode pointMode, List points, Paint paint) { + _canvas.drawPoints(pointMode, points, paint); + } + + @override + void drawRawPoints(PointMode pointMode, Float32List points, Paint paint) { + _canvas.drawRawPoints(pointMode, points, paint); + } + + @override + void drawVertices(Vertices vertices, BlendMode blendMode, Paint paint) { + _canvas.drawVertices(vertices, blendMode, paint); + } + + @override + void drawAtlas( + Image atlas, + List transforms, + List rects, + List? colors, + BlendMode? blendMode, + Rect? cullRect, + Paint paint, + ) { + _canvas.drawAtlas( + atlas, + transforms, + rects, + colors, + blendMode, + cullRect, + paint, + ); + } + + @override + void drawRawAtlas( + Image atlas, + Float32List rstTransforms, + Float32List rects, + Int32List? colors, + BlendMode? blendMode, + Rect? cullRect, + Paint paint, + ) { + _canvas.drawRawAtlas( + atlas, + rstTransforms, + rects, + colors, + blendMode, + cullRect, + paint, + ); + } + + @override + void drawShadow( + Path path, + Color color, + double elevation, + bool transparentOccluder, + ) { + _canvas.drawShadow(path, color, elevation, transparentOccluder); + } +} + +class _SaveLevel { + _SaveLevel.plain() : isLayer = false, layerBounds = null, layerPaint = null; + + _SaveLevel.layer(this.layerBounds, this.layerPaint) : isLayer = true; + + final bool isLayer; + final Rect? layerBounds; + final Paint? layerPaint; + final List<_CanvasOp> ops = []; +} + +abstract class _CanvasOp { + void apply(Canvas canvas); +} + +class _TranslateOp implements _CanvasOp { + _TranslateOp(this.dx, this.dy); + final double dx; + final double dy; + + @override + void apply(Canvas canvas) => canvas.translate(dx, dy); +} + +class _ScaleOp implements _CanvasOp { + _ScaleOp(this.sx, this.sy); + final double sx; + final double? sy; + + @override + void apply(Canvas canvas) => canvas.scale(sx, sy); +} + +class _RotateOp implements _CanvasOp { + _RotateOp(this.radians); + final double radians; + + @override + void apply(Canvas canvas) => canvas.rotate(radians); +} + +class _SkewOp implements _CanvasOp { + _SkewOp(this.sx, this.sy); + final double sx; + final double sy; + + @override + void apply(Canvas canvas) => canvas.skew(sx, sy); +} + +class _TransformOp implements _CanvasOp { + _TransformOp(this.matrix4); + final Float64List matrix4; + + @override + void apply(Canvas canvas) => canvas.transform(matrix4); +} + +class _ClipRectOp implements _CanvasOp { + _ClipRectOp(this.rect, {required this.clipOp, required this.doAntiAlias}); + final Rect rect; + final ClipOp clipOp; + final bool doAntiAlias; + + @override + void apply(Canvas canvas) { + canvas.clipRect(rect, clipOp: clipOp, doAntiAlias: doAntiAlias); + } +} + +class _ClipRRectOp implements _CanvasOp { + _ClipRRectOp(this.rrect, {required this.doAntiAlias}); + final RRect rrect; + final bool doAntiAlias; + + @override + void apply(Canvas canvas) { + canvas.clipRRect(rrect, doAntiAlias: doAntiAlias); + } +} + +class _ClipRSuperellipseOp implements _CanvasOp { + _ClipRSuperellipseOp(this.shape, {required this.doAntiAlias}); + final RSuperellipse shape; + final bool doAntiAlias; + + @override + void apply(Canvas canvas) { + canvas.clipRSuperellipse(shape, doAntiAlias: doAntiAlias); + } +} + +class _ClipPathOp implements _CanvasOp { + _ClipPathOp(this.path, {required this.doAntiAlias}); + final Path path; + final bool doAntiAlias; + + @override + void apply(Canvas canvas) { + canvas.clipPath(path, doAntiAlias: doAntiAlias); + } +} diff --git a/packages/flame/test/components/widget_component_test.dart b/packages/flame/test/components/widget_component_test.dart new file mode 100644 index 00000000000..4754ecf36f3 --- /dev/null +++ b/packages/flame/test/components/widget_component_test.dart @@ -0,0 +1,446 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _TappableComponent extends PositionComponent with TapCallbacks { + int tapUpCount = 0; + + @override + void onTapUp(TapUpEvent event) { + tapUpCount++; + } +} + +class _SnapshotComponent extends PositionComponent with Snapshot {} + +class _CameraGame extends FlameGame { + @override + Future onLoad() async { + camera.viewfinder.zoom = 2; + camera.viewfinder.anchor = Anchor.topLeft; + camera.viewfinder.position = Vector2(50, 50); + } +} + +class _ClippedCameraGame extends FlameGame { + _ClippedCameraGame() + : super( + camera: CameraComponent.withFixedResolution(width: 400, height: 400), + ); + + @override + Future onLoad() async { + camera.viewfinder.anchor = Anchor.topLeft; + } +} + +const _red = Color(0xFFFF0000); + +Finder _redBox() { + return find.byWidgetPredicate( + (widget) => widget is ColoredBox && widget.color == _red, + ); +} + +Future _pumpGame(WidgetTester tester, FlameGame game) async { + await tester.pumpWidget(MaterialApp(home: GameWidget(game: game))); + await tester.pump(); + await tester.pump(); + await game.ready(); + await tester.pump(); + await tester.pump(); +} + +Widget _button(String label, VoidCallback onPressed) { + return Material( + child: ElevatedButton(onPressed: onPressed, child: Text(label)), + ); +} + +void main() { + group('WidgetComponent', () { + testWidgets('renders its widget inside the game', (tester) async { + final game = FlameGame(); + final component = WidgetComponent( + widget: const Text('hello'), + size: Vector2(100, 40), + ); + game.add(component); + await _pumpGame(tester, game); + + expect(find.text('hello'), findsOneWidget); + expect(game.renderBox.paintedWidgetComponents, [component]); + }); + + testWidgets('lays the widget out with the component size', ( + tester, + ) async { + final game = FlameGame(); + final component = WidgetComponent( + widget: const ColoredBox(color: _red), + size: Vector2(120, 40), + ); + game.add(component); + await _pumpGame(tester, game); + + expect(tester.getSize(_redBox()), const Size(120, 40)); + + component.size = Vector2(60, 20); + await tester.pump(); + await tester.pump(); + expect(tester.getSize(_redBox()), const Size(60, 20)); + }); + + testWidgets('adopts the size of the widget when no size is given', ( + tester, + ) async { + final game = FlameGame(); + final component = WidgetComponent( + widget: const SizedBox(width: 70, height: 30), + ); + game.add(component); + await _pumpGame(tester, game); + + expect(component.adoptsWidgetSize, isTrue); + expect(component.size, Vector2(70, 30)); + + component.widget = const SizedBox(width: 50, height: 10); + await tester.pump(); + await tester.pump(); + expect(component.size, Vector2(50, 10)); + }); + + testWidgets('positions the widget on screen', (tester) async { + final game = FlameGame(); + game.add( + WidgetComponent( + widget: const ColoredBox(color: _red), + size: Vector2(100, 50), + position: Vector2(200, 100), + anchor: Anchor.center, + ), + ); + await _pumpGame(tester, game); + + expect( + tester.getRect(_redBox()), + const Rect.fromLTWH(150, 75, 100, 50), + ); + }); + + testWidgets('follows the camera transform', (tester) async { + final game = _CameraGame(); + game.world.add( + WidgetComponent( + widget: const ColoredBox(color: _red), + size: Vector2(100, 50), + position: Vector2(100, 100), + ), + ); + await _pumpGame(tester, game); + + expect( + tester.getRect(_redBox()), + const Rect.fromLTWH(100, 100, 200, 100), + ); + }); + + testWidgets('receives taps', (tester) async { + var pressed = 0; + final game = FlameGame(); + game.add( + WidgetComponent( + widget: _button('Press', () => pressed++), + size: Vector2(120, 40), + position: Vector2(300, 200), + ), + ); + await _pumpGame(tester, game); + + await tester.tapAt(const Offset(360, 220)); + await tester.pump(); + expect(pressed, 1); + + await tester.tapAt(const Offset(100, 100)); + await tester.pump(); + expect(pressed, 1); + }); + + testWidgets('receives taps when rotated and scaled', (tester) async { + var pressed = 0; + final game = FlameGame(); + final component = WidgetComponent( + widget: _button('Press', () => pressed++), + size: Vector2(120, 40), + position: Vector2(300, 200), + anchor: Anchor.center, + angle: pi / 2, + scale: Vector2.all(2), + ); + game.add(component); + await _pumpGame(tester, game); + + final inside = component.absolutePositionOf(Vector2(110, 10)); + await tester.tapAt(Offset(inside.x, inside.y)); + await tester.pump(); + expect(pressed, 1); + + final outside = component.absolutePositionOf(Vector2(130, 10)); + await tester.tapAt(Offset(outside.x, outside.y)); + await tester.pump(); + expect(pressed, 1); + }); + + testWidgets('takes priority over Flame tap callbacks below it', ( + tester, + ) async { + var pressed = 0; + final game = FlameGame(); + final tappable = _TappableComponent()..size = Vector2(800, 600); + game.add(tappable); + game.add( + WidgetComponent( + widget: _button('Press', () => pressed++), + size: Vector2(120, 40), + position: Vector2(300, 200), + priority: 1, + ), + ); + await _pumpGame(tester, game); + + await tester.tapAt(const Offset(360, 220)); + await tester.pump(const Duration(milliseconds: 100)); + expect(pressed, 1); + expect(tappable.tapUpCount, 0); + + await tester.tapAt(const Offset(100, 100)); + await tester.pump(const Duration(milliseconds: 100)); + expect(pressed, 1); + expect(tappable.tapUpCount, 1); + }); + + testWidgets('hit tests the topmost of overlapping widgets', ( + tester, + ) async { + var bottomPressed = 0; + var topPressed = 0; + final game = FlameGame(); + final bottom = WidgetComponent( + widget: _button('Bottom', () => bottomPressed++), + size: Vector2(120, 40), + position: Vector2(300, 200), + priority: 2, + ); + final top = WidgetComponent( + widget: _button('Top', () => topPressed++), + size: Vector2(120, 40), + position: Vector2(300, 200), + priority: 1, + ); + game.addAll([top, bottom]); + await _pumpGame(tester, game); + + expect(game.renderBox.paintedWidgetComponents, [top, bottom]); + + await tester.tapAt(const Offset(360, 220)); + await tester.pump(); + expect(bottomPressed, 1); + expect(topPressed, 0); + + bottom.priority = 0; + await tester.pump(); + await tester.pump(); + expect(game.renderBox.paintedWidgetComponents, [bottom, top]); + + await tester.tapAt(const Offset(360, 220)); + await tester.pump(); + expect(bottomPressed, 1); + expect(topPressed, 1); + }); + + testWidgets('renders and hit tests a composited widget', (tester) async { + var pressed = 0; + final game = FlameGame(); + game.add(RectangleComponent(size: Vector2(800, 600))); + game.add( + WidgetComponent( + widget: RepaintBoundary( + child: _button('Press', () => pressed++), + ), + size: Vector2(120, 40), + position: Vector2(300, 200), + priority: 1, + ), + ); + game.add( + RectangleComponent(size: Vector2(10, 10), priority: 2), + ); + await _pumpGame(tester, game); + + expect(find.text('Press'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.tapAt(const Offset(360, 220)); + await tester.pump(); + expect(pressed, 1); + }); + + testWidgets('splits the game picture around a composited widget', ( + tester, + ) async { + final game = FlameGame(); + game.add(RectangleComponent(size: Vector2(800, 600))); + game.add( + WidgetComponent( + widget: const RepaintBoundary(child: ColoredBox(color: _red)), + size: Vector2(120, 40), + position: Vector2(300, 200), + priority: 1, + ), + ); + game.add(RectangleComponent(size: Vector2(10, 10), priority: 2)); + await _pumpGame(tester, game); + + final gameLayer = game.renderBox.debugLayer!; + final children = []; + var layer = gameLayer.firstChild; + while (layer != null) { + children.add(layer); + layer = layer.nextSibling; + } + expect(children.map((layer) => layer.runtimeType).toList(), [ + PictureLayer, + TransformLayer, + PictureLayer, + ]); + final transformLayer = children[1] as TransformLayer; + expect(transformLayer.transform!.getTranslation().x, 300); + expect(transformLayer.transform!.getTranslation().y, 200); + }); + + testWidgets('is clipped and hit tested by the camera viewport', ( + tester, + ) async { + var pressed = 0; + final game = _ClippedCameraGame(); + game.world.add( + WidgetComponent( + widget: RepaintBoundary( + child: _button('Press', () => pressed++), + ), + size: Vector2(120, 40), + position: Vector2(380, 100), + ), + ); + await _pumpGame(tester, game); + expect(tester.takeException(), isNull); + + // On the 800x600 test screen the viewport is scaled by 1.5 and covers + // the horizontal range 100 to 700, so the widget spans 670 to 850 on + // screen but is clipped at 700. + await tester.tapAt(const Offset(690, 180)); + await tester.pump(); + expect(pressed, 1); + + await tester.tapAt(const Offset(720, 180)); + await tester.pump(); + expect(pressed, 1); + }); + + testWidgets('removes the widget when the component is removed', ( + tester, + ) async { + final game = FlameGame(); + final component = WidgetComponent( + widget: const Text('hello'), + size: Vector2(100, 40), + ); + game.add(component); + await _pumpGame(tester, game); + expect(find.text('hello'), findsOneWidget); + + component.removeFromParent(); + await tester.pump(); + await tester.pump(); + expect(find.text('hello'), findsNothing); + expect(game.widgetComponents, isEmpty); + }); + + testWidgets('rebuilds when the widget is replaced', (tester) async { + final game = FlameGame(); + final component = WidgetComponent( + widget: const Text('one'), + size: Vector2(100, 40), + ); + game.add(component); + await _pumpGame(tester, game); + expect(find.text('one'), findsOneWidget); + + component.widget = const Text('two'); + await tester.pump(); + await tester.pump(); + expect(find.text('one'), findsNothing); + expect(find.text('two'), findsOneWidget); + }); + + testWidgets('keeps widget state across game frames', (tester) async { + final game = FlameGame(); + game.add( + WidgetComponent( + widget: const Material(child: TextField()), + size: Vector2(200, 60), + position: Vector2(100, 100), + ), + ); + await _pumpGame(tester, game); + + await tester.enterText(find.byType(TextField), 'typed'); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('typed'), findsOneWidget); + }); + + testWidgets('is skipped when rendered into a snapshot', (tester) async { + final game = FlameGame(); + final snapshot = _SnapshotComponent()..size = Vector2(200, 200); + snapshot.add( + WidgetComponent( + widget: const Text('hello'), + size: Vector2(100, 40), + ), + ); + game.add(snapshot); + await _pumpGame(tester, game); + + expect(tester.takeException(), isNull); + expect(game.renderBox.paintedWidgetComponents, isEmpty); + }); + + testWithFlameGame('mounts without a GameWidget', (game) async { + final component = WidgetComponent( + widget: const Text('hello'), + size: Vector2(100, 40), + ); + game.add(component); + await game.ready(); + + expect(component.isMounted, isTrue); + expect(game.widgetComponents, [component]); + game.update(0); + final recorder = PictureRecorder(); + game.render(Canvas(recorder)); + recorder.endRecording().dispose(); + + component.removeFromParent(); + await game.ready(); + expect(game.widgetComponents, isEmpty); + }); + }); +} diff --git a/packages/flame/test/game/proxy_canvas_test.dart b/packages/flame/test/game/proxy_canvas_test.dart new file mode 100644 index 00000000000..dd5ee7718d6 --- /dev/null +++ b/packages/flame/test/game/proxy_canvas_test.dart @@ -0,0 +1,115 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flame/src/game/proxy_canvas.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ProxyCanvas', () { + test('forwards drawing calls to the inner canvas', () { + final recorder = PictureRecorder(); + final inner = Canvas(recorder); + final canvas = ProxyCanvas(inner); + + canvas.translate(10, 20); + canvas.drawRect(const Rect.fromLTWH(0, 0, 10, 10), Paint()); + + expect(canvas.inner, same(inner)); + expect(canvas.getTransform(), inner.getTransform()); + expect(canvas.getTransform()[12], 10); + expect(canvas.getTransform()[13], 20); + recorder.endRecording().dispose(); + }); + + test('replays the save stack, transforms and clips on swap', () { + final firstRecorder = PictureRecorder(); + final first = Canvas(firstRecorder); + final canvas = ProxyCanvas(first); + + canvas.translate(10, 20); + canvas.save(); + canvas.scale(2); + canvas.clipRect(const Rect.fromLTWH(0, 0, 100, 100)); + canvas.save(); + canvas.rotate(1); + canvas.transform( + Float64List.fromList( + [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 5, 0, 1], + ), + ); + final expectedTransform = first.getTransform(); + final expectedClip = first.getDestinationClipBounds(); + final expectedSaveCount = first.getSaveCount(); + + final secondRecorder = PictureRecorder(); + final second = Canvas(secondRecorder); + canvas.swap(second); + + expect(canvas.inner, same(second)); + expect(second.getSaveCount(), expectedSaveCount); + expect(second.getTransform(), expectedTransform); + expect(second.getDestinationClipBounds(), expectedClip); + + canvas.restore(); + canvas.restore(); + expect(second.getSaveCount(), 1); + expect(second.getTransform()[0], 1); + expect(second.getTransform()[12], 10); + expect(second.getTransform()[13], 20); + + firstRecorder.endRecording().dispose(); + secondRecorder.endRecording().dispose(); + }); + + test('drops popped levels before a swap', () { + final firstRecorder = PictureRecorder(); + final first = Canvas(firstRecorder); + final canvas = ProxyCanvas(first); + + canvas.save(); + canvas.translate(100, 0); + canvas.restore(); + canvas.save(); + canvas.translate(1, 0); + canvas.save(); + canvas.translate(1, 0); + canvas.save(); + canvas.translate(1, 0); + canvas.restoreToCount(2); + final expectedTransform = first.getTransform(); + + final secondRecorder = PictureRecorder(); + final second = Canvas(secondRecorder); + canvas.swap(second); + + expect(second.getSaveCount(), 2); + expect(second.getTransform(), expectedTransform); + expect(second.getTransform()[12], 1); + + firstRecorder.endRecording().dispose(); + secondRecorder.endRecording().dispose(); + }); + + test('replays a saveLayer as a new layer on the swapped canvas', () { + final firstRecorder = PictureRecorder(); + final first = Canvas(firstRecorder); + final canvas = ProxyCanvas(first); + + canvas.saveLayer(null, Paint()); + canvas.translate(3, 4); + + final secondRecorder = PictureRecorder(); + final second = Canvas(secondRecorder); + canvas.swap(second); + + expect(second.getSaveCount(), 2); + expect(second.getTransform()[12], 3); + expect(second.getTransform()[13], 4); + canvas.restore(); + expect(second.getSaveCount(), 1); + + firstRecorder.endRecording().dispose(); + secondRecorder.endRecording().dispose(); + }); + }); +} From d42d6a510f2fdc31cb821d1e1568123991207c57 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 23 Sep 2026 17:19:32 +0200 Subject: [PATCH 2/7] fix: Sort the Dart dictionary, skip zero scaled widgets, and polish the example --- .github/.cspell/dart_dictionary.txt | 2 +- .../lib/stories/components/components.dart | 4 ++- .../components/widget_component_example.dart | 35 ++++++++++++------- .../flame/lib/src/game/game_render_box.dart | 3 ++ .../components/widget_component_test.dart | 20 +++++++++++ 5 files changed, 49 insertions(+), 15 deletions(-) diff --git a/.github/.cspell/dart_dictionary.txt b/.github/.cspell/dart_dictionary.txt index ac0b9b72de6..a35c2273828 100644 --- a/.github/.cspell/dart_dictionary.txt +++ b/.github/.cspell/dart_dictionary.txt @@ -4,5 +4,5 @@ dartdoc # documentation tool for dart dartdocs # plural of dartdoc endtemplate # Use @endtemplate to close a @template block in dartdoc pubspec # dependency and configuration file of every Dart project -unawaited # dart:async helper to mark a Future as intentionally not awaited superellipse # dart:ui RSuperellipse, used in Canvas.clipRSuperellipse +unawaited # dart:async helper to mark a Future as intentionally not awaited diff --git a/examples/lib/stories/components/components.dart b/examples/lib/stories/components/components.dart index 81a939be7f4..59bf02129d6 100644 --- a/examples/lib/stories/components/components.dart +++ b/examples/lib/stories/components/components.dart @@ -120,7 +120,9 @@ void addComponentsStories(Dashbook dashbook) { ) ..add( 'Widget Component', - (_) => GameWidget(game: WidgetComponentExample()), + (_) => const GameWidget.managed( + gameFactory: WidgetComponentExample.new, + ), codeLink: baseLink('components/widget_component_example.dart'), info: WidgetComponentExample.description, ); diff --git a/examples/lib/stories/components/widget_component_example.dart b/examples/lib/stories/components/widget_component_example.dart index e11289988f5..8b117106b32 100644 --- a/examples/lib/stories/components/widget_component_example.dart +++ b/examples/lib/stories/components/widget_component_example.dart @@ -14,13 +14,16 @@ class WidgetComponentExample extends FlameGame { keyboard input, while they are rendered with the position, angle, scale and priority of their component, in between other Flame components. - Press the button to spawn an Ember, and type in the text field to change the - label of the rotating card. + Press the button to spawn Embers around the card, randomly behind or in + front of it, and type in the text field to change the label of the + rotating card. '''; - int _spawned = 0; + final Random _random = Random(); final ValueNotifier _label = ValueNotifier('Flame'); + Vector2 get _cardCenter => size / 2 + Vector2(0, 80); + @override Future onLoad() async { final button = WidgetComponent( @@ -44,18 +47,20 @@ class WidgetComponentExample extends FlameGame { color: Colors.transparent, child: TextField( onChanged: (value) => _label.value = value, + style: const TextStyle(color: Colors.black87), decoration: const InputDecoration( border: OutlineInputBorder(), filled: true, fillColor: Colors.white, labelText: 'Card label', + labelStyle: TextStyle(color: Colors.black54), ), ), ), ); final card = WidgetComponent( - position: size / 2 + Vector2(0, 80), + position: _cardCenter, size: Vector2(220, 120), anchor: Anchor.center, priority: 1, @@ -79,22 +84,22 @@ class WidgetComponentExample extends FlameGame { ); addAll([ - _BackgroundEmber(position: size / 2 + Vector2(0, 80)), + _BackgroundEmber(position: _cardCenter), button, textField, card, - _ForegroundEmber(position: size / 2 + Vector2(0, 80)), + _ForegroundEmber(position: _cardCenter), ]); } void _spawnEmber() { - _spawned++; + final angle = _random.nextDouble() * 2 * pi; + final distance = 80 + _random.nextDouble() * 120; + final inFront = _random.nextBool(); final ember = Ember( - position: Vector2( - 60.0 + (_spawned * 70) % (size.x - 120), - size.y - 60, - ), + position: _cardCenter + Vector2(cos(angle), sin(angle)) * distance, size: Vector2.all(40), + priority: inFront ? 2 : 0, ); ember.add( MoveEffect.by( @@ -148,14 +153,18 @@ class _LabelCard extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.widgets, size: 32), + const Icon(Icons.widgets, size: 32, color: Colors.black87), const SizedBox(height: 8), ValueListenableBuilder( valueListenable: label, builder: (context, value, child) { return Text( value.isEmpty ? 'Flame' : value, - style: Theme.of(context).textTheme.titleMedium, + style: const TextStyle( + color: Colors.black87, + fontSize: 18, + fontWeight: FontWeight.w600, + ), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, ); diff --git a/packages/flame/lib/src/game/game_render_box.dart b/packages/flame/lib/src/game/game_render_box.dart index 150498e6b4d..5da94c3aba6 100644 --- a/packages/flame/lib/src/game/game_render_box.dart +++ b/packages/flame/lib/src/game/game_render_box.dart @@ -327,6 +327,9 @@ class GameRenderBox extends RenderBox } final transform = Matrix4.fromFloat64List(canvas.getTransform()); + if (transform.determinant() == 0) { + return; + } final clip = canvas.getDestinationClipBounds(); final localTransform = inverseBaseTransform.multiplied(transform); (child.parentData! as WidgetComponentParentData).paintTransform = diff --git a/packages/flame/test/components/widget_component_test.dart b/packages/flame/test/components/widget_component_test.dart index 4754ecf36f3..70962671270 100644 --- a/packages/flame/test/components/widget_component_test.dart +++ b/packages/flame/test/components/widget_component_test.dart @@ -325,6 +325,26 @@ void main() { expect(transformLayer.transform!.getTranslation().y, 200); }); + testWidgets('skips a widget that is scaled to nothing', (tester) async { + final game = FlameGame(); + final component = WidgetComponent( + widget: const RepaintBoundary(child: ColoredBox(color: _red)), + size: Vector2(120, 40), + position: Vector2(300, 200), + scale: Vector2.zero(), + ); + game.add(component); + await _pumpGame(tester, game); + + expect(tester.takeException(), isNull); + expect(game.renderBox.paintedWidgetComponents, isEmpty); + + component.scale = Vector2.all(1); + await tester.pump(); + await tester.pump(); + expect(game.renderBox.paintedWidgetComponents, [component]); + }); + testWidgets('is clipped and hit tested by the camera viewport', ( tester, ) async { From c7daee41a0d22d67f63dffacb7cf1781e443c4db Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 23 Sep 2026 17:30:14 +0200 Subject: [PATCH 3/7] fix: Address review findings for WidgetComponent --- .github/.cspell/dart_dictionary.txt | 1 + doc/flame/components/utility_components.md | 12 +- .../components/widget_component_example.dart | 4 +- .../lib/src/components/widget_component.dart | 83 ++++++-- .../flame/lib/src/game/game_render_box.dart | 177 ++++++++++++++++-- .../lib/src/game/game_widget/game_widget.dart | 3 +- packages/flame/lib/src/game/proxy_canvas.dart | 159 ++++++++++------ .../components/widget_component_test.dart | 163 ++++++++++++++-- .../flame/test/game/proxy_canvas_test.dart | 59 +++++- 9 files changed, 549 insertions(+), 112 deletions(-) diff --git a/.github/.cspell/dart_dictionary.txt b/.github/.cspell/dart_dictionary.txt index a35c2273828..f9b1a914def 100644 --- a/.github/.cspell/dart_dictionary.txt +++ b/.github/.cspell/dart_dictionary.txt @@ -6,3 +6,4 @@ endtemplate # Use @endtemplate to close a @template block in dartdoc pubspec # dependency and configuration file of every Dart project superellipse # dart:ui RSuperellipse, used in Canvas.clipRSuperellipse unawaited # dart:async helper to mark a Future as intentionally not awaited +superellipse diff --git a/doc/flame/components/utility_components.md b/doc/flame/components/utility_components.md index a9352986537..1d1239831f7 100644 --- a/doc/flame/components/utility_components.md +++ b/doc/flame/components/utility_components.md @@ -196,8 +196,11 @@ world.add( ``` When `size` is given, the widget is laid out with tight constraints of that size, in the same way -as a `SizedBox` would. When it is omitted, the widget is laid out with loose constraints bounded by -the size of the game canvas, and the component adopts whatever size the widget ends up with. +as a `SizedBox` would. When it is omitted, the component adopts whatever size the widget ends up +with. In that case the widget is laid out with the `constraints` passed to the component, or, when +those are omitted too, with loose constraints bounded by the size of the game canvas expressed in +the local units of the component, so the scale of the component and of its ancestors is taken into +account but the camera zoom is not. The hosted widget can be replaced at any time by assigning `widget`, which rebuilds the hosted subtree in the same way as returning a new widget from a `build` method would. State inside the @@ -226,6 +229,11 @@ There are some limitations to be aware of: - A widget that needs its own compositing layer (for example one that contains a `RepaintBoundary`, a scrollable list, or a platform view) splits the game's picture in two around it. Any `saveLayer` that an ancestor component has active at that point is closed and reopened around the widget. +- A widget can only be painted once per frame. When the same `WidgetComponent` is rendered several + times in one frame, for example because its world is viewed by several cameras, only the first + render paints the widget. +- While the component is not rendered, for example because an ancestor is hidden, the widget stays + in the widget tree but is excluded from focus and semantics, and `isPainted` is false. Check the example app [widget_component](https://github.com/flame-engine/flame/blob/main/examples/lib/stories/components/widget_component_example.dart) diff --git a/examples/lib/stories/components/widget_component_example.dart b/examples/lib/stories/components/widget_component_example.dart index 8b117106b32..3684a26d323 100644 --- a/examples/lib/stories/components/widget_component_example.dart +++ b/examples/lib/stories/components/widget_component_example.dart @@ -52,8 +52,8 @@ class WidgetComponentExample extends FlameGame { border: OutlineInputBorder(), filled: true, fillColor: Colors.white, - labelText: 'Card label', - labelStyle: TextStyle(color: Colors.black54), + hintText: 'Card label', + hintStyle: TextStyle(color: Colors.black54), ), ), ), diff --git a/packages/flame/lib/src/components/widget_component.dart b/packages/flame/lib/src/components/widget_component.dart index de9e23e4bda..43bb9c917eb 100644 --- a/packages/flame/lib/src/components/widget_component.dart +++ b/packages/flame/lib/src/components/widget_component.dart @@ -2,6 +2,7 @@ import 'package:flame/src/components/position_component.dart'; import 'package:flame/src/extensions/vector2.dart'; import 'package:flame/src/game/game.dart'; import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; /// A [PositionComponent] that hosts a Flutter [widget] inside the Flame /// component tree. @@ -30,27 +31,35 @@ import 'package:flutter/widgets.dart'; /// ``` /// /// When [size] is given, the widget is laid out with tight constraints of that -/// size. When it is omitted, the widget is laid out with loose constraints -/// bounded by the size of the game canvas, and the component adopts whatever -/// size the widget ends up with. +/// size. When it is omitted, the component adopts whatever size the widget +/// ends up with after being laid out with [constraints], which default to +/// loose constraints bounded by the size of the game canvas expressed in the +/// local units of the component, so the scale of the component and of its +/// ancestors is taken into account but the camera zoom is not. /// /// Limitations: /// - The widget is only rendered by the `GameWidget` render pass. It is not /// included when the component tree is rendered to a `Picture` or `Image` /// elsewhere, for example by the `Snapshot` mixin, `PostProcess`es or the /// devtools component snapshot. -/// - Flame paints and clips are not applied to the widget. Paint based effects -/// such as `OpacityEffect` or `ColorEffect` on this component or its -/// ancestors do not affect the widget, only transforms do. +/// - Flame paints are not applied to the widget. Paint based effects such as +/// `OpacityEffect` or `ColorEffect` on this component or its ancestors do +/// not affect the widget, only transforms and rectangular clips (such as the +/// camera viewport) do. /// - A widget that needs its own compositing layer (for example one that /// contains a `RepaintBoundary`) splits the game's picture, which means that /// any `saveLayer` an ancestor component has active at that point is closed /// and reopened around it. +/// - A widget can only be painted once per frame. When the same +/// [WidgetComponent] is rendered several times in one frame, for example +/// because its world is viewed by several cameras, only the first render +/// paints the widget. class WidgetComponent extends PositionComponent { // ignore: use_super_parameters WidgetComponent({ required Widget widget, Vector2? size, + this.constraints, super.position, super.scale, super.angle, @@ -68,7 +77,17 @@ class WidgetComponent extends PositionComponent { Widget _widget; final bool _adoptsWidgetSize; bool _isAdoptingSize = false; + bool _isPainted = false; Game? _game; + final _HostNotifier _hostNotifier = _HostNotifier(); + + /// The constraints that the widget is laid out with when no [size] was + /// given. When this is null too, the widget is laid out with loose + /// constraints bounded by the size of the game canvas in local units. + /// + /// Ignored when a [size] was given, since the widget then always gets tight + /// constraints of that size. + final BoxConstraints? constraints; /// The Flutter widget hosted by this component. /// @@ -80,23 +99,49 @@ class WidgetComponent extends PositionComponent { return; } _widget = value; - _game?.refreshWidget(isInternalRefresh: false); + _hostNotifier.notify(); } /// Whether the component takes its [size] from the laid out widget, which is /// the case when no size was passed to the constructor. bool get adoptsWidgetSize => _adoptsWidgetSize; + /// Whether the widget was painted during the last game render pass. + /// + /// This is false while the component is not rendered, for example because + /// an ancestor is hidden, in which case the hosted widget is also excluded + /// from focus and semantics. + bool get isPainted => _isPainted; + + /// Notifies when [widget] or [isPainted] change, so that the host of the + /// widget in the Flutter tree can rebuild. + @internal + Listenable get hostListenable => _hostNotifier; + /// The [BoxConstraints] that the widget is laid out with. + @internal BoxConstraints constraintsFor(Vector2 gameSize) { - if (_adoptsWidgetSize) { - return BoxConstraints.loose(Size(gameSize.x, gameSize.y)); + if (!_adoptsWidgetSize) { + return BoxConstraints.tightFor(width: size.x, height: size.y); + } + final constraints = this.constraints; + if (constraints != null) { + return constraints; } - return BoxConstraints.tightFor(width: size.x, height: size.y); + final absoluteScale = this.absoluteScale; + final scaleX = absoluteScale.x.abs(); + final scaleY = absoluteScale.y.abs(); + return BoxConstraints.loose( + Size( + scaleX == 0 ? 0 : gameSize.x / scaleX, + scaleY == 0 ? 0 : gameSize.y / scaleY, + ), + ); } /// Called by the `GameRenderBox` after the widget has been laid out, so that /// the component can adopt the widget's size without triggering a relayout. + @internal void adoptWidgetSize(Size widgetSize) { if (!_adoptsWidgetSize) { return; @@ -109,13 +154,24 @@ class WidgetComponent extends PositionComponent { _isAdoptingSize = false; } + /// Called by the `GameRenderBox` after each render pass with whether the + /// widget was painted in it. + @internal + void markPainted({required bool isPainted}) { + if (_isPainted == isPainted) { + return; + } + _isPainted = isPainted; + _hostNotifier.notify(); + } + void _onSizeChanged() { if (_isAdoptingSize) { return; } final game = _game; if (game != null && game.isAttached) { - game.renderBox.markNeedsLayout(); + game.renderBox.markNeedsWidgetLayout(); } } @@ -131,6 +187,7 @@ class WidgetComponent extends PositionComponent { void onRemove() { _game?.unregisterWidgetComponent(this); _game = null; + _isPainted = false; super.onRemove(); } @@ -143,3 +200,7 @@ class WidgetComponent extends PositionComponent { game.renderBox.paintWidgetComponent(this, canvas); } } + +class _HostNotifier extends ChangeNotifier { + void notify() => notifyListeners(); +} diff --git a/packages/flame/lib/src/game/game_render_box.dart b/packages/flame/lib/src/game/game_render_box.dart index 5da94c3aba6..a72e19eb26c 100644 --- a/packages/flame/lib/src/game/game_render_box.dart +++ b/packages/flame/lib/src/game/game_render_box.dart @@ -6,6 +6,7 @@ import 'package:flame/src/components/widget_component.dart'; import 'package:flame/src/game/game_loop.dart'; import 'package:flame/src/game/proxy_canvas.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart' hide WidgetBuilder; import 'package:meta/meta.dart'; @@ -15,8 +16,7 @@ import 'package:meta/meta.dart'; /// render the game. /// /// Its [children] are the widgets hosted by the [WidgetComponent]s that are -/// currently mounted in the game, each wrapped in a -/// [WidgetComponentParentDataWidget]. +/// currently mounted in the game, each wrapped in a [WidgetComponentHost]. class RenderGameWidget extends MultiChildRenderObjectWidget { const RenderGameWidget({ required this.game, @@ -56,8 +56,12 @@ class WidgetComponentParentData extends ContainerBoxParentData { WidgetComponent? component; /// The transform from the child's coordinates to the local coordinates of - /// the [GameRenderBox], as of the last time the child was painted. + /// the [GameRenderBox], as of the last time the child was painted, or null + /// when the child was not painted during the last paint. Matrix4? paintTransform; + + /// Whether the child has been painted during the current paint. + bool paintedThisFrame = false; } /// Wraps the widget of a [WidgetComponent] so that the [GameRenderBox] knows @@ -86,6 +90,79 @@ class WidgetComponentParentDataWidget Type get debugTypicalAncestorWidgetClass => RenderGameWidget; } +/// Hosts the widget of a [WidgetComponent] in the Flutter tree. +/// +/// Rebuilds the hosted subtree when the component's widget changes, without +/// rebuilding the whole `GameWidget`, and excludes the subtree from focus and +/// semantics while the component is not being painted. +@internal +class WidgetComponentHost extends StatefulWidget { + const WidgetComponentHost({required this.component, super.key}); + + final WidgetComponent component; + + @override + State createState() => _WidgetComponentHostState(); +} + +class _WidgetComponentHostState extends State { + bool _rebuildScheduled = false; + + @override + void initState() { + super.initState(); + widget.component.hostListenable.addListener(_onComponentChanged); + } + + @override + void didUpdateWidget(WidgetComponentHost oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.component != widget.component) { + oldWidget.component.hostListenable.removeListener(_onComponentChanged); + widget.component.hostListenable.addListener(_onComponentChanged); + } + } + + @override + void dispose() { + widget.component.hostListenable.removeListener(_onComponentChanged); + super.dispose(); + } + + void _onComponentChanged() { + final phase = SchedulerBinding.instance.schedulerPhase; + if (phase == SchedulerPhase.persistentCallbacks) { + if (_rebuildScheduled) { + return; + } + _rebuildScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _rebuildScheduled = false; + if (mounted) { + setState(() {}); + } + }); + } else { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final component = widget.component; + return WidgetComponentParentDataWidget( + component: component, + child: ExcludeFocus( + excluding: !component.isPainted, + child: ExcludeSemantics( + excluding: !component.isPainted, + child: component.widget, + ), + ), + ); + } +} + class GameRenderBox extends RenderBox with ContainerRenderObjectMixin, @@ -142,6 +219,9 @@ class GameRenderBox extends RenderBox /// The widgets that were painted during the last paint, in paint order. final List<_PaintedWidget> _paintedWidgets = []; + bool _isPerformingLayout = false; + bool _paintedStateUpdateScheduled = false; + PaintingContext? _paintingContext; ProxyCanvas? _canvas; @@ -171,23 +251,41 @@ class GameRenderBox extends RenderBox } } + /// Requests a relayout of the hosted widgets, for example because the size + /// of a [WidgetComponent] changed. Ignored while the layout is running, + /// since the sizes set during layout are already the laid out ones. + @internal + void markNeedsWidgetLayout() { + if (!_isPerformingLayout) { + markNeedsLayout(); + } + } + @override void performLayout() { - _childByComponent.clear(); - _paintedWidgets.clear(); - final gameSize = size.toVector2(); - var child = firstChild; - while (child != null) { - final parentData = child.parentData! as WidgetComponentParentData; - final component = parentData.component; - if (component == null) { - child.layout(BoxConstraints.tight(Size.zero)); - } else { - _childByComponent[component] = child; - child.layout(component.constraintsFor(gameSize), parentUsesSize: true); - component.adoptWidgetSize(child.size); + _isPerformingLayout = true; + try { + _childByComponent.clear(); + _paintedWidgets.clear(); + final gameSize = size.toVector2(); + var child = firstChild; + while (child != null) { + final parentData = child.parentData! as WidgetComponentParentData; + final component = parentData.component; + if (component == null) { + child.layout(BoxConstraints.tight(Size.zero)); + } else { + _childByComponent[component] = child; + child.layout( + component.constraintsFor(gameSize), + parentUsesSize: true, + ); + component.adoptWidgetSize(child.size); + } + child = parentData.nextSibling; } - child = parentData.nextSibling; + } finally { + _isPerformingLayout = false; } } @@ -287,6 +385,9 @@ class GameRenderBox extends RenderBox return; } + for (final child in _childByComponent.values) { + (child.parentData! as WidgetComponentParentData).paintedThisFrame = false; + } final canvas = ProxyCanvas(context.canvas); _paintingContext = context; _canvas = canvas; @@ -302,6 +403,36 @@ class GameRenderBox extends RenderBox _paintingContext = null; _canvas = null; } + _updatePaintedState(); + } + + /// Clears the paint transform of the children that were not painted and + /// tells the components whose painted state changed, after the frame, so + /// that their hosts can rebuild. + void _updatePaintedState() { + var needsUpdate = false; + for (final entry in _childByComponent.entries) { + final parentData = entry.value.parentData! as WidgetComponentParentData; + if (!parentData.paintedThisFrame) { + parentData.paintTransform = null; + } + if (parentData.paintedThisFrame != entry.key.isPainted) { + needsUpdate = true; + } + } + if (!needsUpdate || _paintedStateUpdateScheduled) { + return; + } + _paintedStateUpdateScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _paintedStateUpdateScheduled = false; + for (final entry in _childByComponent.entries) { + final parentData = entry.value.parentData; + if (parentData is WidgetComponentParentData) { + entry.key.markPainted(isPainted: parentData.paintedThisFrame); + } + } + }); } /// Paints the widget hosted by [component] at the current transform of @@ -309,7 +440,8 @@ class GameRenderBox extends RenderBox /// /// Does nothing when [canvas] is not the canvas of the current paint, which /// is the case when the component tree is being rendered somewhere else, - /// for example into a snapshot. + /// for example into a snapshot, or when the widget has already been painted + /// during this paint. @internal void paintWidgetComponent(WidgetComponent component, ui.Canvas canvas) { final context = _paintingContext; @@ -325,6 +457,10 @@ class GameRenderBox extends RenderBox if (child == null) { return; } + final parentData = child.parentData! as WidgetComponentParentData; + if (parentData.paintedThisFrame) { + return; + } final transform = Matrix4.fromFloat64List(canvas.getTransform()); if (transform.determinant() == 0) { @@ -332,8 +468,9 @@ class GameRenderBox extends RenderBox } final clip = canvas.getDestinationClipBounds(); final localTransform = inverseBaseTransform.multiplied(transform); - (child.parentData! as WidgetComponentParentData).paintTransform = - localTransform; + parentData + ..paintTransform = localTransform + ..paintedThisFrame = true; _paintedWidgets.add( _PaintedWidget( component: component, diff --git a/packages/flame/lib/src/game/game_widget/game_widget.dart b/packages/flame/lib/src/game/game_widget/game_widget.dart index 420a95a4b47..90432329e96 100644 --- a/packages/flame/lib/src/game/game_widget/game_widget.dart +++ b/packages/flame/lib/src/game/game_widget/game_widget.dart @@ -386,10 +386,9 @@ class GameWidgetState extends State> { behavior: widget.behavior, children: [ for (final component in currentGame.widgetComponents) - WidgetComponentParentDataWidget( + WidgetComponentHost( key: ObjectKey(component), component: component, - child: component.widget, ), ], ); diff --git a/packages/flame/lib/src/game/proxy_canvas.dart b/packages/flame/lib/src/game/proxy_canvas.dart index 353b03c3d95..6552ec8d03d 100644 --- a/packages/flame/lib/src/game/proxy_canvas.dart +++ b/packages/flame/lib/src/game/proxy_canvas.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'dart:ui'; import 'package:meta/meta.dart'; +import 'package:vector_math/vector_math_64.dart'; /// A [Canvas] that forwards every call to an underlying canvas, and that can /// swap that underlying canvas mid-frame while preserving the current save @@ -15,15 +16,32 @@ import 'package:meta/meta.dart'; /// canvas is brought to the same state (save count, transforms and clips) that /// the old one was in, so that pending `restore` calls in the component tree /// keep balancing. +/// +/// Transforms are accumulated into a single matrix per save level, so that +/// recording them does not allocate. Clips are recorded as objects together +/// with the transform that was accumulated before them, so that they can be +/// replayed in the right coordinate space. +/// +/// Any transform or clip that the underlying canvas already had when the proxy +/// was created is replayed too. In practice there is none: whenever a widget +/// needs compositing, Flutter also composites the transforms and clips of the +/// ancestors of the game render box through layers instead of drawing them on +/// the shared canvas. @internal class ProxyCanvas implements Canvas { - ProxyCanvas(this._canvas) : _baseSaveCount = _canvas.getSaveCount() { + ProxyCanvas(this._canvas) + : _baseSaveCount = _canvas.getSaveCount(), + _initialTransform = _canvas.getTransform(), + _initialClip = _canvas.getDestinationClipBounds() { _levels.add(_SaveLevel.plain()); } Canvas _canvas; int _baseSaveCount; + final Float64List _initialTransform; + final Rect _initialClip; final List<_SaveLevel> _levels = []; + final Matrix4 _scratch = Matrix4.identity(); /// The canvas that currently receives all the calls. Canvas get inner => _canvas; @@ -32,6 +50,12 @@ class ProxyCanvas implements Canvas { /// stack, transforms and clips onto it first. void swap(Canvas canvas) { _baseSaveCount = canvas.getSaveCount(); + if (_initialClip.isFinite && _initialClip != Rect.largest) { + canvas.clipRect(_initialClip); + } + if (!_isIdentity(_initialTransform)) { + canvas.transform(_initialTransform); + } for (var i = 0; i < _levels.length; i++) { final level = _levels[i]; if (i > 0) { @@ -41,9 +65,7 @@ class ProxyCanvas implements Canvas { canvas.save(); } } - for (final op in level.ops) { - op.apply(canvas); - } + level.replay(canvas); } _canvas = canvas; } @@ -83,31 +105,35 @@ class ProxyCanvas implements Canvas { @override void translate(double dx, double dy) { _canvas.translate(dx, dy); - _current.ops.add(_TranslateOp(dx, dy)); + _current.pendingTransform.translateByDouble(dx, dy, 0, 1); } @override void scale(double sx, [double? sy]) { _canvas.scale(sx, sy); - _current.ops.add(_ScaleOp(sx, sy)); + _current.pendingTransform.scaleByDouble(sx, sy ?? sx, 1, 1); } @override void rotate(double radians) { _canvas.rotate(radians); - _current.ops.add(_RotateOp(radians)); + _current.pendingTransform.rotateZ(radians); } @override void skew(double sx, double sy) { _canvas.skew(sx, sy); - _current.ops.add(_SkewOp(sx, sy)); + _scratch.setIdentity(); + _scratch.storage[4] = sx; + _scratch.storage[1] = sy; + _current.pendingTransform.multiply(_scratch); } @override void transform(Float64List matrix4) { _canvas.transform(matrix4); - _current.ops.add(_TransformOp(Float64List.fromList(matrix4))); + _scratch.storage.setAll(0, matrix4); + _current.pendingTransform.multiply(_scratch); } @override @@ -120,15 +146,15 @@ class ProxyCanvas implements Canvas { bool doAntiAlias = true, }) { _canvas.clipRect(rect, clipOp: clipOp, doAntiAlias: doAntiAlias); - _current.ops.add( - _ClipRectOp(rect, clipOp: clipOp, doAntiAlias: doAntiAlias), + _current.recordClip( + _ClipRectOperation(rect, clipOp: clipOp, doAntiAlias: doAntiAlias), ); } @override void clipRRect(RRect rrect, {bool doAntiAlias = true}) { _canvas.clipRRect(rrect, doAntiAlias: doAntiAlias); - _current.ops.add(_ClipRRectOp(rrect, doAntiAlias: doAntiAlias)); + _current.recordClip(_ClipRRectOperation(rrect, doAntiAlias: doAntiAlias)); } @override @@ -137,15 +163,15 @@ class ProxyCanvas implements Canvas { bool doAntiAlias = true, }) { _canvas.clipRSuperellipse(shape, doAntiAlias: doAntiAlias); - _current.ops.add( - _ClipRSuperellipseOp(shape, doAntiAlias: doAntiAlias), + _current.recordClip( + _ClipRSuperellipseOperation(shape, doAntiAlias: doAntiAlias), ); } @override void clipPath(Path path, {bool doAntiAlias = true}) { _canvas.clipPath(path, doAntiAlias: doAntiAlias); - _current.ops.add(_ClipPathOp(path, doAntiAlias: doAntiAlias)); + _current.recordClip(_ClipPathOperation(path, doAntiAlias: doAntiAlias)); } @override @@ -306,8 +332,20 @@ class ProxyCanvas implements Canvas { ) { _canvas.drawShadow(path, color, elevation, transparentOccluder); } + + static bool _isIdentity(Float64List matrix) { + for (var i = 0; i < 16; i++) { + final expected = i % 5 == 0 ? 1.0 : 0.0; + if (matrix[i] != expected) { + return false; + } + } + return true; + } } +/// One level of the save stack: the transforms and clips applied since the +/// `save` or `saveLayer` call that opened it. class _SaveLevel { _SaveLevel.plain() : isLayer = false, layerBounds = null, layerPaint = null; @@ -316,58 +354,52 @@ class _SaveLevel { final bool isLayer; final Rect? layerBounds; final Paint? layerPaint; - final List<_CanvasOp> ops = []; -} - -abstract class _CanvasOp { - void apply(Canvas canvas); -} -class _TranslateOp implements _CanvasOp { - _TranslateOp(this.dx, this.dy); - final double dx; - final double dy; + /// The transforms accumulated since the last recorded clip, or since the + /// start of the level when there is none. + final Matrix4 pendingTransform = Matrix4.identity(); - @override - void apply(Canvas canvas) => canvas.translate(dx, dy); -} + List<_ClipOperation>? _clips; -class _ScaleOp implements _CanvasOp { - _ScaleOp(this.sx, this.sy); - final double sx; - final double? sy; - - @override - void apply(Canvas canvas) => canvas.scale(sx, sy); -} + void recordClip(_ClipOperation clip) { + clip.transformBefore.setFrom(pendingTransform); + pendingTransform.setIdentity(); + (_clips ??= []).add(clip); + } -class _RotateOp implements _CanvasOp { - _RotateOp(this.radians); - final double radians; + void replay(Canvas canvas) { + final clips = _clips; + if (clips != null) { + for (final clip in clips) { + _applyTransform(canvas, clip.transformBefore); + clip.apply(canvas); + } + } + _applyTransform(canvas, pendingTransform); + } - @override - void apply(Canvas canvas) => canvas.rotate(radians); + static void _applyTransform(Canvas canvas, Matrix4 transform) { + if (!transform.isIdentity()) { + canvas.transform(transform.storage); + } + } } -class _SkewOp implements _CanvasOp { - _SkewOp(this.sx, this.sy); - final double sx; - final double sy; +abstract class _ClipOperation { + /// The transform that was accumulated between the previous clip (or the + /// start of the save level) and this clip. + final Matrix4 transformBefore = Matrix4.identity(); - @override - void apply(Canvas canvas) => canvas.skew(sx, sy); + void apply(Canvas canvas); } -class _TransformOp implements _CanvasOp { - _TransformOp(this.matrix4); - final Float64List matrix4; - - @override - void apply(Canvas canvas) => canvas.transform(matrix4); -} +class _ClipRectOperation extends _ClipOperation { + _ClipRectOperation( + this.rect, { + required this.clipOp, + required this.doAntiAlias, + }); -class _ClipRectOp implements _CanvasOp { - _ClipRectOp(this.rect, {required this.clipOp, required this.doAntiAlias}); final Rect rect; final ClipOp clipOp; final bool doAntiAlias; @@ -378,8 +410,9 @@ class _ClipRectOp implements _CanvasOp { } } -class _ClipRRectOp implements _CanvasOp { - _ClipRRectOp(this.rrect, {required this.doAntiAlias}); +class _ClipRRectOperation extends _ClipOperation { + _ClipRRectOperation(this.rrect, {required this.doAntiAlias}); + final RRect rrect; final bool doAntiAlias; @@ -389,8 +422,9 @@ class _ClipRRectOp implements _CanvasOp { } } -class _ClipRSuperellipseOp implements _CanvasOp { - _ClipRSuperellipseOp(this.shape, {required this.doAntiAlias}); +class _ClipRSuperellipseOperation extends _ClipOperation { + _ClipRSuperellipseOperation(this.shape, {required this.doAntiAlias}); + final RSuperellipse shape; final bool doAntiAlias; @@ -400,8 +434,9 @@ class _ClipRSuperellipseOp implements _CanvasOp { } } -class _ClipPathOp implements _CanvasOp { - _ClipPathOp(this.path, {required this.doAntiAlias}); +class _ClipPathOperation extends _ClipOperation { + _ClipPathOperation(this.path, {required this.doAntiAlias}); + final Path path; final bool doAntiAlias; diff --git a/packages/flame/test/components/widget_component_test.dart b/packages/flame/test/components/widget_component_test.dart index 70962671270..2507e16d7b1 100644 --- a/packages/flame/test/components/widget_component_test.dart +++ b/packages/flame/test/components/widget_component_test.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'dart:ui'; +import 'package:flame/camera.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/game.dart'; @@ -58,12 +59,45 @@ Future _pumpGame(WidgetTester tester, FlameGame game) async { await tester.pump(); } -Widget _button(String label, VoidCallback onPressed) { - return Material( - child: ElevatedButton(onPressed: onPressed, child: Text(label)), - ); +class _Button extends StatelessWidget { + const _Button(this.label, this.onPressed); + + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Material( + child: ElevatedButton(onPressed: onPressed, child: Text(label)), + ); + } +} + +class _ResizeCountingGame extends FlameGame { + int resizeCount = 0; + + @override + void onGameResize(Vector2 size) { + resizeCount++; + super.onGameResize(size); + } +} + +class _SplitScreenGame extends FlameGame { + @override + Future onLoad() async { + camera.viewport = FixedSizeViewport(400, 600); + camera.viewfinder.anchor = Anchor.topLeft; + final secondCamera = CameraComponent( + world: world, + viewport: FixedSizeViewport(400, 600)..position = Vector2(400, 0), + )..viewfinder.anchor = Anchor.topLeft; + add(secondCamera); + } } +class _HidableComponent extends PositionComponent with HasVisibility {} + void main() { group('WidgetComponent', () { testWidgets('renders its widget inside the game', (tester) async { @@ -157,7 +191,7 @@ void main() { final game = FlameGame(); game.add( WidgetComponent( - widget: _button('Press', () => pressed++), + widget: _Button('Press', () => pressed++), size: Vector2(120, 40), position: Vector2(300, 200), ), @@ -177,7 +211,7 @@ void main() { var pressed = 0; final game = FlameGame(); final component = WidgetComponent( - widget: _button('Press', () => pressed++), + widget: _Button('Press', () => pressed++), size: Vector2(120, 40), position: Vector2(300, 200), anchor: Anchor.center, @@ -207,7 +241,7 @@ void main() { game.add(tappable); game.add( WidgetComponent( - widget: _button('Press', () => pressed++), + widget: _Button('Press', () => pressed++), size: Vector2(120, 40), position: Vector2(300, 200), priority: 1, @@ -233,13 +267,13 @@ void main() { var topPressed = 0; final game = FlameGame(); final bottom = WidgetComponent( - widget: _button('Bottom', () => bottomPressed++), + widget: _Button('Bottom', () => bottomPressed++), size: Vector2(120, 40), position: Vector2(300, 200), priority: 2, ); final top = WidgetComponent( - widget: _button('Top', () => topPressed++), + widget: _Button('Top', () => topPressed++), size: Vector2(120, 40), position: Vector2(300, 200), priority: 1, @@ -272,7 +306,7 @@ void main() { game.add( WidgetComponent( widget: RepaintBoundary( - child: _button('Press', () => pressed++), + child: _Button('Press', () => pressed++), ), size: Vector2(120, 40), position: Vector2(300, 200), @@ -325,6 +359,113 @@ void main() { expect(transformLayer.transform!.getTranslation().y, 200); }); + testWidgets('lays an adopting widget out in local units', (tester) async { + final game = FlameGame(); + final parent = PositionComponent(scale: Vector2(2, 4)); + final component = WidgetComponent(widget: const SizedBox.expand()); + parent.add(component); + game.add(parent); + await _pumpGame(tester, game); + + expect(component.size, Vector2(400, 150)); + }); + + testWidgets('lays an adopting widget out with explicit constraints', ( + tester, + ) async { + final game = FlameGame(); + final component = WidgetComponent( + widget: const SizedBox.expand(), + constraints: const BoxConstraints.tightFor(width: 50, height: 20), + ); + game.add(component); + await _pumpGame(tester, game); + + expect(component.size, Vector2(50, 20)); + }); + + testWidgets('replaces the widget without rebuilding the game widget', ( + tester, + ) async { + final game = _ResizeCountingGame(); + final component = WidgetComponent( + widget: const Text('one'), + size: Vector2(100, 40), + ); + game.add(component); + await _pumpGame(tester, game); + final resizeCount = game.resizeCount; + + component.widget = const Text('two'); + await tester.pump(); + await tester.pump(); + expect(find.text('one'), findsNothing); + expect(find.text('two'), findsOneWidget); + expect(game.resizeCount, resizeCount); + }); + + testWidgets('paints a widget once when its world has several cameras', ( + tester, + ) async { + var pressed = 0; + final game = _SplitScreenGame(); + final component = WidgetComponent( + widget: RepaintBoundary(child: _Button('Press', () => pressed++)), + size: Vector2(120, 40), + position: Vector2(100, 200), + ); + game.world.add(component); + await _pumpGame(tester, game); + + expect(tester.takeException(), isNull); + expect(game.renderBox.paintedWidgetComponents, [component]); + + await tester.tapAt(const Offset(160, 220)); + await tester.pump(); + expect(pressed, 1); + + await tester.tapAt(const Offset(560, 220)); + await tester.pump(); + expect(pressed, 1); + }); + + testWidgets('excludes a widget that is not rendered from focus', ( + tester, + ) async { + final game = FlameGame(); + final parent = _HidableComponent(); + final component = WidgetComponent( + widget: const Material(child: TextField()), + size: Vector2(200, 60), + position: Vector2(100, 100), + ); + parent.add(component); + game.add(parent); + await _pumpGame(tester, game); + await tester.pump(); + expect(component.isPainted, isTrue); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + final editable = tester.widget(find.byType(EditableText)); + expect(editable.focusNode.hasFocus, isTrue); + + parent.isVisible = false; + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect(component.isPainted, isFalse); + expect(editable.focusNode.hasFocus, isFalse); + expect(game.renderBox.paintedWidgetComponents, isEmpty); + + parent.isVisible = true; + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect(component.isPainted, isTrue); + expect(game.renderBox.paintedWidgetComponents, [component]); + }); + testWidgets('skips a widget that is scaled to nothing', (tester) async { final game = FlameGame(); final component = WidgetComponent( @@ -353,7 +494,7 @@ void main() { game.world.add( WidgetComponent( widget: RepaintBoundary( - child: _button('Press', () => pressed++), + child: _Button('Press', () => pressed++), ), size: Vector2(120, 40), position: Vector2(380, 100), diff --git a/packages/flame/test/game/proxy_canvas_test.dart b/packages/flame/test/game/proxy_canvas_test.dart index dd5ee7718d6..65ece04f1ca 100644 --- a/packages/flame/test/game/proxy_canvas_test.dart +++ b/packages/flame/test/game/proxy_canvas_test.dart @@ -47,7 +47,7 @@ void main() { expect(canvas.inner, same(second)); expect(second.getSaveCount(), expectedSaveCount); - expect(second.getTransform(), expectedTransform); + _expectTransform(second.getTransform(), expectedTransform); expect(second.getDestinationClipBounds(), expectedClip); canvas.restore(); @@ -83,13 +83,62 @@ void main() { canvas.swap(second); expect(second.getSaveCount(), 2); - expect(second.getTransform(), expectedTransform); + _expectTransform(second.getTransform(), expectedTransform); expect(second.getTransform()[12], 1); firstRecorder.endRecording().dispose(); secondRecorder.endRecording().dispose(); }); + test('replays a clip in the coordinate space it was applied in', () { + final firstRecorder = PictureRecorder(); + final first = Canvas(firstRecorder); + final canvas = ProxyCanvas(first); + + canvas.translate(10, 0); + canvas.clipRect(const Rect.fromLTWH(0, 0, 50, 50)); + canvas.translate(5, 0); + canvas.save(); + canvas.scale(2); + canvas.clipRect(const Rect.fromLTWH(0, 0, 10, 10)); + canvas.translate(1, 1); + final expectedTransform = first.getTransform(); + final expectedClip = first.getDestinationClipBounds(); + + final secondRecorder = PictureRecorder(); + final second = Canvas(secondRecorder); + canvas.swap(second); + + _expectTransform(second.getTransform(), expectedTransform); + expect(second.getDestinationClipBounds(), expectedClip); + expect(expectedClip, const Rect.fromLTWH(15, 0, 20, 20)); + + firstRecorder.endRecording().dispose(); + secondRecorder.endRecording().dispose(); + }); + + test('replays the state the inner canvas had before the proxy', () { + final firstRecorder = PictureRecorder(); + final first = Canvas(firstRecorder); + first.translate(7, 0); + first.clipRect(const Rect.fromLTWH(0, 0, 100, 100)); + final canvas = ProxyCanvas(first); + canvas.translate(3, 0); + final expectedTransform = first.getTransform(); + final expectedClip = first.getDestinationClipBounds(); + + final secondRecorder = PictureRecorder(); + final second = Canvas(secondRecorder); + canvas.swap(second); + + _expectTransform(second.getTransform(), expectedTransform); + expect(second.getDestinationClipBounds(), expectedClip); + expect(second.getTransform()[12], 10); + + firstRecorder.endRecording().dispose(); + secondRecorder.endRecording().dispose(); + }); + test('replays a saveLayer as a new layer on the swapped canvas', () { final firstRecorder = PictureRecorder(); final first = Canvas(firstRecorder); @@ -113,3 +162,9 @@ void main() { }); }); } + +void _expectTransform(Float64List actual, Float64List expected) { + for (var i = 0; i < 16; i++) { + expect(actual[i], closeTo(expected[i], 1e-6), reason: 'element $i'); + } +} From 580d9ab650c143de0a7cb250951975850b0e5d16 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 23 Sep 2026 17:30:56 +0200 Subject: [PATCH 4/7] chore: Remove a duplicated dictionary entry --- .github/.cspell/dart_dictionary.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/.cspell/dart_dictionary.txt b/.github/.cspell/dart_dictionary.txt index f9b1a914def..a35c2273828 100644 --- a/.github/.cspell/dart_dictionary.txt +++ b/.github/.cspell/dart_dictionary.txt @@ -6,4 +6,3 @@ endtemplate # Use @endtemplate to close a @template block in dartdoc pubspec # dependency and configuration file of every Dart project superellipse # dart:ui RSuperellipse, used in Canvas.clipRSuperellipse unawaited # dart:async helper to mark a Future as intentionally not awaited -superellipse From bde960e83a2e416ccc68d6dec7323f896ad1187a Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 25 Sep 2026 08:52:37 +0200 Subject: [PATCH 5/7] docs: Spawn the example Embers closer to the card --- examples/lib/stories/components/widget_component_example.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/lib/stories/components/widget_component_example.dart b/examples/lib/stories/components/widget_component_example.dart index 3684a26d323..c5b13749e13 100644 --- a/examples/lib/stories/components/widget_component_example.dart +++ b/examples/lib/stories/components/widget_component_example.dart @@ -94,7 +94,7 @@ class WidgetComponentExample extends FlameGame { void _spawnEmber() { final angle = _random.nextDouble() * 2 * pi; - final distance = 80 + _random.nextDouble() * 120; + final distance = 30 + _random.nextDouble() * 70; final inFront = _random.nextBool(); final ember = Ember( position: _cardCenter + Vector2(cos(angle), sin(angle)) * distance, From 737f9f8d16920644b9d0f82bf399fc3a57a2fb93 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 09:44:28 +0200 Subject: [PATCH 6/7] refactor: Use a super parameter for the WidgetComponent size --- packages/flame/lib/src/components/widget_component.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/flame/lib/src/components/widget_component.dart b/packages/flame/lib/src/components/widget_component.dart index 43bb9c917eb..f6f988f418e 100644 --- a/packages/flame/lib/src/components/widget_component.dart +++ b/packages/flame/lib/src/components/widget_component.dart @@ -55,11 +55,10 @@ import 'package:meta/meta.dart'; /// because its world is viewed by several cameras, only the first render /// paints the widget. class WidgetComponent extends PositionComponent { - // ignore: use_super_parameters WidgetComponent({ required Widget widget, - Vector2? size, this.constraints, + super.size, super.position, super.scale, super.angle, @@ -69,9 +68,8 @@ class WidgetComponent extends PositionComponent { super.priority, super.key, }) : _widget = widget, // ignore: prefer_initializing_formals - _adoptsWidgetSize = size == null, - super(size: size) { - this.size.addListener(_onSizeChanged); + _adoptsWidgetSize = size == null { + size.addListener(_onSizeChanged); } Widget _widget; From 5d15f678548e594a48f80f040d091a998f1dcfb6 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 09:56:09 +0200 Subject: [PATCH 7/7] refactor: Move the widget component host classes into their own file --- .../flame/lib/src/game/game_render_box.dart | 114 +---------------- .../lib/src/game/game_widget/game_widget.dart | 1 + .../flame/lib/src/game/widget_components.dart | 119 ++++++++++++++++++ 3 files changed, 121 insertions(+), 113 deletions(-) create mode 100644 packages/flame/lib/src/game/widget_components.dart diff --git a/packages/flame/lib/src/game/game_render_box.dart b/packages/flame/lib/src/game/game_render_box.dart index a72e19eb26c..bc91a06bc9c 100644 --- a/packages/flame/lib/src/game/game_render_box.dart +++ b/packages/flame/lib/src/game/game_render_box.dart @@ -5,6 +5,7 @@ import 'package:flame/game.dart' hide Matrix4; import 'package:flame/src/components/widget_component.dart'; import 'package:flame/src/game/game_loop.dart'; import 'package:flame/src/game/proxy_canvas.dart'; +import 'package:flame/src/game/widget_components.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart' hide WidgetBuilder; @@ -50,119 +51,6 @@ class RenderGameWidget extends MultiChildRenderObjectWidget { } } -/// Parent data for the children of [GameRenderBox], linking each child render -/// box to the [WidgetComponent] that hosts it. -class WidgetComponentParentData extends ContainerBoxParentData { - WidgetComponent? component; - - /// The transform from the child's coordinates to the local coordinates of - /// the [GameRenderBox], as of the last time the child was painted, or null - /// when the child was not painted during the last paint. - Matrix4? paintTransform; - - /// Whether the child has been painted during the current paint. - bool paintedThisFrame = false; -} - -/// Wraps the widget of a [WidgetComponent] so that the [GameRenderBox] knows -/// which component a child render box belongs to. -@internal -class WidgetComponentParentDataWidget - extends ParentDataWidget { - const WidgetComponentParentDataWidget({ - required this.component, - required super.child, - super.key, - }); - - final WidgetComponent component; - - @override - void applyParentData(RenderObject renderObject) { - final parentData = renderObject.parentData! as WidgetComponentParentData; - if (parentData.component != component) { - parentData.component = component; - renderObject.parent?.markNeedsLayout(); - } - } - - @override - Type get debugTypicalAncestorWidgetClass => RenderGameWidget; -} - -/// Hosts the widget of a [WidgetComponent] in the Flutter tree. -/// -/// Rebuilds the hosted subtree when the component's widget changes, without -/// rebuilding the whole `GameWidget`, and excludes the subtree from focus and -/// semantics while the component is not being painted. -@internal -class WidgetComponentHost extends StatefulWidget { - const WidgetComponentHost({required this.component, super.key}); - - final WidgetComponent component; - - @override - State createState() => _WidgetComponentHostState(); -} - -class _WidgetComponentHostState extends State { - bool _rebuildScheduled = false; - - @override - void initState() { - super.initState(); - widget.component.hostListenable.addListener(_onComponentChanged); - } - - @override - void didUpdateWidget(WidgetComponentHost oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.component != widget.component) { - oldWidget.component.hostListenable.removeListener(_onComponentChanged); - widget.component.hostListenable.addListener(_onComponentChanged); - } - } - - @override - void dispose() { - widget.component.hostListenable.removeListener(_onComponentChanged); - super.dispose(); - } - - void _onComponentChanged() { - final phase = SchedulerBinding.instance.schedulerPhase; - if (phase == SchedulerPhase.persistentCallbacks) { - if (_rebuildScheduled) { - return; - } - _rebuildScheduled = true; - SchedulerBinding.instance.addPostFrameCallback((_) { - _rebuildScheduled = false; - if (mounted) { - setState(() {}); - } - }); - } else { - setState(() {}); - } - } - - @override - Widget build(BuildContext context) { - final component = widget.component; - return WidgetComponentParentDataWidget( - component: component, - child: ExcludeFocus( - excluding: !component.isPainted, - child: ExcludeSemantics( - excluding: !component.isPainted, - child: component.widget, - ), - ), - ); - } -} - class GameRenderBox extends RenderBox with ContainerRenderObjectMixin, diff --git a/packages/flame/lib/src/game/game_widget/game_widget.dart b/packages/flame/lib/src/game/game_widget/game_widget.dart index 90432329e96..05d3eb94905 100644 --- a/packages/flame/lib/src/game/game_widget/game_widget.dart +++ b/packages/flame/lib/src/game/game_widget/game_widget.dart @@ -6,6 +6,7 @@ import 'package:flame/game.dart'; import 'package:flame/input.dart'; import 'package:flame/src/game/game_render_box.dart'; import 'package:flame/src/game/game_widget/gesture_detector_builder.dart'; +import 'package:flame/src/game/widget_components.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; diff --git a/packages/flame/lib/src/game/widget_components.dart b/packages/flame/lib/src/game/widget_components.dart new file mode 100644 index 00000000000..8be4398d713 --- /dev/null +++ b/packages/flame/lib/src/game/widget_components.dart @@ -0,0 +1,119 @@ +import 'package:flame/src/components/widget_component.dart'; +import 'package:flame/src/game/game_render_box.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +/// Parent data for the children of [GameRenderBox], linking each child render +/// box to the [WidgetComponent] that hosts it. +class WidgetComponentParentData extends ContainerBoxParentData { + WidgetComponent? component; + + /// The transform from the child's coordinates to the local coordinates of + /// the [GameRenderBox], as of the last time the child was painted, or null + /// when the child was not painted during the last paint. + Matrix4? paintTransform; + + /// Whether the child has been painted during the current paint. + bool paintedThisFrame = false; +} + +/// Wraps the widget of a [WidgetComponent] so that the [GameRenderBox] knows +/// which component a child render box belongs to. +@internal +class WidgetComponentParentDataWidget + extends ParentDataWidget { + const WidgetComponentParentDataWidget({ + required this.component, + required super.child, + super.key, + }); + + final WidgetComponent component; + + @override + void applyParentData(RenderObject renderObject) { + final parentData = renderObject.parentData! as WidgetComponentParentData; + if (parentData.component != component) { + parentData.component = component; + renderObject.parent?.markNeedsLayout(); + } + } + + @override + Type get debugTypicalAncestorWidgetClass => RenderGameWidget; +} + +/// Hosts the widget of a [WidgetComponent] in the Flutter tree. +/// +/// Rebuilds the hosted subtree when the component's widget changes, without +/// rebuilding the whole `GameWidget`, and excludes the subtree from focus and +/// semantics while the component is not being painted. +@internal +class WidgetComponentHost extends StatefulWidget { + const WidgetComponentHost({required this.component, super.key}); + + final WidgetComponent component; + + @override + State createState() => _WidgetComponentHostState(); +} + +class _WidgetComponentHostState extends State { + bool _rebuildScheduled = false; + + @override + void initState() { + super.initState(); + widget.component.hostListenable.addListener(_onComponentChanged); + } + + @override + void didUpdateWidget(WidgetComponentHost oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.component != widget.component) { + oldWidget.component.hostListenable.removeListener(_onComponentChanged); + widget.component.hostListenable.addListener(_onComponentChanged); + } + } + + @override + void dispose() { + widget.component.hostListenable.removeListener(_onComponentChanged); + super.dispose(); + } + + void _onComponentChanged() { + final phase = SchedulerBinding.instance.schedulerPhase; + if (phase == SchedulerPhase.persistentCallbacks) { + if (_rebuildScheduled) { + return; + } + _rebuildScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _rebuildScheduled = false; + if (mounted) { + setState(() {}); + } + }); + } else { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final component = widget.component; + return WidgetComponentParentDataWidget( + component: component, + child: ExcludeFocus( + excluding: !component.isPainted, + child: ExcludeSemantics( + excluding: !component.isPainted, + child: component.widget, + ), + ), + ); + } +}