diff --git a/.github/cspell.json b/.github/cspell.json index 96eadd1..6c2f446 100644 --- a/.github/cspell.json +++ b/.github/cspell.json @@ -28,6 +28,7 @@ "cubit", "cubits", "flame", + "LTWH", "mocktail", "Zanardo" ] diff --git a/CLAUDE.md b/CLAUDE.md index 2f2e7f6..cb96944 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 and anchor — edited only on +- Component types: `rectangle` (position, anchor and angle — 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 + `PositionComponent`: position, anchor and angle — children only — and size; draws nothing itself, useful as a container) and `circle` (a Flame `CircleComponent`: - position and anchor — children only —, radius, and paint; the radius is its own + position, anchor and angle — 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 321b91a..f32a56d 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`, `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` | +| Value object (Equatable, JSON, defaults) | `component_repository/lib/src/models/` | `ComponentPosition`, `ComponentAnchor`, `ComponentAngle`, `ComponentSize`, `ComponentRadius`, `ComponentPaint` | +| Capability interface | `component_repository/lib/src/models/component_capabilities.dart` | `PositionedComponent`, `AnchoredComponent`, `RotatedComponent`, `SizedComponent`, `RadiusComponent`, `PaintedComponent` | +| Component type | `ComponentType` enum + a subclass in `component.dart`'s library (`part` file) | `RectangleComponent implements PositionedComponent, AnchoredComponent, RotatedComponent, SizedComponent, PaintedComponent`, `PositionComponent implements PositionedComponent, AnchoredComponent, RotatedComponent, SizedComponent`, `CircleComponent implements PositionedComponent, AnchoredComponent, RotatedComponent, 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/` | `PositionEditor`, `AnchorEditor`, `SizeEditor`, `RadiusEditor`, `PaintEditor`, built from `NumberField`, `ColorField`, `EnumField` | +| Editor widget (`value` + `onChanged`, bloc-agnostic) | `lib/component_editor/widgets/property_editors/` | `PositionEditor`, `AnchorEditor`, `AngleEditor`, `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`, `applyAnchor`, `applySize`, `applyRadius`, `applyPaint`, `buildFlameComponent` | +| Flame mapping | `lib/component_editor/widgets/preview/flame_mapping.dart` | `applyPosition`, `applyAnchor`, `applyAngle`, `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,12 +184,15 @@ 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 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` +The position, the anchor and the angle 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 and no +rotation (its stored position, anchor and angle 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 angle +(`ComponentAngle`, stored in radians like Flame, edited in degrees by +`AngleEditor`) and are positioned relative to their parent. The selection +highlight is rotated with the selected node. `ComponentPreviewGame.show` updates the Flame components in place while the tree shape is unchanged (`haveSameShape`) and rebuilds them otherwise. @@ -242,12 +245,15 @@ member lists in `dart_names.dart` (`flameMemberNames`). It generates: with the `$` prefix of generated code (`player_ship` → `$PlayerShip`), extending its Flame class (games subclass it: `class PlayerShip extends $PlayerShip`), with a - `{super.position, super.anchor, super.priority}` constructor (the root's - stored position is ignored, like in the preview); + `{super.position, super.angle, super.anchor, super.priority}` constructor + (the root's stored position, angle and anchor are 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); +- the children's angle as an `angle:` argument in radians, omitted when it + is `0`; - **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; @@ -314,7 +320,7 @@ before creating them. | Layer | Candidate | Responsibility | | --- | --- | --- | -| Repository | `component_repository` (exists) | More component types and properties (position, anchor, angle, children…), undo/redo-friendly immutable models | +| Repository | `component_repository` (exists) | More component types and properties (scale, sprites…), undo/redo-friendly immutable models | | Bloc | `component_tree/`, `canvas/` | Selection, canvas interactions | | Presentation | same features | Desktop layout: tree panel, Flame canvas preview (`GameWidget`), inspector panel | diff --git a/lib/component_editor/bloc/component_editor_bloc.dart b/lib/component_editor/bloc/component_editor_bloc.dart index f77e9eb..fce1c19 100644 --- a/lib/component_editor/bloc/component_editor_bloc.dart +++ b/lib/component_editor/bloc/component_editor_bloc.dart @@ -36,6 +36,7 @@ class ComponentEditorBloc on(_onNameChanged); on(_onPositionChanged); on(_onAnchorChanged); + on(_onAngleChanged); on(_onSizeChanged); on(_onRadiusChanged); on(_onPaintChanged); @@ -194,6 +195,18 @@ class ComponentEditorBloc }); } + void _onAngleChanged( + ComponentEditorAngleChanged event, + Emitter emit, + ) { + _editSelected(emit, (node) { + if (node case final RotatedComponent node) { + return node.copyWithAngle(event.angle); + } + 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 c5e6489..490ba3f 100644 --- a/lib/component_editor/bloc/component_editor_event.dart +++ b/lib/component_editor/bloc/component_editor_event.dart @@ -104,6 +104,18 @@ final class ComponentEditorAnchorChanged extends ComponentEditorEvent { List get props => [anchor]; } +/// The user edited the angle of the selected [RotatedComponent]. +final class ComponentEditorAngleChanged extends ComponentEditorEvent { + /// Creates a [ComponentEditorAngleChanged] event. + const new(this.angle); + + /// The new angle. + final ComponentAngle angle; + + @override + List get props => [angle]; +} + /// 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 47b670e..456bec8 100644 --- a/lib/component_editor/widgets/preview/flame_mapping.dart +++ b/lib/component_editor/widgets/preview/flame_mapping.dart @@ -8,9 +8,10 @@ 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 use their own anchor (Flame's -/// default top left one unless edited) and are positioned relative to their -/// parent, like in the generated code. +/// is placed by whoever uses it, and it is not rotated for the same reason. +/// Children use their own anchor (Flame's default top left one unless +/// edited) and angle, and are positioned relative to their parent, like in +/// the generated code. flame.PositionComponent buildFlameComponent(Component component) { return _placeRoot(_build(component)); } @@ -18,6 +19,7 @@ flame.PositionComponent buildFlameComponent(Component component) { flame.PositionComponent _placeRoot(flame.PositionComponent root) { root ..anchor = flame.Anchor.center + ..angle = 0 ..position.setZero(); return root; } @@ -96,6 +98,9 @@ T applyProperties( if (component case final AnchoredComponent anchored) { applyAnchor(target, anchored.anchor); } + if (component case final RotatedComponent rotated) { + applyAngle(target, rotated.angle); + } if (component case final SizedComponent sized) { applySize(target, sized.size); } @@ -120,6 +125,11 @@ void applyAnchor(flame.PositionComponent target, ComponentAnchor anchor) { target.anchor = flame.Anchor(anchor.x, anchor.y); } +/// Applies [angle] to [target]. +void applyAngle(flame.PositionComponent target, ComponentAngle angle) { + target.angle = angle.radians; +} + /// 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/preview/selection_highlight.dart b/lib/component_editor/widgets/preview/selection_highlight.dart index 059aaed..0813179 100644 --- a/lib/component_editor/widgets/preview/selection_highlight.dart +++ b/lib/component_editor/widgets/preview/selection_highlight.dart @@ -31,7 +31,19 @@ class SelectionHighlight extends flame.Component { void render(Canvas canvas) { final target = this.target; if (target == null || !target.isMounted) return; - // Drawn outside of the component, so it doesn't hide its own edges. - canvas.drawRect(target.toAbsoluteRect().inflate(strokeWidth / 2), _paint); + // Rotated with the component (and its ancestors), around its top left + // corner. + final topLeft = target.absoluteTopLeftPosition; + final size = target.absoluteScaledSize; + canvas + ..save() + ..translate(topLeft.x, topLeft.y) + ..rotate(target.absoluteAngle) + // Drawn outside of the component, so it doesn't hide its own edges. + ..drawRect( + Rect.fromLTWH(0, 0, size.x, size.y).inflate(strokeWidth / 2), + _paint, + ) + ..restore(); } } diff --git a/lib/component_editor/widgets/properties_panel.dart b/lib/component_editor/widgets/properties_panel.dart index 78916e3..9dcdf8b 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, 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. +/// component type gets them without changes here. The name, the position, +/// the anchor and the angle 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} @@ -59,6 +59,11 @@ class PropertiesPanel extends StatelessWidget { onChanged: (anchor) => bloc.add(ComponentEditorAnchorChanged(anchor)), ), + if (component case final RotatedComponent rotated when !isRoot) + AngleEditor( + value: rotated.angle, + onChanged: (angle) => bloc.add(ComponentEditorAngleChanged(angle)), + ), if (component case final SizedComponent sized) SizeEditor( value: sized.size, diff --git a/lib/component_editor/widgets/property_editors/angle_editor.dart b/lib/component_editor/widgets/property_editors/angle_editor.dart new file mode 100644 index 0000000..6811065 --- /dev/null +++ b/lib/component_editor/widgets/property_editors/angle_editor.dart @@ -0,0 +1,37 @@ +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 angle_editor} +/// Edits a [ComponentAngle], in degrees. Used by every [RotatedComponent] +/// inside another component. +/// {@endtemplate} +class AngleEditor extends StatelessWidget { + /// {@macro angle_editor} + const new({required this.value, required this.onChanged, super.key}); + + /// The current angle. + final ComponentAngle value; + + /// Called with the edited angle. + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return PropertySection( + title: 'Angle', + children: [ + NumberField( + label: 'Degrees', + // Rounded, so that converting the typed degrees to radians and + // back gives the same number, and the field keeps the user input. + value: double.parse(value.degrees.toStringAsFixed(6)), + min: null, + onChanged: (degrees) => + onChanged(ComponentAngle.fromDegrees(degrees)), + ), + ], + ); + } +} diff --git a/lib/component_editor/widgets/property_editors/property_editors.dart b/lib/component_editor/widgets/property_editors/property_editors.dart index 1854491..26d50a3 100644 --- a/lib/component_editor/widgets/property_editors/property_editors.dart +++ b/lib/component_editor/widgets/property_editors/property_editors.dart @@ -1,4 +1,5 @@ export 'anchor_editor.dart'; +export 'angle_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 14f897b..42139d8 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 @@ -61,7 +61,7 @@ class ComponentCodeGenerator { rootName: component.name, className: rootClass, doc: 'The `${component.name}` component.', - parameters: '{super.position, super.anchor, super.priority}', + parameters: '{super.position, super.angle, super.anchor, super.priority}', arguments: _arguments(component, withPosition: false), ); for (final node in component.nodes.skip(1)) { @@ -133,7 +133,7 @@ class ComponentCodeGenerator { } /// The arguments of the Flame constructor, one per property. The root - /// position and anchor are left to whoever uses the component. + /// position, angle and anchor are left to whoever uses the component. static List _arguments( Component component, { required bool withPosition, @@ -141,7 +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. + // Flame already defaults to no rotation and the top left anchor. + if (component case final RotatedComponent c + when withPosition && c.angle != ComponentAngle.zero) + 'angle: ${_number(c.angle.radians)}', if (component case final AnchoredComponent c when withPosition && c.anchor != ComponentAnchor.topLeft) 'anchor: ${_anchor(c.anchor)}', diff --git a/packages/component_repository/lib/src/models/circle_component.dart b/packages/component_repository/lib/src/models/circle_component.dart index c8a4f6b..80023c8 100644 --- a/packages/component_repository/lib/src/models/circle_component.dart +++ b/packages/component_repository/lib/src/models/circle_component.dart @@ -1,12 +1,13 @@ part of 'component.dart'; /// {@template circle_component} -/// A circle with a position, an anchor, a radius and a paint. +/// A circle with a position, an anchor, an angle, a radius and a paint. /// {@endtemplate} final class CircleComponent extends Component implements PositionedComponent, AnchoredComponent, + RotatedComponent, RadiusComponent, PaintedComponent { /// {@macro circle_component} @@ -14,6 +15,7 @@ final class CircleComponent extends Component required super.name, this.position = const ComponentPosition(), this.anchor = ComponentAnchor.topLeft, + this.angle = ComponentAngle.zero, this.radius = const ComponentRadius(), this.paint = const ComponentPaint(), super.children, @@ -27,6 +29,7 @@ final class CircleComponent extends Component name: json['name'] as String, position: ComponentPosition.fromJson(json['position']), anchor: ComponentAnchor.fromJson(json['anchor']), + angle: ComponentAngle.fromJson(json['angle']), radius: ComponentRadius.fromJson(json['radius']), paint: ComponentPaint.fromJson(json['paint']), ); @@ -41,6 +44,9 @@ final class CircleComponent extends Component @override final ComponentAnchor anchor; + @override + final ComponentAngle angle; + @override final ComponentRadius radius; @@ -57,6 +63,11 @@ final class CircleComponent extends Component return _copyWith(anchor: anchor); } + @override + CircleComponent copyWithAngle(ComponentAngle angle) { + return _copyWith(angle: angle); + } + @override CircleComponent copyWithRadius(ComponentRadius radius) { return _copyWith(radius: radius); @@ -79,6 +90,7 @@ final class CircleComponent extends Component String? name, ComponentPosition? position, ComponentAnchor? anchor, + ComponentAngle? angle, ComponentRadius? radius, ComponentPaint? paint, List? children, @@ -87,6 +99,7 @@ final class CircleComponent extends Component name: name ?? this.name, position: position ?? this.position, anchor: anchor ?? this.anchor, + angle: angle ?? this.angle, radius: radius ?? this.radius, paint: paint ?? this.paint, children: children ?? this.children, @@ -98,10 +111,18 @@ final class CircleComponent extends Component ...super.toJson(), 'position': position.toJson(), 'anchor': anchor.toJson(), + 'angle': angle.toJson(), 'radius': radius.toJson(), 'paint': paint.toJson(), }; @override - List get props => [...super.props, position, anchor, radius, paint]; + List get props => [ + ...super.props, + position, + anchor, + angle, + radius, + paint, + ]; } diff --git a/packages/component_repository/lib/src/models/component_angle.dart b/packages/component_repository/lib/src/models/component_angle.dart new file mode 100644 index 0000000..8d85ba5 --- /dev/null +++ b/packages/component_repository/lib/src/models/component_angle.dart @@ -0,0 +1,49 @@ +import 'dart:math' as math; + +import 'package:equatable/equatable.dart'; + +/// {@template component_angle} +/// The angle property shared by every position based component. +/// +/// Mirrors Flame's `angle`: the rotation of the component around its +/// anchor, in radians, clockwise. The editor shows it in [degrees]. +/// {@endtemplate} +class ComponentAngle extends Equatable { + /// {@macro component_angle} + const new({this.radians = 0}); + + /// Creates a [ComponentAngle] of [degrees]. + factory fromDegrees(double degrees) { + return ComponentAngle(radians: degrees * math.pi / 180); + } + + /// Creates a [ComponentAngle] from its JSON representation. + /// + /// A missing or invalid value falls back to `0`, so components saved + /// without an angle are not rotated. + factory fromJson(Object? json) { + final map = json is Map ? json : const {}; + final radians = map['radians']; + return ComponentAngle(radians: radians is num ? radians.toDouble() : 0); + } + + /// No rotation, Flame's default angle. + static const zero = ComponentAngle(); + + /// The angle, in radians. + final double radians; + + /// The angle, in degrees. + double get degrees => radians * 180 / math.pi; + + /// Returns a copy of this angle with the given fields replaced. + ComponentAngle copyWith({double? radians}) { + return ComponentAngle(radians: radians ?? this.radians); + } + + /// Converts this angle to its JSON representation. + Map toJson() => {'radians': radians}; + + @override + List get props => [radians]; +} diff --git a/packages/component_repository/lib/src/models/component_capabilities.dart b/packages/component_repository/lib/src/models/component_capabilities.dart index 2615a59..1a6ef03 100644 --- a/packages/component_repository/lib/src/models/component_capabilities.dart +++ b/packages/component_repository/lib/src/models/component_capabilities.dart @@ -26,6 +26,19 @@ abstract interface class AnchoredComponent { Component copyWithAnchor(ComponentAnchor anchor); } +/// A [Component] that has a [ComponentAngle]. +/// +/// Implemented by every component type backed by a Flame +/// `PositionComponent`. Like the position, the angle is only edited on +/// child components: the root is placed by whoever uses it. +abstract interface class RotatedComponent { + /// The rotation of the component around its anchor. + ComponentAngle get angle; + + /// Returns a copy of this component with [angle] replaced. + Component copyWithAngle(ComponentAngle angle); +} + /// 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 742d4a4..7d586c9 100644 --- a/packages/component_repository/lib/src/models/models.dart +++ b/packages/component_repository/lib/src/models/models.dart @@ -1,5 +1,6 @@ export 'component.dart'; export 'component_anchor.dart'; +export 'component_angle.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 9f9052b..a5397ad 100644 --- a/packages/component_repository/lib/src/models/position_component.dart +++ b/packages/component_repository/lib/src/models/position_component.dart @@ -5,12 +5,17 @@ part of 'component.dart'; /// nothing itself: a container for its children. /// {@endtemplate} final class PositionComponent extends Component - implements PositionedComponent, AnchoredComponent, SizedComponent { + implements + PositionedComponent, + AnchoredComponent, + RotatedComponent, + SizedComponent { /// {@macro position_component} const new({ required super.name, this.position = const ComponentPosition(), this.anchor = ComponentAnchor.topLeft, + this.angle = ComponentAngle.zero, this.size = const ComponentSize(), super.children, }); @@ -23,6 +28,7 @@ final class PositionComponent extends Component name: json['name'] as String, position: ComponentPosition.fromJson(json['position']), anchor: ComponentAnchor.fromJson(json['anchor']), + angle: ComponentAngle.fromJson(json['angle']), size: ComponentSize.fromJson(json['size']), ); } @@ -36,6 +42,9 @@ final class PositionComponent extends Component @override final ComponentAnchor anchor; + @override + final ComponentAngle angle; + @override final ComponentSize size; @@ -49,6 +58,11 @@ final class PositionComponent extends Component return _copyWith(anchor: anchor); } + @override + PositionComponent copyWithAngle(ComponentAngle angle) { + return _copyWith(angle: angle); + } + @override PositionComponent copyWithSize(ComponentSize size) { return _copyWith(size: size); @@ -66,6 +80,7 @@ final class PositionComponent extends Component String? name, ComponentPosition? position, ComponentAnchor? anchor, + ComponentAngle? angle, ComponentSize? size, List? children, }) { @@ -73,6 +88,7 @@ final class PositionComponent extends Component name: name ?? this.name, position: position ?? this.position, anchor: anchor ?? this.anchor, + angle: angle ?? this.angle, size: size ?? this.size, children: children ?? this.children, ); @@ -83,9 +99,10 @@ final class PositionComponent extends Component ...super.toJson(), 'position': position.toJson(), 'anchor': anchor.toJson(), + 'angle': angle.toJson(), 'size': size.toJson(), }; @override - List get props => [...super.props, position, anchor, size]; + List get props => [...super.props, position, anchor, angle, size]; } diff --git a/packages/component_repository/lib/src/models/rectangle_component.dart b/packages/component_repository/lib/src/models/rectangle_component.dart index 8142852..a810d9d 100644 --- a/packages/component_repository/lib/src/models/rectangle_component.dart +++ b/packages/component_repository/lib/src/models/rectangle_component.dart @@ -1,12 +1,13 @@ part of 'component.dart'; /// {@template rectangle_component} -/// A rectangle with a position, an anchor, a size and a paint. +/// A rectangle with a position, an anchor, an angle, a size and a paint. /// {@endtemplate} final class RectangleComponent extends Component implements PositionedComponent, AnchoredComponent, + RotatedComponent, SizedComponent, PaintedComponent { /// {@macro rectangle_component} @@ -14,6 +15,7 @@ final class RectangleComponent extends Component required super.name, this.position = const ComponentPosition(), this.anchor = ComponentAnchor.topLeft, + this.angle = ComponentAngle.zero, this.size = const ComponentSize(), this.paint = const ComponentPaint(), super.children, @@ -27,6 +29,7 @@ final class RectangleComponent extends Component name: json['name'] as String, position: ComponentPosition.fromJson(json['position']), anchor: ComponentAnchor.fromJson(json['anchor']), + angle: ComponentAngle.fromJson(json['angle']), size: ComponentSize.fromJson(json['size']), paint: ComponentPaint.fromJson(json['paint']), ); @@ -41,6 +44,9 @@ final class RectangleComponent extends Component @override final ComponentAnchor anchor; + @override + final ComponentAngle angle; + @override final ComponentSize size; @@ -57,6 +63,11 @@ final class RectangleComponent extends Component return _copyWith(anchor: anchor); } + @override + RectangleComponent copyWithAngle(ComponentAngle angle) { + return _copyWith(angle: angle); + } + @override RectangleComponent copyWithSize(ComponentSize size) { return _copyWith(size: size); @@ -79,6 +90,7 @@ final class RectangleComponent extends Component String? name, ComponentPosition? position, ComponentAnchor? anchor, + ComponentAngle? angle, ComponentSize? size, ComponentPaint? paint, List? children, @@ -87,6 +99,7 @@ final class RectangleComponent extends Component name: name ?? this.name, position: position ?? this.position, anchor: anchor ?? this.anchor, + angle: angle ?? this.angle, size: size ?? this.size, paint: paint ?? this.paint, children: children ?? this.children, @@ -98,10 +111,18 @@ final class RectangleComponent extends Component ...super.toJson(), 'position': position.toJson(), 'anchor': anchor.toJson(), + 'angle': angle.toJson(), 'size': size.toJson(), 'paint': paint.toJson(), }; @override - List get props => [...super.props, position, anchor, size, paint]; + List get props => [ + ...super.props, + position, + anchor, + angle, + 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 da84dec..868e55d 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 @@ -39,7 +39,7 @@ void main() { $_header/// The `player` component. class \$Player extends RectangleComponent { /// Creates a [\$Player]. - \$Player({super.position, super.anchor, super.priority}) + \$Player({super.position, super.angle, super.anchor, super.priority}) : super( size: Vector2(10, 20.5), paint: Paint()..color = const Color(0xFFFFFFFF), @@ -81,7 +81,7 @@ class \$Player extends RectangleComponent { $_header/// The `player` component. class \$Player extends RectangleComponent { /// Creates a [\$Player]. - \$Player({super.position, super.anchor, super.priority}) + \$Player({super.position, super.angle, super.anchor, super.priority}) : super( size: Vector2(50, 50), paint: Paint()..color = const Color(0xFFFFFFFF), @@ -151,7 +151,7 @@ import 'package:flame/components.dart'; /// The `player` component. class $Player extends PositionComponent { /// Creates a [$Player]. - $Player({super.position, super.anchor, super.priority}) + $Player({super.position, super.angle, super.anchor, super.priority}) : super(size: Vector2(10, 20)) { add(body); } @@ -189,7 +189,7 @@ class $Player extends PositionComponent { $_header/// The `ball` component. class \$Ball extends CircleComponent { /// Creates a [\$Ball]. - \$Ball({super.position, super.anchor, super.priority}) + \$Ball({super.position, super.angle, super.anchor, super.priority}) : super(radius: 10, paint: Paint()..color = const Color(0xFFFFFFFF)) { add(core); } @@ -207,6 +207,74 @@ class \$Ball extends CircleComponent { ); }); + test('generates the angle of children, but not of the root', () { + expect( + generator.generate( + const PositionComponent( + name: 'player', + // The root angle is set by whoever uses the component. + angle: ComponentAngle(radians: 1), + children: [ + PositionComponent( + name: 'head', + angle: ComponentAngle(radians: 0.5), + children: [ + PositionComponent( + name: 'eye', + angle: ComponentAngle(radians: 2), + ), + ], + ), + 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.angle, 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), angle: 0.5, size: Vector2(50, 50)) { + add(eye); + } + + /// The `eye` child component. + final PositionComponent eye = PositionComponent( + position: Vector2(0, 0), + angle: 2, + size: Vector2(50, 50), + ); +} +'''), + ); + }); + test('generates the anchor of children, but not of the root', () { expect( generator.generate( @@ -241,7 +309,7 @@ import 'package:flame/components.dart'; /// The `player` component. class $Player extends PositionComponent { /// Creates a [$Player]. - $Player({super.position, super.anchor, super.priority}) + $Player({super.position, super.angle, super.anchor, super.priority}) : super(size: Vector2(50, 50)) { addAll([head, body]); } 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 c49ce40..23dae01 100644 --- a/packages/component_repository/test/src/models/circle_component_test.dart +++ b/packages/component_repository/test/src/models/circle_component_test.dart @@ -7,6 +7,7 @@ void main() { name: 'Player', position: ComponentPosition(x: 1, y: 2), anchor: ComponentAnchor.center, + angle: ComponentAngle(radians: 1.5), radius: ComponentRadius(radius: 10), paint: ComponentPaint(color: 0xFF00FF00, style: PaintStyle.stroke), children: [RectangleComponent(name: 'rectangle1')], @@ -24,6 +25,7 @@ void main() { expect(component.type, equals(ComponentType.circle)); expect(component, isA()); expect(component, isA()); + expect(component, isA()); expect(component, isA()); expect(component, isNot(isA())); expect(component, isA()); @@ -46,12 +48,14 @@ void main() { 'children': [], 'position': {'x': 0.0, 'y': 0.0}, 'anchor': {'x': 0.0, 'y': 0.0}, + 'angle': {'radians': 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}, + 'angle': {'radians': 1.5}, 'radius': {'radius': 10.0}, 'paint': {'color': 0xFF00FF00, 'style': 'stroke'}, }), @@ -90,6 +94,26 @@ void main() { ); }); + test('copyWithAngle replaces only the angle', () { + const angle = ComponentAngle(radians: 3); + final copy = component.copyWithAngle(angle); + + expect(copy.angle, equals(angle)); + expect( + copy.toJson()..remove('angle'), + equals(component.toJson()..remove('angle')), + ); + }); + + test('is not rotated when saved without an angle', () { + final json = component.toJson()..remove('angle'); + + expect( + (Component.fromJson(json) as RotatedComponent).angle, + equals(ComponentAngle.zero), + ); + }); + 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_angle_test.dart b/packages/component_repository/test/src/models/component_angle_test.dart new file mode 100644 index 0000000..31cb15e --- /dev/null +++ b/packages/component_repository/test/src/models/component_angle_test.dart @@ -0,0 +1,66 @@ +import 'dart:math' as math; + +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(ComponentAngle, () { + test('defaults to no rotation', () { + expect(ComponentAngle.zero.radians, equals(0)); + expect(ComponentAngle.zero.degrees, equals(0)); + }); + + test('supports value equality', () { + expect( + const ComponentAngle(radians: 1), + equals(const ComponentAngle(radians: 1)), + ); + expect( + const ComponentAngle(radians: 1), + isNot(equals(const ComponentAngle(radians: 2))), + ); + }); + + test('converts between degrees and radians', () { + expect(ComponentAngle.fromDegrees(180).radians, equals(math.pi)); + expect(const ComponentAngle(radians: math.pi / 2).degrees, equals(90)); + }); + + test('round-trips through JSON', () { + const angle = ComponentAngle(radians: 1.5); + + expect(ComponentAngle.fromJson(angle.toJson()), equals(angle)); + expect(angle.toJson(), equals({'radians': 1.5})); + }); + + test('falls back to no rotation for missing or invalid values', () { + expect(ComponentAngle.fromJson(null), equals(ComponentAngle.zero)); + expect( + ComponentAngle.fromJson(const {}), + equals(ComponentAngle.zero), + ); + expect( + ComponentAngle.fromJson(const {'radians': 'a lot'}), + equals(ComponentAngle.zero), + ); + }); + + test('accepts integer values', () { + expect( + ComponentAngle.fromJson(const {'radians': 2}), + equals(const ComponentAngle(radians: 2)), + ); + }); + + test('copyWith replaces the given fields', () { + expect( + const ComponentAngle(radians: 1).copyWith(radians: 2), + equals(const ComponentAngle(radians: 2)), + ); + expect( + const ComponentAngle(radians: 1).copyWith(), + equals(const ComponentAngle(radians: 1)), + ); + }); + }); +} 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 04784c4..8c18871 100644 --- a/packages/component_repository/test/src/models/position_component_test.dart +++ b/packages/component_repository/test/src/models/position_component_test.dart @@ -7,6 +7,7 @@ void main() { name: 'Player', position: ComponentPosition(x: 1, y: 2), anchor: ComponentAnchor.center, + angle: ComponentAngle(radians: 1.5), size: ComponentSize(width: 10, height: 20), children: [RectangleComponent(name: 'rectangle1')], ); @@ -27,6 +28,7 @@ void main() { expect(component.type, equals(ComponentType.position)); expect(component, isA()); expect(component, isA()); + expect(component, isA()); expect(component, isA()); expect(component, isNot(isA())); }); @@ -48,12 +50,14 @@ void main() { 'children': [], 'position': {'x': 0.0, 'y': 0.0}, 'anchor': {'x': 0.0, 'y': 0.0}, + 'angle': {'radians': 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}, + 'angle': {'radians': 1.5}, 'size': {'width': 10.0, 'height': 20.0}, }), ); @@ -91,6 +95,26 @@ void main() { ); }); + test('copyWithAngle replaces only the angle', () { + const angle = ComponentAngle(radians: 3); + final copy = component.copyWithAngle(angle); + + expect(copy.angle, equals(angle)); + expect( + copy.toJson()..remove('angle'), + equals(component.toJson()..remove('angle')), + ); + }); + + test('is not rotated when saved without an angle', () { + final json = component.toJson()..remove('angle'); + + expect( + (Component.fromJson(json) as RotatedComponent).angle, + equals(ComponentAngle.zero), + ); + }); + 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 8f54281..3f00b8e 100644 --- a/packages/component_repository/test/src/models/rectangle_component_test.dart +++ b/packages/component_repository/test/src/models/rectangle_component_test.dart @@ -7,6 +7,7 @@ void main() { name: 'Player', position: ComponentPosition(x: 1, y: 2), anchor: ComponentAnchor.center, + angle: ComponentAngle(radians: 1.5), size: ComponentSize(width: 10, height: 20), paint: ComponentPaint(color: 0xFF00FF00, style: PaintStyle.stroke), children: [RectangleComponent(name: 'rectangle1')], @@ -27,6 +28,7 @@ void main() { expect(component.type, equals(ComponentType.rectangle)); expect(component, isA()); expect(component, isA()); + expect(component, isA()); expect(component, isA()); expect(component, isA()); }); @@ -48,12 +50,14 @@ void main() { 'children': [], 'position': {'x': 0.0, 'y': 0.0}, 'anchor': {'x': 0.0, 'y': 0.0}, + 'angle': {'radians': 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}, + 'angle': {'radians': 1.5}, 'size': {'width': 10.0, 'height': 20.0}, 'paint': {'color': 0xFF00FF00, 'style': 'stroke'}, }), @@ -92,6 +96,26 @@ void main() { ); }); + test('copyWithAngle replaces only the angle', () { + const angle = ComponentAngle(radians: 3); + final copy = component.copyWithAngle(angle); + + expect(copy.angle, equals(angle)); + expect( + copy.toJson()..remove('angle'), + equals(component.toJson()..remove('angle')), + ); + }); + + test('is not rotated when saved without an angle', () { + final json = component.toJson()..remove('angle'); + + expect( + (Component.fromJson(json) as RotatedComponent).angle, + equals(ComponentAngle.zero), + ); + }); + 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 6991a20..02b41a6 100644 --- a/test/component_editor/bloc/component_editor_bloc_test.dart +++ b/test/component_editor/bloc/component_editor_bloc_test.dart @@ -83,6 +83,23 @@ void main() { ], ); + blocTest( + 'ComponentEditorAngleChanged updates the angle', + build: buildBloc, + act: (bloc) => bloc.add( + const ComponentEditorAngleChanged(ComponentAngle(radians: 1)), + ), + expect: () => const [ + ComponentEditorState( + component: RectangleComponent( + name: 'Player', + angle: ComponentAngle(radians: 1), + ), + 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 f6d6d39..b6c9d70 100644 --- a/test/component_editor/view/component_editor_page_test.dart +++ b/test/component_editor/view/component_editor_page_test.dart @@ -310,7 +310,7 @@ void main() { verify(() => bloc.add(const ComponentEditorNodeRemoved([0]))).called(1); }); - testWidgets('hides the position and the anchor of the root', ( + testWidgets('hides the position, the anchor and the angle of the root', ( tester, ) async { stubTree(); @@ -318,6 +318,7 @@ void main() { expect(find.byType(PositionEditor), findsNothing); expect(find.byType(AnchorEditor), findsNothing); + expect(find.byType(AngleEditor), findsNothing); expect(find.byType(SizeEditor), findsOneWidget); }); @@ -399,6 +400,23 @@ void main() { ), ).called(1); }); + + testWidgets('edits the angle of a selected child', (tester) async { + stubTree(selectedPath: [0]); + await tester.pumpApp(buildSubject()); + + await tester.enterText( + find.widgetWithText(TextField, 'Degrees'), + '180', + ); + await tester.testTextInput.receiveAction(TextInputAction.done); + + verify( + () => bloc.add( + ComponentEditorAngleChanged(ComponentAngle.fromDegrees(180)), + ), + ).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 fb1d172..5347c9a 100644 --- a/test/component_editor/widgets/preview/component_preview_game_test.dart +++ b/test/component_editor/widgets/preview/component_preview_game_test.dart @@ -163,6 +163,23 @@ void main() { }, ); + testWithGame( + 'rotates the children with their angle', + () => ComponentPreviewGame( + component: component.copyWithChildren([ + child.copyWithAngle(const ComponentAngle(radians: 1)), + ]), + ), + (game) async { + await game.ready(); + + final rendered = game.preview.children + .whereType() + .single; + expect(rendered.angle, equals(1)); + }, + ); + testWithGame( 'show updates the children in place', () => ComponentPreviewGame(component: tree), @@ -284,16 +301,19 @@ void main() { }); testWithGame( - 'keeps the root centered whatever its stored position and anchor', + 'keeps the root centered and not rotated whatever its stored ' + 'position, anchor and angle', () => ComponentPreviewGame( component: component .copyWithPosition(const ComponentPosition(x: 100, y: 100)) - .copyWithAnchor(ComponentAnchor.bottomRight), + .copyWithAnchor(ComponentAnchor.bottomRight) + .copyWithAngle(const ComponentAngle(radians: 1)), ), (game) async { await game.ready(); expect(game.preview.position, equals(flame.Vector2.zero())); expect(game.preview.anchor, equals(flame.Anchor.center)); + expect(game.preview.angle, equals(0)); game.show( component.copyWithPosition(const ComponentPosition(x: 7, y: 8)), diff --git a/test/component_editor/widgets/property_editors/angle_editor_test.dart b/test/component_editor/widgets/property_editors/angle_editor_test.dart new file mode 100644 index 0000000..bbbbb0e --- /dev/null +++ b/test/component_editor/widgets/property_editors/angle_editor_test.dart @@ -0,0 +1,49 @@ +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(AngleEditor, () { + late List changes; + + setUp(() => changes = []); + + Future pumpSubject( + WidgetTester tester, { + ComponentAngle value = ComponentAngle.zero, + }) => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AngleEditor(value: value, onChanged: changes.add), + ), + ), + ); + + testWidgets('shows the angle in degrees', (tester) async { + await pumpSubject(tester, value: ComponentAngle.fromDegrees(45)); + + expect(find.widgetWithText(TextField, '45'), findsOneWidget); + }); + + testWidgets('edits the angle in degrees', (tester) async { + await pumpSubject(tester); + + await tester.enterText(find.widgetWithText(TextField, 'Degrees'), '-90'); + await tester.testTextInput.receiveAction(TextInputAction.done); + + expect(changes, equals([ComponentAngle.fromDegrees(-90)])); + }); + + testWidgets('keeps the typed degrees when the edited angle comes back', ( + tester, + ) async { + await pumpSubject(tester); + + await tester.enterText(find.widgetWithText(TextField, 'Degrees'), '45'); + await pumpSubject(tester, value: changes.single); + + expect(find.widgetWithText(TextField, '45'), findsOneWidget); + }); + }); +}