diff --git a/.github/.cspell/dart_dictionary.txt b/.github/.cspell/dart_dictionary.txt index 3a0eef43893..a35c2273828 100644 --- a/.github/.cspell/dart_dictionary.txt +++ b/.github/.cspell/dart_dictionary.txt @@ -4,4 +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 +superellipse # dart:ui RSuperellipse, used in Canvas.clipRSuperellipse unawaited # dart:async helper to mark a Future as intentionally not awaited diff --git a/doc/flame/components/utility_components.md b/doc/flame/components/utility_components.md index a3edb3b2567..1d1239831f7 100644 --- a/doc/flame/components/utility_components.md +++ b/doc/flame/components/utility_components.md @@ -171,6 +171,75 @@ 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 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 +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. +- 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) +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..59bf02129d6 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,13 @@ void addComponentsStories(Dashbook dashbook) { (_) => GameWidget(game: SkipTextBoxComponentExample()), codeLink: baseLink('components/skip_text_box_component_example.dart'), info: SkipTextBoxComponentExample.description, + ) + ..add( + 'Widget Component', + (_) => 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 new file mode 100644 index 00000000000..c5b13749e13 --- /dev/null +++ b/examples/lib/stories/components/widget_component_example.dart @@ -0,0 +1,178 @@ +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 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. + '''; + + final Random _random = Random(); + final ValueNotifier _label = ValueNotifier('Flame'); + + Vector2 get _cardCenter => size / 2 + Vector2(0, 80); + + @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, + style: const TextStyle(color: Colors.black87), + decoration: const InputDecoration( + border: OutlineInputBorder(), + filled: true, + fillColor: Colors.white, + hintText: 'Card label', + hintStyle: TextStyle(color: Colors.black54), + ), + ), + ), + ); + + final card = WidgetComponent( + position: _cardCenter, + 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: _cardCenter), + button, + textField, + card, + _ForegroundEmber(position: _cardCenter), + ]); + } + + void _spawnEmber() { + final angle = _random.nextDouble() * 2 * pi; + final distance = 30 + _random.nextDouble() * 70; + final inFront = _random.nextBool(); + final ember = Ember( + position: _cardCenter + Vector2(cos(angle), sin(angle)) * distance, + size: Vector2.all(40), + priority: inFront ? 2 : 0, + ); + 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, color: Colors.black87), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: label, + builder: (context, value, child) { + return Text( + value.isEmpty ? 'Flame' : value, + style: const TextStyle( + color: Colors.black87, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + 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..f6f988f418e --- /dev/null +++ b/packages/flame/lib/src/components/widget_component.dart @@ -0,0 +1,204 @@ +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. +/// +/// 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 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 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 { + WidgetComponent({ + required Widget widget, + this.constraints, + super.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 { + size.addListener(_onSizeChanged); + } + + 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. + /// + /// 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; + _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.tightFor(width: size.x, height: size.y); + } + final constraints = this.constraints; + if (constraints != null) { + return constraints; + } + 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; + } + if (size.x == widgetSize.width && size.y == widgetSize.height) { + return; + } + _isAdoptingSize = true; + size = Vector2(widgetSize.width, widgetSize.height); + _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.markNeedsWidgetLayout(); + } + } + + @override + void onMount() { + super.onMount(); + final game = findRootGame()!; + _game = game; + game.registerWidgetComponent(this); + } + + @override + void onRemove() { + _game?.unregisterWidgetComponent(this); + _game = null; + _isPainted = false; + super.onRemove(); + } + + @override + void render(Canvas canvas) { + final game = _game; + if (game == null || !game.isAttached) { + return; + } + game.renderBox.paintWidgetComponent(this, canvas); + } +} + +class _HostNotifier extends ChangeNotifier { + void notify() => notifyListeners(); +} diff --git a/packages/flame/lib/src/game/game.dart b/packages/flame/lib/src/game/game.dart index 7069d999533..1d3742b3394 100644 --- a/packages/flame/lib/src/game/game.dart +++ b/packages/flame/lib/src/game/game.dart @@ -413,6 +413,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 fc9b4e5998c..f9e9e0662b9 100644 --- a/packages/flame/lib/src/game/game_render_box.dart +++ b/packages/flame/lib/src/game/game_render_box.dart @@ -1,18 +1,29 @@ -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:flame/src/game/widget_components.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.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 [WidgetComponentHost]. +class RenderGameWidget extends MultiChildRenderObjectWidget { const RenderGameWidget({ required this.game, required this.addRepaintBoundary, required this.behavior, + super.children, super.key, }); @@ -40,7 +51,10 @@ class RenderGameWidget extends LeafRenderObjectWidget { } } -class GameRenderBox extends RenderBox with WidgetsBindingObserver { +class GameRenderBox extends RenderBox + with + ContainerRenderObjectMixin, + WidgetsBindingObserver { GameRenderBox( this._game, this.buildContext, { @@ -88,12 +102,81 @@ 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 = []; + + bool _isPerformingLayout = false; + bool _paintedStateUpdateScheduled = false; + + 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(); + } + } + + /// 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() { + _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; + } + } finally { + _isPerformingLayout = false; + } + } + @override void attach(PipelineOwner owner) { super.attach(owner); @@ -145,12 +228,180 @@ 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; + } + + for (final child in _childByComponent.values) { + (child.parentData! as WidgetComponentParentData).paintedThisFrame = false; + } + 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; + } + _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 + /// [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, or when the widget has already been painted + /// during this paint. + @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 parentData = child.parentData! as WidgetComponentParentData; + if (parentData.paintedThisFrame) { + return; + } + + final transform = Matrix4.fromFloat64List(canvas.getTransform()); + if (transform.determinant() == 0) { + return; + } + final clip = canvas.getDestinationClipBounds(); + final localTransform = inverseBaseTransform.multiplied(transform); + parentData + ..paintTransform = localTransform + ..paintedThisFrame = true; + _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() { @@ -166,3 +417,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..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'; @@ -384,6 +385,13 @@ class GameWidgetState extends State> { game: currentGame, addRepaintBoundary: widget.addRepaintBoundary, behavior: widget.behavior, + children: [ + for (final component in currentGame.widgetComponents) + WidgetComponentHost( + key: ObjectKey(component), + component: component, + ), + ], ); 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..6552ec8d03d --- /dev/null +++ b/packages/flame/lib/src/game/proxy_canvas.dart @@ -0,0 +1,447 @@ +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 +/// 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. +/// +/// 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(), + _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; + + /// Replaces the underlying canvas with [canvas], replaying the current save + /// 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) { + if (level.isLayer) { + canvas.saveLayer(level.layerBounds, level.layerPaint!); + } else { + canvas.save(); + } + } + level.replay(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.pendingTransform.translateByDouble(dx, dy, 0, 1); + } + + @override + void scale(double sx, [double? sy]) { + _canvas.scale(sx, sy); + _current.pendingTransform.scaleByDouble(sx, sy ?? sx, 1, 1); + } + + @override + void rotate(double radians) { + _canvas.rotate(radians); + _current.pendingTransform.rotateZ(radians); + } + + @override + void skew(double sx, double sy) { + _canvas.skew(sx, sy); + _scratch.setIdentity(); + _scratch.storage[4] = sx; + _scratch.storage[1] = sy; + _current.pendingTransform.multiply(_scratch); + } + + @override + void transform(Float64List matrix4) { + _canvas.transform(matrix4); + _scratch.storage.setAll(0, matrix4); + _current.pendingTransform.multiply(_scratch); + } + + @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.recordClip( + _ClipRectOperation(rect, clipOp: clipOp, doAntiAlias: doAntiAlias), + ); + } + + @override + void clipRRect(RRect rrect, {bool doAntiAlias = true}) { + _canvas.clipRRect(rrect, doAntiAlias: doAntiAlias); + _current.recordClip(_ClipRRectOperation(rrect, doAntiAlias: doAntiAlias)); + } + + @override + void clipRSuperellipse( + RSuperellipse shape, { + bool doAntiAlias = true, + }) { + _canvas.clipRSuperellipse(shape, doAntiAlias: doAntiAlias); + _current.recordClip( + _ClipRSuperellipseOperation(shape, doAntiAlias: doAntiAlias), + ); + } + + @override + void clipPath(Path path, {bool doAntiAlias = true}) { + _canvas.clipPath(path, doAntiAlias: doAntiAlias); + _current.recordClip(_ClipPathOperation(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); + } + + 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; + + _SaveLevel.layer(this.layerBounds, this.layerPaint) : isLayer = true; + + final bool isLayer; + final Rect? layerBounds; + final Paint? layerPaint; + + /// The transforms accumulated since the last recorded clip, or since the + /// start of the level when there is none. + final Matrix4 pendingTransform = Matrix4.identity(); + + List<_ClipOperation>? _clips; + + void recordClip(_ClipOperation clip) { + clip.transformBefore.setFrom(pendingTransform); + pendingTransform.setIdentity(); + (_clips ??= []).add(clip); + } + + 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); + } + + static void _applyTransform(Canvas canvas, Matrix4 transform) { + if (!transform.isIdentity()) { + canvas.transform(transform.storage); + } + } +} + +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(); + + void apply(Canvas canvas); +} + +class _ClipRectOperation extends _ClipOperation { + _ClipRectOperation( + 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 _ClipRRectOperation extends _ClipOperation { + _ClipRRectOperation(this.rrect, {required this.doAntiAlias}); + + final RRect rrect; + final bool doAntiAlias; + + @override + void apply(Canvas canvas) { + canvas.clipRRect(rrect, doAntiAlias: doAntiAlias); + } +} + +class _ClipRSuperellipseOperation extends _ClipOperation { + _ClipRSuperellipseOperation(this.shape, {required this.doAntiAlias}); + + final RSuperellipse shape; + final bool doAntiAlias; + + @override + void apply(Canvas canvas) { + canvas.clipRSuperellipse(shape, doAntiAlias: doAntiAlias); + } +} + +class _ClipPathOperation extends _ClipOperation { + _ClipPathOperation(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/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, + ), + ), + ); + } +} 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..2507e16d7b1 --- /dev/null +++ b/packages/flame/test/components/widget_component_test.dart @@ -0,0 +1,607 @@ +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'; +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(); +} + +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 { + 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('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( + 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 { + 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..65ece04f1ca --- /dev/null +++ b/packages/flame/test/game/proxy_canvas_test.dart @@ -0,0 +1,170 @@ +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); + _expectTransform(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); + _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); + 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(); + }); + }); +} + +void _expectTransform(Float64List actual, Float64List expected) { + for (var i = 0; i < 16; i++) { + expect(actual[i], closeTo(expected[i], 1e-6), reason: 'element $i'); + } +}