From f088b61d9f1cb40ba51155a5e5274837c82678b1 Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Mon, 19 Oct 2026 12:22:00 -0300 Subject: [PATCH] feat: Adding anchor attributes --- CLAUDE.md | 8 +- docs/architecture.md | 24 +++-- .../bloc/component_editor_bloc.dart | 13 +++ .../bloc/component_editor_event.dart | 12 +++ .../widgets/preview/flame_mapping.dart | 14 ++- .../widgets/properties_panel.dart | 12 ++- .../property_editors/anchor_editor.dart | 99 +++++++++++++++++++ .../property_editors/property_editors.dart | 1 + .../component_code_generator.dart | 13 ++- .../lib/src/models/circle_component.dart | 23 ++++- .../lib/src/models/component_anchor.dart | 96 ++++++++++++++++++ .../src/models/component_capabilities.dart | 13 +++ .../lib/src/models/models.dart | 1 + .../lib/src/models/position_component.dart | 21 +++- .../lib/src/models/rectangle_component.dart | 23 ++++- .../component_code_generator_test.dart | 72 ++++++++++++++ .../src/models/circle_component_test.dart | 25 +++++ .../src/models/component_anchor_test.dart | 55 +++++++++++ .../src/models/position_component_test.dart | 25 +++++ .../src/models/rectangle_component_test.dart | 25 +++++ .../bloc/component_editor_bloc_test.dart | 16 +++ .../view/component_editor_page_test.dart | 26 ++++- .../preview/component_preview_game_test.dart | 26 ++++- .../property_editors/anchor_editor_test.dart | 73 ++++++++++++++ 24 files changed, 680 insertions(+), 36 deletions(-) create mode 100644 lib/component_editor/widgets/property_editors/anchor_editor.dart create mode 100644 packages/component_repository/lib/src/models/component_anchor.dart create mode 100644 packages/component_repository/test/src/models/component_anchor_test.dart create mode 100644 test/component_editor/widgets/property_editors/anchor_editor_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index c3ebdef..2f2e7f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,11 +65,11 @@ three pass. `file_system_client`, `project_repository`, `component_repository` (components stored in `/.flame_editor/components/.json`) — see "Existing modules" in [docs/architecture.md](docs/architecture.md). -- Component types: `rectangle` (position — edited only on children —, - size, and paint with color and fill style), `position` (a plain Flame - `PositionComponent`: position — children only — and size; draws nothing +- Component types: `rectangle` (position and anchor — edited only on + children —, size, and paint with color and fill style), `position` (a plain Flame + `PositionComponent`: position and anchor — children only — and size; draws nothing itself, useful as a container) and `circle` (a Flame `CircleComponent`: - position — children only —, radius, and paint; the radius is its own + position and anchor — children only —, radius, and paint; the radius is its own `ComponentRadius` property, since Flame circles take a radius, not a size). Components can have children (see "Component tree" in [docs/architecture.md](docs/architecture.md)). Properties are shared between types through capability interfaces — diff --git a/docs/architecture.md b/docs/architecture.md index 9396f5d..321b91a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -150,13 +150,13 @@ component types only declare which ones they have: | Piece | Where | Example | | --- | --- | --- | -| Value object (Equatable, JSON, defaults) | `component_repository/lib/src/models/` | `ComponentPosition`, `ComponentSize`, `ComponentRadius`, `ComponentPaint` | -| Capability interface | `component_repository/lib/src/models/component_capabilities.dart` | `PositionedComponent`, `SizedComponent`, `RadiusComponent`, `PaintedComponent` | -| Component type | `ComponentType` enum + a subclass in `component.dart`'s library (`part` file) | `RectangleComponent implements PositionedComponent, SizedComponent, PaintedComponent`, `PositionComponent implements PositionedComponent, SizedComponent`, `CircleComponent implements PositionedComponent, RadiusComponent, PaintedComponent` | +| Value object (Equatable, JSON, defaults) | `component_repository/lib/src/models/` | `ComponentPosition`, `ComponentAnchor`, `ComponentSize`, `ComponentRadius`, `ComponentPaint` | +| Capability interface | `component_repository/lib/src/models/component_capabilities.dart` | `PositionedComponent`, `AnchoredComponent`, `SizedComponent`, `RadiusComponent`, `PaintedComponent` | +| Component type | `ComponentType` enum + a subclass in `component.dart`'s library (`part` file) | `RectangleComponent implements PositionedComponent, AnchoredComponent, SizedComponent, PaintedComponent`, `PositionComponent implements PositionedComponent, AnchoredComponent, SizedComponent`, `CircleComponent implements PositionedComponent, AnchoredComponent, RadiusComponent, PaintedComponent` | | Bloc event | `lib/component_editor/bloc/` | `ComponentEditorSizeChanged` (edits the selected node if it is a `SizedComponent`) | -| Editor widget (`value` + `onChanged`, bloc-agnostic) | `lib/component_editor/widgets/property_editors/` | `SizeEditor`, `RadiusEditor`, `PaintEditor`, built from `NumberField`, `ColorField`, `EnumField` | +| Editor widget (`value` + `onChanged`, bloc-agnostic) | `lib/component_editor/widgets/property_editors/` | `PositionEditor`, `AnchorEditor`, `SizeEditor`, `RadiusEditor`, `PaintEditor`, built from `NumberField`, `ColorField`, `EnumField` | | Panel wiring | `lib/component_editor/widgets/properties_panel.dart` | `if (component case SizedComponent c) SizeEditor(...)` | -| Flame mapping | `lib/component_editor/widgets/preview/flame_mapping.dart` | `applyPosition`, `applySize`, `applyRadius`, `applyPaint`, `buildFlameComponent` | +| Flame mapping | `lib/component_editor/widgets/preview/flame_mapping.dart` | `applyPosition`, `applyAnchor`, `applySize`, `applyRadius`, `applyPaint`, `buildFlameComponent` | | Code generation | `component_repository/lib/src/code_generation/` | `ComponentCodeGenerator._flameClass` / `_arguments`, `flameMemberNames` in `dart_names.dart` | **Adding a component type:** add a `ComponentType` value, a `Component` @@ -184,10 +184,12 @@ from the root (`[]` is the root); `ComponentTree` (`nodeAt`, `updateNodeAt`, edits. The editor state holds `selectedPath`, and every property event edits `selectedNode`. New children get a unique default name such as `rectangle1`. -The position is only edited on children (`PropertiesPanel` hides it for the -root, which is placed by whoever uses it). In the preview the root is -centered with a center anchor (its stored position is ignored); children keep Flame's default top left anchor -and are positioned relative to their parent. `ComponentPreviewGame.show` +The position and the anchor are only edited on children (`PropertiesPanel` +hides them for the root, which is placed by whoever uses it). In the preview +the root is centered with a center anchor (its stored position and anchor +are ignored); children use their own anchor (`ComponentAnchor`, Flame's +default top left unless edited: one of Flame's nine named anchors or a +custom `x`/`y`) and are positioned relative to their parent. `ComponentPreviewGame.show` updates the Flame components in place while the tree shape is unchanged (`haveSameShape`) and rebuilds them otherwise. @@ -242,6 +244,10 @@ member lists in `dart_names.dart` (`flameMemberNames`). It generates: `class PlayerShip extends $PlayerShip`), with a `{super.position, super.anchor, super.priority}` constructor (the root's stored position is ignored, like in the preview); +- the children's anchor as an `anchor:` argument, `Anchor.` for + Flame's named anchors (`ComponentAnchor.presetName`) and `Anchor(x, y)` + otherwise, omitted for the default top left (so files generated before + anchors existed stay up to date); - **a `final` field (a getter) for every child on the component it is inside**, named in camelCase after the child (`left_arm` → `leftArm`), added in the constructor body; diff --git a/lib/component_editor/bloc/component_editor_bloc.dart b/lib/component_editor/bloc/component_editor_bloc.dart index d773f8b..f77e9eb 100644 --- a/lib/component_editor/bloc/component_editor_bloc.dart +++ b/lib/component_editor/bloc/component_editor_bloc.dart @@ -35,6 +35,7 @@ class ComponentEditorBloc on(_onNodeRemoved); on(_onNameChanged); on(_onPositionChanged); + on(_onAnchorChanged); on(_onSizeChanged); on(_onRadiusChanged); on(_onPaintChanged); @@ -181,6 +182,18 @@ class ComponentEditorBloc }); } + void _onAnchorChanged( + ComponentEditorAnchorChanged event, + Emitter emit, + ) { + _editSelected(emit, (node) { + if (node case final AnchoredComponent node) { + return node.copyWithAnchor(event.anchor); + } + return node; + }); + } + void _onSizeChanged( ComponentEditorSizeChanged event, Emitter emit, diff --git a/lib/component_editor/bloc/component_editor_event.dart b/lib/component_editor/bloc/component_editor_event.dart index a3d2961..c5e6489 100644 --- a/lib/component_editor/bloc/component_editor_event.dart +++ b/lib/component_editor/bloc/component_editor_event.dart @@ -92,6 +92,18 @@ final class ComponentEditorSizeChanged extends ComponentEditorEvent { List get props => [size]; } +/// The user edited the anchor of the selected [AnchoredComponent]. +final class ComponentEditorAnchorChanged extends ComponentEditorEvent { + /// Creates a [ComponentEditorAnchorChanged] event. + const new(this.anchor); + + /// The new anchor. + final ComponentAnchor anchor; + + @override + List get props => [anchor]; +} + /// The user edited the radius of the selected [RadiusComponent]. final class ComponentEditorRadiusChanged extends ComponentEditorEvent { /// Creates a [ComponentEditorRadiusChanged] event. diff --git a/lib/component_editor/widgets/preview/flame_mapping.dart b/lib/component_editor/widgets/preview/flame_mapping.dart index d78f2b5..47b670e 100644 --- a/lib/component_editor/widgets/preview/flame_mapping.dart +++ b/lib/component_editor/widgets/preview/flame_mapping.dart @@ -8,9 +8,9 @@ import 'package:flutter/painting.dart'; /// /// The root is anchored at its center, at the origin of the world, so it /// is centered on screen; its stored position is ignored because the root -/// is placed by whoever uses it. Children keep Flame's default top left -/// anchor and are positioned relative to their parent, like in the -/// generated code. +/// is placed by whoever uses it. Children use their own anchor (Flame's +/// default top left one unless edited) and are positioned relative to their +/// parent, like in the generated code. flame.PositionComponent buildFlameComponent(Component component) { return _placeRoot(_build(component)); } @@ -93,6 +93,9 @@ T applyProperties( if (component case final PositionedComponent positioned) { applyPosition(target, positioned.position); } + if (component case final AnchoredComponent anchored) { + applyAnchor(target, anchored.anchor); + } if (component case final SizedComponent sized) { applySize(target, sized.size); } @@ -112,6 +115,11 @@ void applyPosition(flame.PositionComponent target, ComponentPosition position) { target.position.setValues(position.x, position.y); } +/// Applies [anchor] to [target]. +void applyAnchor(flame.PositionComponent target, ComponentAnchor anchor) { + target.anchor = flame.Anchor(anchor.x, anchor.y); +} + /// Applies [size] to [target]. void applySize(flame.PositionComponent target, ComponentSize size) { target.size.setValues(size.width, size.height); diff --git a/lib/component_editor/widgets/properties_panel.dart b/lib/component_editor/widgets/properties_panel.dart index c901b69..78916e3 100644 --- a/lib/component_editor/widgets/properties_panel.dart +++ b/lib/component_editor/widgets/properties_panel.dart @@ -9,9 +9,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// component tree. /// /// Editors are picked from the capabilities the node implements, so a new -/// component type gets them without changes here. The name and the -/// position are only edited on children: the root name is its file and -/// class name, and the root is placed by whoever uses it. +/// component type gets them without changes here. The name, the position +/// and the anchor are only edited on children: the root name is its file +/// and class name, and the root is placed by whoever uses it. /// {@endtemplate} class PropertiesPanel extends StatelessWidget { /// {@macro properties_panel} @@ -53,6 +53,12 @@ class PropertiesPanel extends StatelessWidget { onChanged: (position) => bloc.add(ComponentEditorPositionChanged(position)), ), + if (component case final AnchoredComponent anchored when !isRoot) + AnchorEditor( + value: anchored.anchor, + onChanged: (anchor) => + bloc.add(ComponentEditorAnchorChanged(anchor)), + ), if (component case final SizedComponent sized) SizeEditor( value: sized.size, diff --git a/lib/component_editor/widgets/property_editors/anchor_editor.dart b/lib/component_editor/widgets/property_editors/anchor_editor.dart new file mode 100644 index 0000000..26c055e --- /dev/null +++ b/lib/component_editor/widgets/property_editors/anchor_editor.dart @@ -0,0 +1,99 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/component_editor/widgets/property_editors/number_field.dart'; +import 'package:flame_component_editor/component_editor/widgets/property_editors/property_section.dart'; +import 'package:flutter/material.dart'; + +/// {@template anchor_editor} +/// Edits a [ComponentAnchor]. Used by every [AnchoredComponent] inside +/// another component. +/// +/// A 3x3 grid picks one of Flame's named anchors, and the X and Y fields +/// set a custom one. +/// {@endtemplate} +class AnchorEditor extends StatelessWidget { + /// {@macro anchor_editor} + const new({required this.value, required this.onChanged, super.key}); + + /// The current anchor. + final ComponentAnchor value; + + /// Called with the edited anchor. + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final presets = ComponentAnchor.presets.entries.toList(); + return PropertySection( + title: 'Anchor', + children: [ + Row( + spacing: 12, + children: [ + Column( + children: [ + for (var row = 0; row < 3; row++) + Row( + children: [ + for (final MapEntry(key: name, value: preset) + in presets.skip(row * 3).take(3)) + _PresetButton( + name: name, + isSelected: preset == value, + onPressed: () => onChanged(preset), + ), + ], + ), + ], + ), + Expanded( + child: Column( + spacing: 12, + children: [ + NumberField( + label: 'X', + value: value.x, + min: null, + onChanged: (x) => onChanged(value.copyWith(x: x)), + ), + NumberField( + label: 'Y', + value: value.y, + min: null, + onChanged: (y) => onChanged(value.copyWith(y: y)), + ), + ], + ), + ), + ], + ), + ], + ); + } +} + +class _PresetButton extends StatelessWidget { + const new({ + required this.name, + required this.isSelected, + required this.onPressed, + }); + + final String name; + + final bool isSelected; + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return IconButton( + tooltip: name, + isSelected: isSelected, + visualDensity: VisualDensity.compact, + iconSize: 16, + icon: const Icon(Icons.circle_outlined), + selectedIcon: const Icon(Icons.circle), + onPressed: onPressed, + ); + } +} diff --git a/lib/component_editor/widgets/property_editors/property_editors.dart b/lib/component_editor/widgets/property_editors/property_editors.dart index ffbe7e8..1854491 100644 --- a/lib/component_editor/widgets/property_editors/property_editors.dart +++ b/lib/component_editor/widgets/property_editors/property_editors.dart @@ -1,3 +1,4 @@ +export 'anchor_editor.dart'; export 'color_field.dart'; export 'enum_field.dart'; export 'name_field.dart'; diff --git a/packages/component_repository/lib/src/code_generation/component_code_generator.dart b/packages/component_repository/lib/src/code_generation/component_code_generator.dart index 6394848..14f897b 100644 --- a/packages/component_repository/lib/src/code_generation/component_code_generator.dart +++ b/packages/component_repository/lib/src/code_generation/component_code_generator.dart @@ -133,7 +133,7 @@ class ComponentCodeGenerator { } /// The arguments of the Flame constructor, one per property. The root - /// position is left to whoever uses the component. + /// position and anchor are left to whoever uses the component. static List _arguments( Component component, { required bool withPosition, @@ -141,6 +141,10 @@ class ComponentCodeGenerator { return [ if (component case final PositionedComponent c when withPosition) 'position: ${_vector(c.position.x, c.position.y)}', + // Flame already defaults to the top left anchor. + if (component case final AnchoredComponent c + when withPosition && c.anchor != ComponentAnchor.topLeft) + 'anchor: ${_anchor(c.anchor)}', if (component case final SizedComponent c) 'size: ${_vector(c.size.width, c.size.height)}', if (component case final RadiusComponent c) @@ -165,6 +169,13 @@ class ComponentCodeGenerator { : value.toString(); } + static String _anchor(ComponentAnchor anchor) { + final name = anchor.presetName; + return name != null + ? 'Anchor.$name' + : 'Anchor(${_number(anchor.x)}, ${_number(anchor.y)})'; + } + static String _paint(ComponentPaint paint) { final color = paint.color.toRadixString(16).toUpperCase().padLeft(8, '0'); return [ diff --git a/packages/component_repository/lib/src/models/circle_component.dart b/packages/component_repository/lib/src/models/circle_component.dart index 33b0695..c8a4f6b 100644 --- a/packages/component_repository/lib/src/models/circle_component.dart +++ b/packages/component_repository/lib/src/models/circle_component.dart @@ -1,14 +1,19 @@ part of 'component.dart'; /// {@template circle_component} -/// A circle with a position, a radius and a paint. +/// A circle with a position, an anchor, a radius and a paint. /// {@endtemplate} final class CircleComponent extends Component - implements PositionedComponent, RadiusComponent, PaintedComponent { + implements + PositionedComponent, + AnchoredComponent, + RadiusComponent, + PaintedComponent { /// {@macro circle_component} const new({ required super.name, this.position = const ComponentPosition(), + this.anchor = ComponentAnchor.topLeft, this.radius = const ComponentRadius(), this.paint = const ComponentPaint(), super.children, @@ -21,6 +26,7 @@ final class CircleComponent extends Component return CircleComponent( name: json['name'] as String, position: ComponentPosition.fromJson(json['position']), + anchor: ComponentAnchor.fromJson(json['anchor']), radius: ComponentRadius.fromJson(json['radius']), paint: ComponentPaint.fromJson(json['paint']), ); @@ -32,6 +38,9 @@ final class CircleComponent extends Component @override final ComponentPosition position; + @override + final ComponentAnchor anchor; + @override final ComponentRadius radius; @@ -43,6 +52,11 @@ final class CircleComponent extends Component return _copyWith(position: position); } + @override + CircleComponent copyWithAnchor(ComponentAnchor anchor) { + return _copyWith(anchor: anchor); + } + @override CircleComponent copyWithRadius(ComponentRadius radius) { return _copyWith(radius: radius); @@ -64,6 +78,7 @@ final class CircleComponent extends Component CircleComponent _copyWith({ String? name, ComponentPosition? position, + ComponentAnchor? anchor, ComponentRadius? radius, ComponentPaint? paint, List? children, @@ -71,6 +86,7 @@ final class CircleComponent extends Component return CircleComponent( name: name ?? this.name, position: position ?? this.position, + anchor: anchor ?? this.anchor, radius: radius ?? this.radius, paint: paint ?? this.paint, children: children ?? this.children, @@ -81,10 +97,11 @@ final class CircleComponent extends Component Map toJson() => { ...super.toJson(), 'position': position.toJson(), + 'anchor': anchor.toJson(), 'radius': radius.toJson(), 'paint': paint.toJson(), }; @override - List get props => [...super.props, position, radius, paint]; + List get props => [...super.props, position, anchor, radius, paint]; } diff --git a/packages/component_repository/lib/src/models/component_anchor.dart b/packages/component_repository/lib/src/models/component_anchor.dart new file mode 100644 index 0000000..2d12ff8 --- /dev/null +++ b/packages/component_repository/lib/src/models/component_anchor.dart @@ -0,0 +1,96 @@ +import 'package:equatable/equatable.dart'; + +/// {@template component_anchor} +/// The anchor property shared by every position based component. +/// +/// Mirrors Flame's `Anchor`: the point of the component, relative to its +/// size, that is placed at its position. `(0, 0)` is the top left corner +/// (Flame's default) and `(1, 1)` the bottom right one. Values outside of +/// that range are allowed, like in Flame. +/// {@endtemplate} +class ComponentAnchor extends Equatable { + /// {@macro component_anchor} + const new({this.x = 0, this.y = 0}); + + /// Creates a [ComponentAnchor] from its JSON representation. + /// + /// Missing or invalid values fall back to `0`, so components saved without + /// an anchor are anchored at their top left corner. + factory fromJson(Object? json) { + final map = json is Map ? json : const {}; + return ComponentAnchor( + x: _readCoordinate(map['x']), + y: _readCoordinate(map['y']), + ); + } + + /// The top left corner, Flame's default anchor. + static const topLeft = ComponentAnchor(); + + /// The center of the top edge. + static const topCenter = ComponentAnchor(x: 0.5); + + /// The top right corner. + static const topRight = ComponentAnchor(x: 1); + + /// The center of the left edge. + static const centerLeft = ComponentAnchor(y: 0.5); + + /// The center of the component. + static const center = ComponentAnchor(x: 0.5, y: 0.5); + + /// The center of the right edge. + static const centerRight = ComponentAnchor(x: 1, y: 0.5); + + /// The bottom left corner. + static const bottomLeft = ComponentAnchor(y: 1); + + /// The center of the bottom edge. + static const bottomCenter = ComponentAnchor(x: 0.5, y: 1); + + /// The bottom right corner. + static const bottomRight = ComponentAnchor(x: 1, y: 1); + + /// Flame's named anchors by name, row by row from the top left corner. + static const Map presets = { + 'topLeft': topLeft, + 'topCenter': topCenter, + 'topRight': topRight, + 'centerLeft': centerLeft, + 'center': center, + 'centerRight': centerRight, + 'bottomLeft': bottomLeft, + 'bottomCenter': bottomCenter, + 'bottomRight': bottomRight, + }; + + /// The horizontal coordinate, relative to the width. + final double x; + + /// The vertical coordinate, relative to the height. + final double y; + + /// The name of the Flame anchor equal to this one (such as `center`), or + /// `null` for a custom anchor. + String? get presetName { + for (final MapEntry(:key, :value) in presets.entries) { + if (value == this) return key; + } + return null; + } + + /// Returns a copy of this anchor with the given fields replaced. + ComponentAnchor copyWith({double? x, double? y}) { + return ComponentAnchor(x: x ?? this.x, y: y ?? this.y); + } + + /// Converts this anchor to its JSON representation. + Map toJson() => {'x': x, 'y': y}; + + static double _readCoordinate(Object? value) { + return value is num ? value.toDouble() : 0; + } + + @override + List get props => [x, y]; +} diff --git a/packages/component_repository/lib/src/models/component_capabilities.dart b/packages/component_repository/lib/src/models/component_capabilities.dart index 2c9ad5e..2615a59 100644 --- a/packages/component_repository/lib/src/models/component_capabilities.dart +++ b/packages/component_repository/lib/src/models/component_capabilities.dart @@ -13,6 +13,19 @@ abstract interface class PositionedComponent { Component copyWithPosition(ComponentPosition position); } +/// A [Component] that has a [ComponentAnchor]. +/// +/// Implemented by every component type backed by a Flame +/// `PositionComponent`. Like the position, the anchor is only edited on +/// child components: the root is placed by whoever uses it. +abstract interface class AnchoredComponent { + /// The point of the component that is placed at its position. + ComponentAnchor get anchor; + + /// Returns a copy of this component with [anchor] replaced. + Component copyWithAnchor(ComponentAnchor anchor); +} + /// A [Component] that has a [ComponentSize]. /// /// Implemented by every component type backed by a Flame diff --git a/packages/component_repository/lib/src/models/models.dart b/packages/component_repository/lib/src/models/models.dart index e4e91c3..742d4a4 100644 --- a/packages/component_repository/lib/src/models/models.dart +++ b/packages/component_repository/lib/src/models/models.dart @@ -1,4 +1,5 @@ export 'component.dart'; +export 'component_anchor.dart'; export 'component_capabilities.dart'; export 'component_paint.dart'; export 'component_position.dart'; diff --git a/packages/component_repository/lib/src/models/position_component.dart b/packages/component_repository/lib/src/models/position_component.dart index b19886d..9f9052b 100644 --- a/packages/component_repository/lib/src/models/position_component.dart +++ b/packages/component_repository/lib/src/models/position_component.dart @@ -1,15 +1,16 @@ part of 'component.dart'; /// {@template position_component} -/// A plain component with a position and a size, that draws nothing itself: -/// a container for its children. +/// A plain component with a position, an anchor and a size, that draws +/// nothing itself: a container for its children. /// {@endtemplate} final class PositionComponent extends Component - implements PositionedComponent, SizedComponent { + implements PositionedComponent, AnchoredComponent, SizedComponent { /// {@macro position_component} const new({ required super.name, this.position = const ComponentPosition(), + this.anchor = ComponentAnchor.topLeft, this.size = const ComponentSize(), super.children, }); @@ -21,6 +22,7 @@ final class PositionComponent extends Component return PositionComponent( name: json['name'] as String, position: ComponentPosition.fromJson(json['position']), + anchor: ComponentAnchor.fromJson(json['anchor']), size: ComponentSize.fromJson(json['size']), ); } @@ -31,6 +33,9 @@ final class PositionComponent extends Component @override final ComponentPosition position; + @override + final ComponentAnchor anchor; + @override final ComponentSize size; @@ -39,6 +44,11 @@ final class PositionComponent extends Component return _copyWith(position: position); } + @override + PositionComponent copyWithAnchor(ComponentAnchor anchor) { + return _copyWith(anchor: anchor); + } + @override PositionComponent copyWithSize(ComponentSize size) { return _copyWith(size: size); @@ -55,12 +65,14 @@ final class PositionComponent extends Component PositionComponent _copyWith({ String? name, ComponentPosition? position, + ComponentAnchor? anchor, ComponentSize? size, List? children, }) { return PositionComponent( name: name ?? this.name, position: position ?? this.position, + anchor: anchor ?? this.anchor, size: size ?? this.size, children: children ?? this.children, ); @@ -70,9 +82,10 @@ final class PositionComponent extends Component Map toJson() => { ...super.toJson(), 'position': position.toJson(), + 'anchor': anchor.toJson(), 'size': size.toJson(), }; @override - List get props => [...super.props, position, size]; + List get props => [...super.props, position, anchor, size]; } diff --git a/packages/component_repository/lib/src/models/rectangle_component.dart b/packages/component_repository/lib/src/models/rectangle_component.dart index 7f7b3c1..8142852 100644 --- a/packages/component_repository/lib/src/models/rectangle_component.dart +++ b/packages/component_repository/lib/src/models/rectangle_component.dart @@ -1,14 +1,19 @@ part of 'component.dart'; /// {@template rectangle_component} -/// A rectangle with a position, a size and a paint. +/// A rectangle with a position, an anchor, a size and a paint. /// {@endtemplate} final class RectangleComponent extends Component - implements PositionedComponent, SizedComponent, PaintedComponent { + implements + PositionedComponent, + AnchoredComponent, + SizedComponent, + PaintedComponent { /// {@macro rectangle_component} const new({ required super.name, this.position = const ComponentPosition(), + this.anchor = ComponentAnchor.topLeft, this.size = const ComponentSize(), this.paint = const ComponentPaint(), super.children, @@ -21,6 +26,7 @@ final class RectangleComponent extends Component return RectangleComponent( name: json['name'] as String, position: ComponentPosition.fromJson(json['position']), + anchor: ComponentAnchor.fromJson(json['anchor']), size: ComponentSize.fromJson(json['size']), paint: ComponentPaint.fromJson(json['paint']), ); @@ -32,6 +38,9 @@ final class RectangleComponent extends Component @override final ComponentPosition position; + @override + final ComponentAnchor anchor; + @override final ComponentSize size; @@ -43,6 +52,11 @@ final class RectangleComponent extends Component return _copyWith(position: position); } + @override + RectangleComponent copyWithAnchor(ComponentAnchor anchor) { + return _copyWith(anchor: anchor); + } + @override RectangleComponent copyWithSize(ComponentSize size) { return _copyWith(size: size); @@ -64,6 +78,7 @@ final class RectangleComponent extends Component RectangleComponent _copyWith({ String? name, ComponentPosition? position, + ComponentAnchor? anchor, ComponentSize? size, ComponentPaint? paint, List? children, @@ -71,6 +86,7 @@ final class RectangleComponent extends Component return RectangleComponent( name: name ?? this.name, position: position ?? this.position, + anchor: anchor ?? this.anchor, size: size ?? this.size, paint: paint ?? this.paint, children: children ?? this.children, @@ -81,10 +97,11 @@ final class RectangleComponent extends Component Map toJson() => { ...super.toJson(), 'position': position.toJson(), + 'anchor': anchor.toJson(), 'size': size.toJson(), 'paint': paint.toJson(), }; @override - List get props => [...super.props, position, size, paint]; + List get props => [...super.props, position, anchor, size, paint]; } diff --git a/packages/component_repository/test/src/code_generation/component_code_generator_test.dart b/packages/component_repository/test/src/code_generation/component_code_generator_test.dart index 0a1c0ea..da84dec 100644 --- a/packages/component_repository/test/src/code_generation/component_code_generator_test.dart +++ b/packages/component_repository/test/src/code_generation/component_code_generator_test.dart @@ -207,6 +207,78 @@ class \$Ball extends CircleComponent { ); }); + test('generates the anchor of children, but not of the root', () { + expect( + generator.generate( + const PositionComponent( + name: 'player', + // The root anchor is set by whoever uses the component. + anchor: ComponentAnchor.center, + children: [ + PositionComponent( + name: 'head', + anchor: ComponentAnchor.bottomCenter, + children: [ + PositionComponent( + name: 'eye', + anchor: ComponentAnchor(x: 0.25, y: 1), + ), + ], + ), + PositionComponent(name: 'body'), + ], + ), + ), + equals(r''' +// GENERATED CODE - DO NOT MODIFY BY HAND. +// Generated by Flame Component Editor. + +// coverage:ignore-file +// ignore_for_file: type=lint + +import 'package:flame/components.dart'; + +/// The `player` component. +class $Player extends PositionComponent { + /// Creates a [$Player]. + $Player({super.position, super.anchor, super.priority}) + : super(size: Vector2(50, 50)) { + addAll([head, body]); + } + + /// The `head` child component. + final $PlayerHead head = $PlayerHead(); + + /// The `body` child component. + final PositionComponent body = PositionComponent( + position: Vector2(0, 0), + size: Vector2(50, 50), + ); +} + +/// The `head` child component of [$Player]. +class $PlayerHead extends PositionComponent { + /// Creates a [$PlayerHead]. + $PlayerHead() + : super( + position: Vector2(0, 0), + anchor: Anchor.bottomCenter, + size: Vector2(50, 50), + ) { + add(eye); + } + + /// The `eye` child component. + final PositionComponent eye = PositionComponent( + position: Vector2(0, 0), + anchor: Anchor(0.25, 1), + size: Vector2(50, 50), + ); +} +'''), + ); + }); + test('imports dart:ui when any node has a paint', () { expect( generator.generate( diff --git a/packages/component_repository/test/src/models/circle_component_test.dart b/packages/component_repository/test/src/models/circle_component_test.dart index 6dbe5c0..c49ce40 100644 --- a/packages/component_repository/test/src/models/circle_component_test.dart +++ b/packages/component_repository/test/src/models/circle_component_test.dart @@ -6,6 +6,7 @@ void main() { const component = CircleComponent( name: 'Player', position: ComponentPosition(x: 1, y: 2), + anchor: ComponentAnchor.center, radius: ComponentRadius(radius: 10), paint: ComponentPaint(color: 0xFF00FF00, style: PaintStyle.stroke), children: [RectangleComponent(name: 'rectangle1')], @@ -22,6 +23,7 @@ void main() { test('is a circle with a position, a radius and a paint', () { expect(component.type, equals(ComponentType.circle)); expect(component, isA()); + expect(component, isA()); expect(component, isA()); expect(component, isNot(isA())); expect(component, isA()); @@ -43,11 +45,13 @@ void main() { 'type': 'rectangle', 'children': [], 'position': {'x': 0.0, 'y': 0.0}, + 'anchor': {'x': 0.0, 'y': 0.0}, 'size': {'width': 50.0, 'height': 50.0}, 'paint': {'color': 0xFFFFFFFF, 'style': 'fill'}, }, ], 'position': {'x': 1.0, 'y': 2.0}, + 'anchor': {'x': 0.5, 'y': 0.5}, 'radius': {'radius': 10.0}, 'paint': {'color': 0xFF00FF00, 'style': 'stroke'}, }), @@ -65,6 +69,27 @@ void main() { ); }); + test('copyWithAnchor replaces only the anchor', () { + const anchor = ComponentAnchor(x: 0.25, y: 1); + final copy = component.copyWithAnchor(anchor); + + expect(copy.anchor, equals(anchor)); + expect( + copy.toJson()..remove('anchor'), + equals(component.toJson()..remove('anchor')), + ); + }); + + test('is anchored at its top left corner when saved without an ' + 'anchor', () { + final json = component.toJson()..remove('anchor'); + + expect( + (Component.fromJson(json) as AnchoredComponent).anchor, + equals(ComponentAnchor.topLeft), + ); + }); + test('copyWithRadius replaces only the radius', () { const radius = ComponentRadius(radius: 3); final copy = component.copyWithRadius(radius); diff --git a/packages/component_repository/test/src/models/component_anchor_test.dart b/packages/component_repository/test/src/models/component_anchor_test.dart new file mode 100644 index 0000000..5edb74e --- /dev/null +++ b/packages/component_repository/test/src/models/component_anchor_test.dart @@ -0,0 +1,55 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(ComponentAnchor, () { + test('top left, the default, is the origin', () { + expect(ComponentAnchor.topLeft.x, equals(0)); + expect(ComponentAnchor.topLeft.y, equals(0)); + expect( + const RectangleComponent(name: 'player').anchor, + equals(ComponentAnchor.topLeft), + ); + }); + + test('has the nine Flame anchors, row by row', () { + expect( + ComponentAnchor.presets.values, + equals([ + for (final y in [0.0, 0.5, 1.0]) + for (final x in [0.0, 0.5, 1.0]) ComponentAnchor(x: x, y: y), + ]), + ); + }); + + test('presetName is the name of the Flame anchor, if any', () { + expect(ComponentAnchor.center.presetName, equals('center')); + expect( + ComponentAnchor.center.copyWith(x: 1).presetName, + equals('centerRight'), + ); + expect(const ComponentAnchor(x: 0.25).presetName, isNull); + }); + + test('round-trips through JSON, including custom values', () { + const anchor = ComponentAnchor(x: -0.5, y: 1.25); + + expect(ComponentAnchor.fromJson(anchor.toJson()), equals(anchor)); + }); + + test('fromJson falls back to the top left corner for invalid values', () { + expect( + ComponentAnchor.fromJson(const {'x': 'a', 'y': 1}), + equals(ComponentAnchor.bottomLeft), + ); + expect(ComponentAnchor.fromJson(null), equals(ComponentAnchor.topLeft)); + }); + + test('copyWith replaces the given fields', () { + expect( + ComponentAnchor.center.copyWith(y: 1), + equals(ComponentAnchor.bottomCenter), + ); + }); + }); +} diff --git a/packages/component_repository/test/src/models/position_component_test.dart b/packages/component_repository/test/src/models/position_component_test.dart index ece796c..04784c4 100644 --- a/packages/component_repository/test/src/models/position_component_test.dart +++ b/packages/component_repository/test/src/models/position_component_test.dart @@ -6,6 +6,7 @@ void main() { const component = PositionComponent( name: 'Player', position: ComponentPosition(x: 1, y: 2), + anchor: ComponentAnchor.center, size: ComponentSize(width: 10, height: 20), children: [RectangleComponent(name: 'rectangle1')], ); @@ -25,6 +26,7 @@ void main() { test('is a position component with a position and a size', () { expect(component.type, equals(ComponentType.position)); expect(component, isA()); + expect(component, isA()); expect(component, isA()); expect(component, isNot(isA())); }); @@ -45,11 +47,13 @@ void main() { 'type': 'rectangle', 'children': [], 'position': {'x': 0.0, 'y': 0.0}, + 'anchor': {'x': 0.0, 'y': 0.0}, 'size': {'width': 50.0, 'height': 50.0}, 'paint': {'color': 0xFFFFFFFF, 'style': 'fill'}, }, ], 'position': {'x': 1.0, 'y': 2.0}, + 'anchor': {'x': 0.5, 'y': 0.5}, 'size': {'width': 10.0, 'height': 20.0}, }), ); @@ -66,6 +70,27 @@ void main() { ); }); + test('copyWithAnchor replaces only the anchor', () { + const anchor = ComponentAnchor(x: 0.25, y: 1); + final copy = component.copyWithAnchor(anchor); + + expect(copy.anchor, equals(anchor)); + expect( + copy.toJson()..remove('anchor'), + equals(component.toJson()..remove('anchor')), + ); + }); + + test('is anchored at its top left corner when saved without an ' + 'anchor', () { + final json = component.toJson()..remove('anchor'); + + expect( + (Component.fromJson(json) as AnchoredComponent).anchor, + equals(ComponentAnchor.topLeft), + ); + }); + test('copyWithSize replaces only the size', () { const size = ComponentSize(width: 1, height: 2); final copy = component.copyWithSize(size); diff --git a/packages/component_repository/test/src/models/rectangle_component_test.dart b/packages/component_repository/test/src/models/rectangle_component_test.dart index ad29f1d..8f54281 100644 --- a/packages/component_repository/test/src/models/rectangle_component_test.dart +++ b/packages/component_repository/test/src/models/rectangle_component_test.dart @@ -6,6 +6,7 @@ void main() { const component = RectangleComponent( name: 'Player', position: ComponentPosition(x: 1, y: 2), + anchor: ComponentAnchor.center, size: ComponentSize(width: 10, height: 20), paint: ComponentPaint(color: 0xFF00FF00, style: PaintStyle.stroke), children: [RectangleComponent(name: 'rectangle1')], @@ -25,6 +26,7 @@ void main() { test('is a rectangle with a position, a size and a paint', () { expect(component.type, equals(ComponentType.rectangle)); expect(component, isA()); + expect(component, isA()); expect(component, isA()); expect(component, isA()); }); @@ -45,11 +47,13 @@ void main() { 'type': 'rectangle', 'children': [], 'position': {'x': 0.0, 'y': 0.0}, + 'anchor': {'x': 0.0, 'y': 0.0}, 'size': {'width': 50.0, 'height': 50.0}, 'paint': {'color': 0xFFFFFFFF, 'style': 'fill'}, }, ], 'position': {'x': 1.0, 'y': 2.0}, + 'anchor': {'x': 0.5, 'y': 0.5}, 'size': {'width': 10.0, 'height': 20.0}, 'paint': {'color': 0xFF00FF00, 'style': 'stroke'}, }), @@ -67,6 +71,27 @@ void main() { ); }); + test('copyWithAnchor replaces only the anchor', () { + const anchor = ComponentAnchor(x: 0.25, y: 1); + final copy = component.copyWithAnchor(anchor); + + expect(copy.anchor, equals(anchor)); + expect( + copy.toJson()..remove('anchor'), + equals(component.toJson()..remove('anchor')), + ); + }); + + test('is anchored at its top left corner when saved without an ' + 'anchor', () { + final json = component.toJson()..remove('anchor'); + + expect( + (Component.fromJson(json) as AnchoredComponent).anchor, + equals(ComponentAnchor.topLeft), + ); + }); + test('copyWithSize replaces only the size', () { const size = ComponentSize(width: 1, height: 2); final copy = component.copyWithSize(size); diff --git a/test/component_editor/bloc/component_editor_bloc_test.dart b/test/component_editor/bloc/component_editor_bloc_test.dart index 444482d..6991a20 100644 --- a/test/component_editor/bloc/component_editor_bloc_test.dart +++ b/test/component_editor/bloc/component_editor_bloc_test.dart @@ -67,6 +67,22 @@ void main() { ], ); + blocTest( + 'ComponentEditorAnchorChanged updates the anchor', + build: buildBloc, + act: (bloc) => + bloc.add(const ComponentEditorAnchorChanged(ComponentAnchor.center)), + expect: () => const [ + ComponentEditorState( + component: RectangleComponent( + name: 'Player', + anchor: ComponentAnchor.center, + ), + savedComponent: component, + ), + ], + ); + group('ComponentEditorRadiusChanged', () { const radius = ComponentRadius(radius: 10); const circle = CircleComponent(name: 'Player'); diff --git a/test/component_editor/view/component_editor_page_test.dart b/test/component_editor/view/component_editor_page_test.dart index b709911..f6d6d39 100644 --- a/test/component_editor/view/component_editor_page_test.dart +++ b/test/component_editor/view/component_editor_page_test.dart @@ -310,11 +310,14 @@ void main() { verify(() => bloc.add(const ComponentEditorNodeRemoved([0]))).called(1); }); - testWidgets('hides the position of the root', (tester) async { + testWidgets('hides the position and the anchor of the root', ( + tester, + ) async { stubTree(); await tester.pumpApp(buildSubject()); expect(find.byType(PositionEditor), findsNothing); + expect(find.byType(AnchorEditor), findsNothing); expect(find.byType(SizeEditor), findsOneWidget); }); @@ -366,7 +369,13 @@ void main() { expect(find.byType(PositionEditor), findsOneWidget); expect(find.widgetWithText(TextField, '5'), findsOneWidget); - await tester.enterText(find.widgetWithText(TextField, 'X'), '-10'); + await tester.enterText( + find.descendant( + of: find.byType(PositionEditor), + matching: find.widgetWithText(TextField, 'X'), + ), + '-10', + ); await tester.testTextInput.receiveAction(TextInputAction.done); verify( @@ -377,6 +386,19 @@ void main() { ), ).called(1); }); + + testWidgets('edits the anchor of a selected child', (tester) async { + stubTree(selectedPath: [0]); + await tester.pumpApp(buildSubject()); + + await tester.tap(find.byTooltip('center')); + + verify( + () => bloc.add( + const ComponentEditorAnchorChanged(ComponentAnchor.center), + ), + ).called(1); + }); }); testWidgets('disables save without changes', (tester) async { diff --git a/test/component_editor/widgets/preview/component_preview_game_test.dart b/test/component_editor/widgets/preview/component_preview_game_test.dart index 1597063..fb1d172 100644 --- a/test/component_editor/widgets/preview/component_preview_game_test.dart +++ b/test/component_editor/widgets/preview/component_preview_game_test.dart @@ -146,6 +146,23 @@ void main() { }, ); + testWithGame( + 'anchors the children with their anchor', + () => ComponentPreviewGame( + component: component.copyWithChildren([ + child.copyWithAnchor(const ComponentAnchor(x: 0.25, y: 1)), + ]), + ), + (game) async { + await game.ready(); + + final rendered = game.preview.children + .whereType() + .single; + expect(rendered.anchor, equals(const flame.Anchor(0.25, 1))); + }, + ); + testWithGame( 'show updates the children in place', () => ComponentPreviewGame(component: tree), @@ -267,15 +284,16 @@ void main() { }); testWithGame( - 'keeps the root centered whatever its stored position', + 'keeps the root centered whatever its stored position and anchor', () => ComponentPreviewGame( - component: component.copyWithPosition( - const ComponentPosition(x: 100, y: 100), - ), + component: component + .copyWithPosition(const ComponentPosition(x: 100, y: 100)) + .copyWithAnchor(ComponentAnchor.bottomRight), ), (game) async { await game.ready(); expect(game.preview.position, equals(flame.Vector2.zero())); + expect(game.preview.anchor, equals(flame.Anchor.center)); game.show( component.copyWithPosition(const ComponentPosition(x: 7, y: 8)), diff --git a/test/component_editor/widgets/property_editors/anchor_editor_test.dart b/test/component_editor/widgets/property_editors/anchor_editor_test.dart new file mode 100644 index 0000000..a84d0e1 --- /dev/null +++ b/test/component_editor/widgets/property_editors/anchor_editor_test.dart @@ -0,0 +1,73 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/component_editor/widgets/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(AnchorEditor, () { + late List changes; + + setUp(() => changes = []); + + Future pumpSubject( + WidgetTester tester, { + ComponentAnchor value = ComponentAnchor.topLeft, + }) => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AnchorEditor(value: value, onChanged: changes.add), + ), + ), + ); + + testWidgets('has a button for every Flame anchor', (tester) async { + await pumpSubject(tester); + + for (final name in ComponentAnchor.presets.keys) { + expect(find.byTooltip(name), findsOneWidget); + } + }); + + testWidgets('selects the button of the current anchor', (tester) async { + await pumpSubject(tester, value: ComponentAnchor.bottomCenter); + + bool isSelected(String name) => tester + .widget( + find.ancestor( + of: find.byTooltip(name), + matching: find.byType(IconButton), + ), + ) + .isSelected!; + + expect(isSelected('bottomCenter'), isTrue); + expect(isSelected('topLeft'), isFalse); + }); + + testWidgets('picks a Flame anchor', (tester) async { + await pumpSubject(tester); + + await tester.tap(find.byTooltip('centerRight')); + + expect(changes, equals([ComponentAnchor.centerRight])); + }); + + testWidgets('edits a custom x', (tester) async { + await pumpSubject(tester, value: ComponentAnchor.center); + + await tester.enterText(find.widgetWithText(TextField, 'X'), '0.25'); + await tester.testTextInput.receiveAction(TextInputAction.done); + + expect(changes, equals([const ComponentAnchor(x: 0.25, y: 0.5)])); + }); + + testWidgets('edits a custom y', (tester) async { + await pumpSubject(tester, value: ComponentAnchor.center); + + await tester.enterText(find.widgetWithText(TextField, 'Y'), '-1'); + await tester.testTextInput.receiveAction(TextInputAction.done); + + expect(changes, equals([const ComponentAnchor(x: 0.5, y: -1)])); + }); + }); +}