diff --git a/CLAUDE.md b/CLAUDE.md index 117f1d5..c3ebdef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,9 +66,12 @@ three pass. (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) and `position` (a plain Flame + size, and paint with color and fill style), `position` (a plain Flame `PositionComponent`: position — children only — and size; draws nothing - itself, useful as a container). Components can have children + itself, useful as a container) and `circle` (a Flame `CircleComponent`: + position — 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 — see "Component types & shared properties" in [docs/architecture.md](docs/architecture.md) before adding a type or diff --git a/docs/architecture.md b/docs/architecture.md index 62ea559..9396f5d 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`, `ComponentPaint` | -| Capability interface | `component_repository/lib/src/models/component_capabilities.dart` | `PositionedComponent`, `SizedComponent`, `PaintedComponent` | -| Component type | `ComponentType` enum + a subclass in `component.dart`'s library (`part` file) | `RectangleComponent implements PositionedComponent, SizedComponent, PaintedComponent`, `PositionComponent implements PositionedComponent, SizedComponent` | +| 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` | | 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`, `PaintEditor`, built from `NumberField`, `ColorField`, `EnumField` | +| Editor widget (`value` + `onChanged`, bloc-agnostic) | `lib/component_editor/widgets/property_editors/` | `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`, `applyPaint`, `buildFlameComponent` | +| Flame mapping | `lib/component_editor/widgets/preview/flame_mapping.dart` | `applyPosition`, `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` @@ -198,7 +198,9 @@ only: the generated code uses a plain `PositionComponent`. The preview highlights the selected node with a magenta border while `isSelectionHighlighted` is on (the toolbar toggle; on by default, not -persisted). Only nodes with a size (`SizedComponent`) are highlighted. The +persisted). Only nodes with a size (`SizedComponent`) or a radius +(`RadiusComponent`, whose Flame size is the circle's bounding box) are +highlighted. The border is drawn by `SelectionHighlight`, a world-level overlay that targets the Flame component found with `flameNodeAt`, rather than a child of it: that keeps it above overlapping siblings and keeps the Flame children diff --git a/lib/component_editor/bloc/component_editor_bloc.dart b/lib/component_editor/bloc/component_editor_bloc.dart index f00730e..d773f8b 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(_onSizeChanged); + on(_onRadiusChanged); on(_onPaintChanged); on(_onSelectionHighlightToggled); on(_onSaveRequested); @@ -192,6 +193,18 @@ class ComponentEditorBloc }); } + void _onRadiusChanged( + ComponentEditorRadiusChanged event, + Emitter emit, + ) { + _editSelected(emit, (node) { + if (node case final RadiusComponent node) { + return node.copyWithRadius(event.radius); + } + return node; + }); + } + void _onPaintChanged( ComponentEditorPaintChanged event, Emitter emit, diff --git a/lib/component_editor/bloc/component_editor_event.dart b/lib/component_editor/bloc/component_editor_event.dart index ace751f..a3d2961 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 radius of the selected [RadiusComponent]. +final class ComponentEditorRadiusChanged extends ComponentEditorEvent { + /// Creates a [ComponentEditorRadiusChanged] event. + const new(this.radius); + + /// The new radius. + final ComponentRadius radius; + + @override + List get props => [radius]; +} + /// The user edited the paint of the selected [PaintedComponent]. final class ComponentEditorPaintChanged extends ComponentEditorEvent { /// Creates a [ComponentEditorPaintChanged] event. diff --git a/lib/component_editor/bloc/component_editor_state.dart b/lib/component_editor/bloc/component_editor_state.dart index 365ed3c..7ede112 100644 --- a/lib/component_editor/bloc/component_editor_state.dart +++ b/lib/component_editor/bloc/component_editor_state.dart @@ -76,7 +76,7 @@ final class ComponentEditorState extends Equatable { final bool isCodeOutdated; /// Whether the preview highlights the selected node, when it has a size - /// (see [SizedComponent]). + /// or a radius (see [SizedComponent] and [RadiusComponent]). final bool isSelectionHighlighted; /// Whether the component can be saved: it has changes or its code is diff --git a/lib/component_editor/widgets/component_tree_panel.dart b/lib/component_editor/widgets/component_tree_panel.dart index 2682dca..a00f3da 100644 --- a/lib/component_editor/widgets/component_tree_panel.dart +++ b/lib/component_editor/widgets/component_tree_panel.dart @@ -91,6 +91,7 @@ class ComponentTreePanel extends StatelessWidget { return switch (type) { ComponentType.rectangle => Icons.crop_square, ComponentType.position => Icons.crop_free, + ComponentType.circle => Icons.circle_outlined, }; } diff --git a/lib/component_editor/widgets/preview/component_preview_game.dart b/lib/component_editor/widgets/preview/component_preview_game.dart index 96e6612..97d9875 100644 --- a/lib/component_editor/widgets/preview/component_preview_game.dart +++ b/lib/component_editor/widgets/preview/component_preview_game.dart @@ -39,7 +39,8 @@ class ComponentPreviewGame extends FlameGame { /// Renders [component] instead of the current one, highlighting the node /// at [highlightedPath] (see [ComponentTree]), if any. /// - /// Only nodes with a size ([SizedComponent]) are highlighted. + /// Only nodes with a size or a radius ([SizedComponent], + /// [RadiusComponent]) are highlighted. /// /// The existing Flame components are updated in place, unless components /// were added, removed or changed type (see [haveSameShape]). @@ -68,8 +69,12 @@ class ComponentPreviewGame extends FlameGame { highlight.target = path != null && _component.hasNodeAt(path) && - _component.nodeAt(path) is SizedComponent + _hasBounds(_component.nodeAt(path)) ? flameNodeAt(_preview, path) : null; } + + static bool _hasBounds(Component node) { + return node is SizedComponent || node is RadiusComponent; + } } diff --git a/lib/component_editor/widgets/preview/flame_mapping.dart b/lib/component_editor/widgets/preview/flame_mapping.dart index 613ca70..d78f2b5 100644 --- a/lib/component_editor/widgets/preview/flame_mapping.dart +++ b/lib/component_editor/widgets/preview/flame_mapping.dart @@ -25,6 +25,7 @@ flame.PositionComponent _placeRoot(flame.PositionComponent root) { flame.PositionComponent _build(Component component) { final target = switch (component) { RectangleComponent() => flame.RectangleComponent(), + CircleComponent() => flame.CircleComponent(), // Outlined, since a plain PositionComponent would be invisible. PositionComponent() => PreviewPositionComponent(), }; @@ -95,6 +96,10 @@ T applyProperties( if (component case final SizedComponent sized) { applySize(target, sized.size); } + if (component case final RadiusComponent round + when target is flame.CircleComponent) { + applyRadius(target as flame.CircleComponent, round.radius); + } if (component case final PaintedComponent painted when target is flame.HasPaint) { applyPaint(target as flame.HasPaint, painted.paint); @@ -112,6 +117,11 @@ void applySize(flame.PositionComponent target, ComponentSize size) { target.size.setValues(size.width, size.height); } +/// Applies [radius] to [target], which also sets its size. +void applyRadius(flame.CircleComponent target, ComponentRadius radius) { + target.radius = radius.radius; +} + /// Applies [paint] to [target]. void applyPaint(flame.HasPaint target, ComponentPaint paint) { target.paint diff --git a/lib/component_editor/widgets/properties_panel.dart b/lib/component_editor/widgets/properties_panel.dart index 1f536ce..c901b69 100644 --- a/lib/component_editor/widgets/properties_panel.dart +++ b/lib/component_editor/widgets/properties_panel.dart @@ -58,6 +58,12 @@ class PropertiesPanel extends StatelessWidget { value: sized.size, onChanged: (size) => bloc.add(ComponentEditorSizeChanged(size)), ), + if (component case final RadiusComponent round) + RadiusEditor( + value: round.radius, + onChanged: (radius) => + bloc.add(ComponentEditorRadiusChanged(radius)), + ), if (component case final PaintedComponent painted) PaintEditor( value: painted.paint, diff --git a/lib/component_editor/widgets/property_editors/property_editors.dart b/lib/component_editor/widgets/property_editors/property_editors.dart index e4f0794..ffbe7e8 100644 --- a/lib/component_editor/widgets/property_editors/property_editors.dart +++ b/lib/component_editor/widgets/property_editors/property_editors.dart @@ -5,4 +5,5 @@ export 'number_field.dart'; export 'paint_editor.dart'; export 'position_editor.dart'; export 'property_section.dart'; +export 'radius_editor.dart'; export 'size_editor.dart'; diff --git a/lib/component_editor/widgets/property_editors/radius_editor.dart b/lib/component_editor/widgets/property_editors/radius_editor.dart new file mode 100644 index 0000000..45f668e --- /dev/null +++ b/lib/component_editor/widgets/property_editors/radius_editor.dart @@ -0,0 +1,32 @@ +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 radius_editor} +/// Edits a [ComponentRadius]. Used by every [RadiusComponent]. +/// {@endtemplate} +class RadiusEditor extends StatelessWidget { + /// {@macro radius_editor} + const new({required this.value, required this.onChanged, super.key}); + + /// The current radius. + final ComponentRadius value; + + /// Called with the edited radius. + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return PropertySection( + title: 'Radius', + children: [ + NumberField( + label: 'Radius', + value: value.radius, + onChanged: (radius) => onChanged(value.copyWith(radius: radius)), + ), + ], + ); + } +} 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 c7065a0..6394848 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 @@ -128,6 +128,7 @@ class ComponentCodeGenerator { return switch (component) { RectangleComponent() => 'RectangleComponent', PositionComponent() => 'PositionComponent', + CircleComponent() => 'CircleComponent', }; } @@ -142,6 +143,8 @@ class ComponentCodeGenerator { 'position: ${_vector(c.position.x, c.position.y)}', if (component case final SizedComponent c) 'size: ${_vector(c.size.width, c.size.height)}', + if (component case final RadiusComponent c) + 'radius: ${_number(c.radius.radius)}', if (component case final PaintedComponent c) 'paint: ${_paint(c.paint)}', ]; } diff --git a/packages/component_repository/lib/src/code_generation/dart_names.dart b/packages/component_repository/lib/src/code_generation/dart_names.dart index 0af9053..a3ca705 100644 --- a/packages/component_repository/lib/src/code_generation/dart_names.dart +++ b/packages/component_repository/lib/src/code_generation/dart_names.dart @@ -62,6 +62,7 @@ Set flameMemberNames(ComponentType type) { return switch (type) { ComponentType.rectangle => _rectangleComponentMembers, ComponentType.position => _positionComponentMembers, + ComponentType.circle => _circleComponentMembers, }; } @@ -231,3 +232,35 @@ const Set _rectangleComponentMembers = { 'tint', 'vertices', }; + +/// Public members of Flame's `CircleComponent` (Flame 2.0.0-dev.1), +/// including the inherited ones: the `PositionComponent` ones, plus those +/// of `HasPaint` and `ShapeComponent`. +const Set _circleComponentMembers = { + ..._positionComponentMembers, + 'deletePaint', + 'getAlpha', + 'getOpacity', + 'getPaint', + 'hasPaintLayers', + 'hue', + 'isSolid', + 'lineSegmentIntersections', + 'makeOpaque', + 'makeTransparent', + 'onChanged', + 'opacity', + 'opacityProviderOf', + 'opacityProviderOfList', + 'paint', + 'paintLayers', + 'paintLayersInternal', + 'radius', + 'renderShape', + 'scaledRadius', + 'setAlpha', + 'setColor', + 'setOpacity', + 'setPaint', + 'tint', +}; diff --git a/packages/component_repository/lib/src/models/circle_component.dart b/packages/component_repository/lib/src/models/circle_component.dart new file mode 100644 index 0000000..33b0695 --- /dev/null +++ b/packages/component_repository/lib/src/models/circle_component.dart @@ -0,0 +1,90 @@ +part of 'component.dart'; + +/// {@template circle_component} +/// A circle with a position, a radius and a paint. +/// {@endtemplate} +final class CircleComponent extends Component + implements PositionedComponent, RadiusComponent, PaintedComponent { + /// {@macro circle_component} + const new({ + required super.name, + this.position = const ComponentPosition(), + this.radius = const ComponentRadius(), + this.paint = const ComponentPaint(), + super.children, + }); + + /// Reads the properties of a [CircleComponent] from its JSON + /// representation. Use [Component.fromJson], which also reads the + /// children. + factory _fromJson(Map json) { + return CircleComponent( + name: json['name'] as String, + position: ComponentPosition.fromJson(json['position']), + radius: ComponentRadius.fromJson(json['radius']), + paint: ComponentPaint.fromJson(json['paint']), + ); + } + + @override + ComponentType get type => ComponentType.circle; + + @override + final ComponentPosition position; + + @override + final ComponentRadius radius; + + @override + final ComponentPaint paint; + + @override + CircleComponent copyWithPosition(ComponentPosition position) { + return _copyWith(position: position); + } + + @override + CircleComponent copyWithRadius(ComponentRadius radius) { + return _copyWith(radius: radius); + } + + @override + CircleComponent copyWithPaint(ComponentPaint paint) { + return _copyWith(paint: paint); + } + + @override + CircleComponent copyWithName(String name) => _copyWith(name: name); + + @override + CircleComponent copyWithChildren(List children) { + return _copyWith(children: children); + } + + CircleComponent _copyWith({ + String? name, + ComponentPosition? position, + ComponentRadius? radius, + ComponentPaint? paint, + List? children, + }) { + return CircleComponent( + name: name ?? this.name, + position: position ?? this.position, + radius: radius ?? this.radius, + paint: paint ?? this.paint, + children: children ?? this.children, + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'position': position.toJson(), + 'radius': radius.toJson(), + 'paint': paint.toJson(), + }; + + @override + List get props => [...super.props, position, radius, paint]; +} diff --git a/packages/component_repository/lib/src/models/component.dart b/packages/component_repository/lib/src/models/component.dart index 5b29e46..cde86d1 100644 --- a/packages/component_repository/lib/src/models/component.dart +++ b/packages/component_repository/lib/src/models/component.dart @@ -1,6 +1,7 @@ import 'package:component_repository/component_repository.dart'; import 'package:equatable/equatable.dart'; +part 'circle_component.dart'; part 'position_component.dart'; part 'rectangle_component.dart'; @@ -23,6 +24,7 @@ sealed class Component extends Equatable { return switch (type) { ComponentType.rectangle => RectangleComponent(name: name), ComponentType.position => PositionComponent(name: name), + ComponentType.circle => CircleComponent(name: name), }; } @@ -36,6 +38,7 @@ sealed class Component extends Equatable { final component = switch (type) { ComponentType.rectangle => RectangleComponent._fromJson(json), ComponentType.position => PositionComponent._fromJson(json), + ComponentType.circle => CircleComponent._fromJson(json), }; return component.copyWithChildren(_childrenFromJson(json['children'])); } diff --git a/packages/component_repository/lib/src/models/component_capabilities.dart b/packages/component_repository/lib/src/models/component_capabilities.dart index 4ed064e..2c9ad5e 100644 --- a/packages/component_repository/lib/src/models/component_capabilities.dart +++ b/packages/component_repository/lib/src/models/component_capabilities.dart @@ -33,3 +33,15 @@ abstract interface class PaintedComponent { /// Returns a copy of this component with [paint] replaced. Component copyWithPaint(ComponentPaint paint); } + +/// A [Component] that has a [ComponentRadius]. +/// +/// Implemented by round component types, such as circles, whose Flame size +/// is derived from the radius. +abstract interface class RadiusComponent { + /// The radius of the component. + ComponentRadius get radius; + + /// Returns a copy of this component with [radius] replaced. + Component copyWithRadius(ComponentRadius radius); +} diff --git a/packages/component_repository/lib/src/models/component_radius.dart b/packages/component_repository/lib/src/models/component_radius.dart new file mode 100644 index 0000000..8af3ba4 --- /dev/null +++ b/packages/component_repository/lib/src/models/component_radius.dart @@ -0,0 +1,38 @@ +import 'package:equatable/equatable.dart'; + +/// {@template component_radius} +/// The radius property shared by every round component. +/// {@endtemplate} +class ComponentRadius extends Equatable { + /// {@macro component_radius} + const new({this.radius = defaultValue}); + + /// Creates a [ComponentRadius] from its JSON representation. + /// + /// A missing or invalid value falls back to [defaultValue]. + factory fromJson(Object? json) { + final map = json is Map ? json : const {}; + final radius = map['radius']; + return ComponentRadius( + radius: radius is num && radius >= 0 ? radius.toDouble() : defaultValue, + ); + } + + /// The default [radius], so that a default circle is as big as a default + /// rectangle. + static const defaultValue = 25.0; + + /// The radius. + final double radius; + + /// Returns a copy of this radius with the given fields replaced. + ComponentRadius copyWith({double? radius}) { + return ComponentRadius(radius: radius ?? this.radius); + } + + /// Converts this radius to its JSON representation. + Map toJson() => {'radius': radius}; + + @override + List get props => [radius]; +} diff --git a/packages/component_repository/lib/src/models/component_type.dart b/packages/component_repository/lib/src/models/component_type.dart index 7f45dcb..e0f6127 100644 --- a/packages/component_repository/lib/src/models/component_type.dart +++ b/packages/component_repository/lib/src/models/component_type.dart @@ -7,7 +7,10 @@ enum ComponentType { /// A plain component that draws nothing itself, backed by Flame's /// `PositionComponent`. - position(key: 'position', label: 'Position'); + position(key: 'position', label: 'Position'), + + /// A circle, backed by Flame's `CircleComponent`. + circle(key: 'circle', label: 'Circle'); new({required this.key, required this.label}); diff --git a/packages/component_repository/lib/src/models/models.dart b/packages/component_repository/lib/src/models/models.dart index 84b0549..e4e91c3 100644 --- a/packages/component_repository/lib/src/models/models.dart +++ b/packages/component_repository/lib/src/models/models.dart @@ -2,6 +2,7 @@ export 'component.dart'; export 'component_capabilities.dart'; export 'component_paint.dart'; export 'component_position.dart'; +export 'component_radius.dart'; export 'component_size.dart'; export 'component_tree.dart'; export 'component_type.dart'; 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 16e99e2..0a1c0ea 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 @@ -166,6 +166,47 @@ class $Player extends PositionComponent { ); }); + test('generates circles with a radius instead of a size', () { + expect( + generator.generate( + const CircleComponent( + name: 'ball', + radius: ComponentRadius(radius: 10), + children: [ + CircleComponent( + name: 'core', + position: ComponentPosition(x: 1, y: 2), + radius: ComponentRadius(radius: 2.5), + paint: ComponentPaint( + color: 0xFFFF0000, + style: PaintStyle.stroke, + ), + ), + ], + ), + ), + equals(''' +$_header/// The `ball` component. +class \$Ball extends CircleComponent { + /// Creates a [\$Ball]. + \$Ball({super.position, super.anchor, super.priority}) + : super(radius: 10, paint: Paint()..color = const Color(0xFFFFFFFF)) { + add(core); + } + + /// The `core` child component. + final CircleComponent core = CircleComponent( + position: Vector2(1, 2), + radius: 2.5, + paint: Paint() + ..color = const Color(0xFFFF0000) + ..style = PaintingStyle.stroke, + ); +} +'''), + ); + }); + test('imports dart:ui when any node has a paint', () { expect( generator.generate( diff --git a/packages/component_repository/test/src/code_generation/dart_names_test.dart b/packages/component_repository/test/src/code_generation/dart_names_test.dart index 0bdbcbe..5d72c16 100644 --- a/packages/component_repository/test/src/code_generation/dart_names_test.dart +++ b/packages/component_repository/test/src/code_generation/dart_names_test.dart @@ -68,6 +68,15 @@ void main() { containsAll(['size', 'position', 'children', 'parent']), ); expect(flameMemberNames(ComponentType.position), isNot(contains('paint'))); + expect( + flameMemberNames(ComponentType.circle), + containsAll(['radius', 'paint', 'isSolid']), + ); + expect( + flameMemberNames(ComponentType.circle), + containsAll(flameMemberNames(ComponentType.position)), + ); + expect(flameMemberNames(ComponentType.circle), isNot(contains('vertices'))); expect( flameMemberNames(ComponentType.rectangle), containsAll(flameMemberNames(ComponentType.position)), diff --git a/packages/component_repository/test/src/models/circle_component_test.dart b/packages/component_repository/test/src/models/circle_component_test.dart new file mode 100644 index 0000000..6dbe5c0 --- /dev/null +++ b/packages/component_repository/test/src/models/circle_component_test.dart @@ -0,0 +1,110 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(CircleComponent, () { + const component = CircleComponent( + name: 'Player', + position: ComponentPosition(x: 1, y: 2), + radius: ComponentRadius(radius: 10), + paint: ComponentPaint(color: 0xFF00FF00, style: PaintStyle.stroke), + children: [RectangleComponent(name: 'rectangle1')], + ); + + test('supports value equality', () { + expect( + const CircleComponent(name: 'Player'), + equals(const CircleComponent(name: 'Player')), + ); + expect(component, isNot(equals(const CircleComponent(name: 'Player')))); + }); + + 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, isNot(isA())); + expect(component, isA()); + }); + + test('round-trips through JSON', () { + expect(Component.fromJson(component.toJson()), equals(component)); + }); + + test('toJson includes the type, properties and children', () { + expect( + component.toJson(), + equals({ + 'name': 'Player', + 'type': 'circle', + 'children': [ + { + 'name': 'rectangle1', + 'type': 'rectangle', + 'children': [], + 'position': {'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}, + 'radius': {'radius': 10.0}, + 'paint': {'color': 0xFF00FF00, 'style': 'stroke'}, + }), + ); + }); + + test('copyWithPosition replaces only the position', () { + const position = ComponentPosition(x: 5, y: 6); + final copy = component.copyWithPosition(position); + + expect(copy.position, equals(position)); + expect( + copy.toJson()..remove('position'), + equals(component.toJson()..remove('position')), + ); + }); + + test('copyWithRadius replaces only the radius', () { + const radius = ComponentRadius(radius: 3); + final copy = component.copyWithRadius(radius); + + expect(copy.radius, equals(radius)); + expect( + copy.toJson()..remove('radius'), + equals(component.toJson()..remove('radius')), + ); + }); + + test('copyWithPaint replaces only the paint', () { + const paint = ComponentPaint(color: 0xFF0000FF); + final copy = component.copyWithPaint(paint); + + expect(copy.paint, equals(paint)); + expect( + copy.toJson()..remove('paint'), + equals(component.toJson()..remove('paint')), + ); + }); + + test('copyWithName replaces only the name', () { + final copy = component.copyWithName('Enemy'); + + expect(copy.name, equals('Enemy')); + expect( + copy.toJson()..remove('name'), + equals(component.toJson()..remove('name')), + ); + }); + + test('copyWithChildren replaces only the children', () { + final copy = component.copyWithChildren(const []); + + expect(copy.children, isEmpty); + expect( + copy.toJson()..remove('children'), + equals(component.toJson()..remove('children')), + ); + }); + }); +} diff --git a/packages/component_repository/test/src/models/component_radius_test.dart b/packages/component_repository/test/src/models/component_radius_test.dart new file mode 100644 index 0000000..2b23db1 --- /dev/null +++ b/packages/component_repository/test/src/models/component_radius_test.dart @@ -0,0 +1,46 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(ComponentRadius, () { + test('defaults to 25', () { + expect(const ComponentRadius().radius, equals(25)); + }); + + test('round-trips through JSON', () { + const radius = ComponentRadius(radius: 12.5); + + expect(ComponentRadius.fromJson(radius.toJson()), equals(radius)); + }); + + test('reads integers', () { + expect( + ComponentRadius.fromJson(const {'radius': 10}), + equals(const ComponentRadius(radius: 10)), + ); + }); + + test('falls back to the default for missing or invalid values', () { + expect(ComponentRadius.fromJson(null), equals(const ComponentRadius())); + expect( + ComponentRadius.fromJson(const {'radius': 'big'}), + equals(const ComponentRadius()), + ); + expect( + ComponentRadius.fromJson(const {'radius': -1}), + equals(const ComponentRadius()), + ); + }); + + test('copyWith replaces the given fields', () { + expect( + const ComponentRadius().copyWith(radius: 1), + equals(const ComponentRadius(radius: 1)), + ); + expect( + const ComponentRadius(radius: 1).copyWith(), + equals(const ComponentRadius(radius: 1)), + ); + }); + }); +} diff --git a/packages/component_repository/test/src/models/component_test.dart b/packages/component_repository/test/src/models/component_test.dart index 47b3890..553be30 100644 --- a/packages/component_repository/test/src/models/component_test.dart +++ b/packages/component_repository/test/src/models/component_test.dart @@ -17,9 +17,32 @@ void main() { equals(const PositionComponent(name: 'Player')), ); }); + + test('creates a circle with default properties', () { + expect( + Component.create('Player', ComponentType.circle), + equals(const CircleComponent(name: 'Player')), + ); + }); }); group('fromJson', () { + test('reads a circle', () { + expect( + Component.fromJson(const { + 'name': 'Player', + 'type': 'circle', + 'radius': {'radius': 10.0}, + }), + equals( + const CircleComponent( + name: 'Player', + radius: ComponentRadius(radius: 10), + ), + ), + ); + }); + test('reads a rectangle', () { expect( Component.fromJson(const {'name': 'Player', 'type': 'rectangle'}), diff --git a/packages/component_repository/test/src/models/component_type_test.dart b/packages/component_repository/test/src/models/component_type_test.dart index f6e90a7..8fbb691 100644 --- a/packages/component_repository/test/src/models/component_type_test.dart +++ b/packages/component_repository/test/src/models/component_type_test.dart @@ -9,10 +9,11 @@ void main() { equals(ComponentType.rectangle), ); expect(ComponentType.fromKey('position'), equals(ComponentType.position)); + expect(ComponentType.fromKey('circle'), equals(ComponentType.circle)); }); test('fromKey returns null for unknown keys', () { - expect(ComponentType.fromKey('circle'), isNull); + expect(ComponentType.fromKey('triangle'), isNull); expect(ComponentType.fromKey(null), isNull); }); }); diff --git a/test/component_editor/bloc/component_editor_bloc_test.dart b/test/component_editor/bloc/component_editor_bloc_test.dart index d91a82e..444482d 100644 --- a/test/component_editor/bloc/component_editor_bloc_test.dart +++ b/test/component_editor/bloc/component_editor_bloc_test.dart @@ -67,6 +67,35 @@ void main() { ], ); + group('ComponentEditorRadiusChanged', () { + const radius = ComponentRadius(radius: 10); + const circle = CircleComponent(name: 'Player'); + + blocTest( + 'updates the radius of a circle', + build: () => ComponentEditorBloc( + project: project, + component: circle, + componentRepository: componentRepository, + projectRepository: projectRepository, + ), + act: (bloc) => bloc.add(const ComponentEditorRadiusChanged(radius)), + expect: () => const [ + ComponentEditorState( + component: CircleComponent(name: 'Player', radius: radius), + savedComponent: circle, + ), + ], + ); + + blocTest( + 'ignores components without a radius', + build: buildBloc, + act: (bloc) => bloc.add(const ComponentEditorRadiusChanged(radius)), + expect: () => const [], + ); + }); + blocTest( 'ComponentEditorPaintChanged updates the paint', build: buildBloc, diff --git a/test/component_editor/view/component_editor_page_test.dart b/test/component_editor/view/component_editor_page_test.dart index 110d858..b709911 100644 --- a/test/component_editor/view/component_editor_page_test.dart +++ b/test/component_editor/view/component_editor_page_test.dart @@ -192,6 +192,25 @@ void main() { ).called(1); }); + testWidgets('edits the radius of circles', (tester) async { + const circle = CircleComponent(name: 'Player'); + when(() => bloc.state).thenReturn( + const ComponentEditorState(component: circle, savedComponent: circle), + ); + await tester.pumpApp(buildSubject()); + + expect(find.byType(SizeEditor), findsNothing); + expect(find.byType(PaintEditor), findsOneWidget); + await tester.enterText(find.widgetWithText(TextField, 'Radius'), '10'); + await tester.testTextInput.receiveAction(TextInputAction.done); + + verify( + () => bloc.add( + const ComponentEditorRadiusChanged(ComponentRadius(radius: 10)), + ), + ).called(1); + }); + testWidgets('dispatches paint changes', (tester) async { await tester.pumpApp(buildSubject()); 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 7ad724b..1597063 100644 --- a/test/component_editor/widgets/preview/component_preview_game_test.dart +++ b/test/component_editor/widgets/preview/component_preview_game_test.dart @@ -80,6 +80,48 @@ void main() { }, ); + testWithGame( + 'renders circles with their radius and paint', + () => ComponentPreviewGame( + component: const CircleComponent( + name: 'Player', + radius: ComponentRadius(radius: 10), + paint: ComponentPaint(color: 0xFF00FF00), + children: [ + CircleComponent( + name: 'circle1', + position: ComponentPosition(x: 5, y: 6), + radius: ComponentRadius(radius: 2), + ), + ], + ), + ), + (game) async { + await game.ready(); + + final preview = game.preview as flame.CircleComponent; + expect(preview.radius, equals(10)); + expect(preview.size, equals(flame.Vector2(20, 20))); + expect(preview.paint.color, equals(const Color(0xFF00FF00))); + final child = preview.children.single as flame.CircleComponent; + expect(child.position, equals(flame.Vector2(5, 6))); + expect(child.radius, equals(2)); + + game.show( + const CircleComponent( + name: 'Player', + radius: ComponentRadius(radius: 15), + children: [CircleComponent(name: 'circle1')], + ), + highlightedPath: [0], + ); + + expect(game.preview, same(preview)); + expect(preview.radius, equals(15)); + expect(game.highlight.target, same(child)); + }, + ); + group('with children', () { const child = RectangleComponent( name: 'rectangle1', diff --git a/test/component_editor/widgets/property_editors/radius_editor_test.dart b/test/component_editor/widgets/property_editors/radius_editor_test.dart new file mode 100644 index 0000000..fba141a --- /dev/null +++ b/test/component_editor/widgets/property_editors/radius_editor_test.dart @@ -0,0 +1,27 @@ +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(RadiusEditor, () { + testWidgets('edits the radius', (tester) async { + final changes = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RadiusEditor( + value: const ComponentRadius(radius: 10), + onChanged: changes.add, + ), + ), + ), + ); + + await tester.enterText(find.widgetWithText(TextField, 'Radius'), '30'); + await tester.testTextInput.receiveAction(TextInputAction.done); + + expect(changes, equals([const ComponentRadius(radius: 30)])); + }); + }); +}