diff --git a/doc/flame/components.md b/doc/flame/components.md index fdc3f1a83f1..c6af85dd6ba 100644 --- a/doc/flame/components.md +++ b/doc/flame/components.md @@ -39,6 +39,9 @@ Every `Component` has a few methods that you can optionally implement, which are The `onGameResize` method is called whenever the screen is resized, and also when this component gets added into the component tree, before the `onMount`. +The `onParentResize` method is similar: it is also called when the component is mounted into the +component tree, and also whenever the parent of the current component changes its size. + The `onRemove` method can be overridden to run code before the component is removed from the game, it is only run once even if the component is removed both by using the parents remove method and the `Component` remove method. diff --git a/doc/flame/flame.md b/doc/flame/flame.md index f502577eaee..289c51c179a 100644 --- a/doc/flame/flame.md +++ b/doc/flame/flame.md @@ -12,6 +12,7 @@ - [Camera Component](camera_component.md) - [Inputs](inputs/inputs.md) - [Rendering](rendering/rendering.md) +- [Layout](layout/layout.md) - [Overlays](overlays.md) - [Other](other/other.md) @@ -30,5 +31,6 @@ Camera & Viewport Camera Component Inputs Rendering +Layout Other ``` diff --git a/doc/flame/layout/align_component.md b/doc/flame/layout/align_component.md new file mode 100644 index 00000000000..61747b9934f --- /dev/null +++ b/doc/flame/layout/align_component.md @@ -0,0 +1,10 @@ +# AlignComponent + +```{dartdoc} +:package: flame +:symbol: AlignComponent +:file: src/layout/align_component.dart + +[Align]: https://api.flutter.dev/flutter/widgets/Align-class.html +[Alignment]: https://api.flutter.dev/flutter/painting/Alignment-class.html +``` diff --git a/doc/flame/layout/layout.md b/doc/flame/layout/layout.md new file mode 100644 index 00000000000..cceeae97acc --- /dev/null +++ b/doc/flame/layout/layout.md @@ -0,0 +1,7 @@ +# Layout + +```{toctree} +:hidden: + +AlignComponent +``` diff --git a/examples/lib/main.dart b/examples/lib/main.dart index cce0400be3b..4feee3cfa75 100644 --- a/examples/lib/main.dart +++ b/examples/lib/main.dart @@ -11,6 +11,7 @@ import 'package:examples/stories/effects/effects.dart'; import 'package:examples/stories/experimental/experimental.dart'; import 'package:examples/stories/games/games.dart'; import 'package:examples/stories/input/input.dart'; +import 'package:examples/stories/layout/layout.dart'; import 'package:examples/stories/parallax/parallax.dart'; import 'package:examples/stories/rendering/rendering.dart'; import 'package:examples/stories/sprites/sprites.dart'; @@ -39,6 +40,7 @@ void main() { addEffectsStories(dashbook); addExperimentalStories(dashbook); addInputStories(dashbook); + addLayoutStories(dashbook); addParallaxStories(dashbook); addRenderingStories(dashbook); addTiledStories(dashbook); diff --git a/examples/lib/stories/components/game_in_game_example.dart b/examples/lib/stories/components/game_in_game_example.dart index b9a806bed28..16dd4bd8913 100644 --- a/examples/lib/stories/components/game_in_game_example.dart +++ b/examples/lib/stories/components/game_in_game_example.dart @@ -37,7 +37,7 @@ class GameChangeTimer extends TimerComponent void onTick() { final child = gameRef.draggablesGame.square; final newParent = child.parent == gameRef.draggablesGame - ? gameRef.composedGame.parentSquare + ? gameRef.composedGame.parentSquare as Component : gameRef.draggablesGame; child.changeParent(newParent); } diff --git a/examples/lib/stories/layout/align_component.dart b/examples/lib/stories/layout/align_component.dart new file mode 100644 index 00000000000..e4296441ba9 --- /dev/null +++ b/examples/lib/stories/layout/align_component.dart @@ -0,0 +1,100 @@ +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/game.dart'; +import 'package:flame/layout.dart'; + +class AlignComponentExample extends FlameGame { + static const String description = ''' + In this example the AlignComponent is used to arrange the circles + so that there is one in the middle and 8 more surrounding it in + the shape of a diamond. + + The arrangement will remain intact if you change the window size. + '''; + + @override + void onLoad() { + addAll([ + AlignComponent( + child: CircleComponent( + radius: 40, + children: [ + SizeEffect.by( + Vector2.all(25), + EffectController( + infinite: true, + duration: 0.75, + reverseDuration: 0.5, + ), + ), + AlignComponent( + alignment: Anchor.topCenter, + child: CircleComponent( + radius: 10, + anchor: Anchor.bottomCenter, + ), + keepChildAnchor: true, + ), + AlignComponent( + alignment: Anchor.bottomCenter, + child: CircleComponent( + radius: 10, + anchor: Anchor.topCenter, + ), + keepChildAnchor: true, + ), + AlignComponent( + alignment: Anchor.centerLeft, + child: CircleComponent( + radius: 10, + anchor: Anchor.centerRight, + ), + keepChildAnchor: true, + ), + AlignComponent( + alignment: Anchor.centerRight, + child: CircleComponent( + radius: 10, + anchor: Anchor.centerLeft, + ), + keepChildAnchor: true, + ), + ], + ), + alignment: Anchor.center, + ), + AlignComponent( + child: CircleComponent(radius: 30), + alignment: Anchor.topCenter, + ), + AlignComponent( + child: CircleComponent(radius: 30), + alignment: Anchor.bottomCenter, + ), + AlignComponent( + child: CircleComponent(radius: 30), + alignment: Anchor.centerLeft, + ), + AlignComponent( + child: CircleComponent(radius: 30), + alignment: Anchor.centerRight, + ), + AlignComponent( + child: CircleComponent(radius: 10), + alignment: const Anchor(0.25, 0.25), + ), + AlignComponent( + child: CircleComponent(radius: 10), + alignment: const Anchor(0.25, 0.75), + ), + AlignComponent( + child: CircleComponent(radius: 10), + alignment: const Anchor(0.75, 0.25), + ), + AlignComponent( + child: CircleComponent(radius: 10), + alignment: const Anchor(0.75, 0.75), + ), + ]); + } +} diff --git a/examples/lib/stories/layout/layout.dart b/examples/lib/stories/layout/layout.dart new file mode 100644 index 00000000000..8c46c4cb9df --- /dev/null +++ b/examples/lib/stories/layout/layout.dart @@ -0,0 +1,13 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:examples/commons/commons.dart'; +import 'package:examples/stories/layout/align_component.dart'; +import 'package:flame/game.dart'; + +void addLayoutStories(Dashbook dashbook) { + dashbook.storiesOf('Layout').add( + 'AlignComponent', + (_) => GameWidget(game: AlignComponentExample()), + codeLink: baseLink('layout/align_component.dart'), + info: AlignComponentExample.description, + ); +} diff --git a/packages/flame/lib/layout.dart b/packages/flame/lib/layout.dart new file mode 100644 index 00000000000..d5a871850f4 --- /dev/null +++ b/packages/flame/lib/layout.dart @@ -0,0 +1 @@ +export 'src/layout/align_component.dart' show AlignComponent; diff --git a/packages/flame/lib/src/camera/viewport.dart b/packages/flame/lib/src/camera/viewport.dart index 3870387dd5f..663998205f9 100644 --- a/packages/flame/lib/src/camera/viewport.dart +++ b/packages/flame/lib/src/camera/viewport.dart @@ -66,6 +66,9 @@ abstract class Viewport extends Component camera.viewfinder.onViewportResize(); } onViewportResize(); + if (hasChildren) { + children.forEach((child) => child.onParentResize(_size)); + } } /// Reference to the parent camera. diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 08d1d0ae027..618b60c022f 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -7,6 +7,7 @@ import 'package:flame/src/components/core/component_tree_root.dart'; import 'package:flame/src/components/core/position_type.dart'; import 'package:flame/src/components/mixins/coordinate_transform.dart'; import 'package:flame/src/components/mixins/has_game_ref.dart'; +import 'package:flame/src/effects/provider_interfaces.dart'; import 'package:flame/src/game/flame_game.dart'; import 'package:flame/src/game/game.dart'; import 'package:flame/src/gestures/events.dart'; @@ -474,6 +475,14 @@ class Component { /// [onMount] call before. void onRemove() {} + /// Called whenever the parent of this component changes size; and also once + /// before [onMount]. + /// + /// The component may change its own size or perform layout in response to + /// this call. If the component changes size, then it should call + /// [onParentResize] for all its children. + void onParentResize(Vector2 maxSize) {} + /// This method is called periodically by the game engine to request that your /// component updates itself. /// @@ -830,6 +839,9 @@ class Component { assert(isLoaded && !isLoading); _setMountingBit(); onGameResize(_parent!.findGame()!.canvasSize); + if (_parent is ReadonlySizeProvider) { + onParentResize((_parent! as ReadonlySizeProvider).size); + } if (isRemoved) { _clearRemovedBit(); } else if (isRemoving) { diff --git a/packages/flame/lib/src/components/position_component.dart b/packages/flame/lib/src/components/position_component.dart index 644c7dd06e4..07754679aca 100644 --- a/packages/flame/lib/src/components/position_component.dart +++ b/packages/flame/lib/src/components/position_component.dart @@ -68,6 +68,7 @@ class PositionComponent extends Component AngleProvider, PositionProvider, ScaleProvider, + SizeProvider, CoordinateTransform { PositionComponent({ Vector2? position, @@ -184,8 +185,16 @@ class PositionComponent extends Component /// This property can be reassigned at runtime, although this is not /// recommended. Instead, in order to make the [PositionComponent] larger /// or smaller, change its [scale]. + @override NotifyingVector2 get size => _size; - set size(Vector2 size) => _size.setFrom(size); + + @override + set size(Vector2 size) { + _size.setFrom(size); + if (hasChildren) { + children.forEach((child) => child.onParentResize(_size)); + } + } /// The width of the component in local coordinates. Note that the object /// may visually appear larger or smaller due to application of [scale]. diff --git a/packages/flame/lib/src/effects/provider_interfaces.dart b/packages/flame/lib/src/effects/provider_interfaces.dart index 74a076fe361..967adacfae1 100644 --- a/packages/flame/lib/src/effects/provider_interfaces.dart +++ b/packages/flame/lib/src/effects/provider_interfaces.dart @@ -46,9 +46,14 @@ abstract class AnchorProvider { set anchor(Anchor value); } -/// Interface for a component that can be affected by size effects. -abstract class SizeProvider { +/// Interface for a class that has [size] property which can be read but not +/// modified. +abstract class ReadonlySizeProvider { Vector2 get size; +} + +/// Interface for a component that can be affected by size effects. +abstract class SizeProvider extends ReadonlySizeProvider { set size(Vector2 value); } diff --git a/packages/flame/lib/src/game/flame_game.dart b/packages/flame/lib/src/game/flame_game.dart index ee59413c1d6..e7c0c18a4bb 100644 --- a/packages/flame/lib/src/game/flame_game.dart +++ b/packages/flame/lib/src/game/flame_game.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flame/components.dart'; import 'package:flame/src/components/core/component_tree_root.dart'; +import 'package:flame/src/effects/provider_interfaces.dart'; import 'package:flame/src/game/camera/camera.dart'; import 'package:flame/src/game/camera/camera_wrapper.dart'; import 'package:flame/src/game/game.dart'; @@ -15,7 +16,9 @@ import 'package:meta/meta.dart'; /// /// This is the recommended base class to use for most games made with Flame. /// It is based on the Flame Component System (also known as FCS). -class FlameGame extends ComponentTreeRoot with Game { +class FlameGame extends ComponentTreeRoot + with Game + implements ReadonlySizeProvider { FlameGame({ super.children, Camera? camera, @@ -111,6 +114,7 @@ class FlameGame extends ComponentTreeRoot with Game { // there is no way to explicitly call the [Component]'s implementation, // we propagate the event to [FlameGame]'s children manually. handleResize(canvasSize); + children.forEach((child) => child.onParentResize(canvasSize)); } /// Ensure that all pending tree operations finish. diff --git a/packages/flame/lib/src/layout/align_component.dart b/packages/flame/lib/src/layout/align_component.dart new file mode 100644 index 00000000000..3547f444b57 --- /dev/null +++ b/packages/flame/lib/src/layout/align_component.dart @@ -0,0 +1,124 @@ +import 'package:flame/src/anchor.dart'; +import 'package:flame/src/components/position_component.dart'; +import 'package:flame/src/effects/provider_interfaces.dart'; +import 'package:flutter/widgets.dart'; +import 'package:vector_math/vector_math_64.dart'; + +/// **AlignComponent** is a layout component that positions its child within +/// itself using relative placement. It is similar to Flutter's [Align] widget. +/// +/// The component requires a single [child], which will be the target of this +/// component's alignment. Of course, other children can be added to this +/// component too, but only the initial [child] will be aligned. +/// +/// The [alignment] parameter describes where the child should be placed within +/// the current component. For example, if the [alignment] is `Anchor.center`, +/// then the child will be centered. +/// +/// Normally, this component's size will match the size of its parent. However, +/// if you provide properties [widthFactor] or [heightFactor], then the size of +/// this component in that direction will be equal to the size of the child +/// times the corresponding factor. For example, if you set [heightFactor] to +/// 1 then the width of this component will be equal to the width of the parent, +/// but the height will match the height of the child. +/// +/// ```dart +/// AlignComponent( +/// child: TextComponent('hello'), +/// alignment: Anchor.centerLeft, +/// ); +/// ``` +/// +/// By default, the child's anchor is set equal to the [alignment] value. This +/// achieves traditional alignment behavior: for example, the center of the +/// child will be placed at the center of the current component, or bottom +/// right corner of the child can be placed in the bottom right corner of the +/// component. However, it is also possible to achieve more extravagant +/// placement by giving the child a different anchor and setting +/// [keepChildAnchor] to true. For example, if you set `alignment` to +/// `topCenter`, and child's anchor to `bottomCenter`, then the child will +/// effectively be placed above the current component: +/// ```dart +/// PlayerSprite().add( +/// AlignComponent( +/// child: HealthBar()..anchor = Anchor.bottomCenter, +/// alignment: Anchor.topCenter, +/// keepChildAnchor: true, +/// ), +/// ); +/// ``` +class AlignComponent extends PositionComponent { + /// Creates a component that keeps its [child] positioned according to the + /// [alignment] within this component's bounding box. + /// + /// More precisely, the child will be placed at [alignment] relative position + /// within the current component's bounding box. The child's anchor will also + /// be set to the [alignment], unless [keepChildAnchor] parameter is true. + AlignComponent({ + required this.child, + required Anchor alignment, + this.widthFactor, + this.heightFactor, + this.keepChildAnchor = false, + }) { + this.alignment = alignment; + add(child); + } + + late Anchor _alignment; + + /// The component that will be positioned by this component. The [child] will + /// be automatically mounted to the current component. + final PositionComponent child; + + /// How the [child] will be positioned within the current component. + /// + /// Note: unlike Flutter's [Alignment], the top-left corner of the component + /// has relative coordinates `(0, 0)`, while the bottom-right corner has + /// coordinates `(1, 1)`. + Anchor get alignment => _alignment; + set alignment(Anchor value) { + _alignment = value; + if (!keepChildAnchor) { + child.anchor = value; + } + child.position = Vector2(size.x * alignment.x, size.y * alignment.y); + } + + /// If `null`, then the component's width will be equal to the width of the + /// parent. Otherwise, the width will be equal to the child's width multiplied + /// by this factor. + final double? widthFactor; + + /// If `null`, then the component's height will be equal to the height of the + /// parent. Otherwise, the height will be equal to the child's height + /// multiplied by this factor. + final double? heightFactor; + + /// If `false` (default), then the child's `anchor` will be kept equal to the + /// [alignment] value. If `true`, then the [child] will be allowed to have its + /// own `anchor` value independent from the parent. + final bool keepChildAnchor; + + @override + set size(Vector2 value) { + throw UnsupportedError('The size of AlignComponent cannot be set directly'); + } + + @override + void onMount() { + assert( + parent is ReadonlySizeProvider, + "An AlignComponent's parent must have a size", + ); + } + + @override + void onParentResize(Vector2 maxSize) { + super.size = Vector2( + widthFactor == null ? maxSize.x : child.size.x * widthFactor!, + heightFactor == null ? maxSize.y : child.size.y * heightFactor!, + ); + child.position = Vector2(size.x * alignment.x, size.y * alignment.y); + } +} diff --git a/packages/flame/test/_goldens/align_component_1.png b/packages/flame/test/_goldens/align_component_1.png new file mode 100644 index 00000000000..521acb102cb Binary files /dev/null and b/packages/flame/test/_goldens/align_component_1.png differ diff --git a/packages/flame/test/layout/align_component_test.dart b/packages/flame/test/layout/align_component_test.dart new file mode 100644 index 00000000000..9d2393f05b2 --- /dev/null +++ b/packages/flame/test/layout/align_component_test.dart @@ -0,0 +1,102 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/layout.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AlignComponent', () { + testWithFlameGame('Valid parent', (game) async { + expectLater( + () async { + final parent = Component(); + game.add(parent); + parent.add( + AlignComponent( + child: PositionComponent(), + alignment: Anchor.center, + ), + ); + await game.ready(); + }, + failsAssert("An AlignComponent's parent must have a size"), + ); + }); + + testGolden( + 'Align placement: golden', + (game) async { + final stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 5.0 + ..color = const Color(0xaaffff00); + game.add( + AlignComponent( + alignment: Anchor.center, + child: CircleComponent(radius: 20), + ), + ); + for (final alignment in [ + Anchor.topLeft, + Anchor.topRight, + Anchor.bottomLeft, + Anchor.bottomRight, + ]) { + game.add( + AlignComponent( + child: CircleComponent( + radius: 60, + paint: stroke, + anchor: Anchor.center, + ), + alignment: alignment, + keepChildAnchor: true, + ), + ); + } + }, + goldenFile: '../_goldens/align_component_1.png', + size: Vector2(150, 100), + ); + + testWithFlameGame( + "Child's alignment remains valid when game resizes", + (game) async { + final component = CircleComponent(radius: 20); + game.add( + AlignComponent(child: component, alignment: Anchor.center), + ); + await game.ready(); + + expect(component.anchor, Anchor.center); + expect(component.position, Vector2(400, 300)); + expect(component.size, Vector2.all(40)); + + game.onGameResize(Vector2(1000, 2000)); + expect(component.position, Vector2(500, 1000)); + expect(component.size, Vector2.all(40)); + }, + ); + + testWithFlameGame( + 'Changing alignment value', + (game) async { + final component = CircleComponent(radius: 20); + final alignComponent = AlignComponent( + child: component, + alignment: Anchor.center, + ); + game.add(alignComponent); + await game.ready(); + + expect(component.anchor, Anchor.center); + expect(component.position, Vector2(400, 300)); + + alignComponent.alignment = Anchor.bottomLeft; + expect(component.position, Vector2(0, 600)); + expect(component.anchor, Anchor.bottomLeft); + }, + ); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 65f76eaa68c..8b19f6c4190 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -290,4 +290,4 @@ packages: source: hosted version: "2.0.3" sdks: - dart: ">=2.18.0 <4.0.0" + dart: ">=2.18.0 <3.0.0"