diff --git a/CLAUDE.md b/CLAUDE.md index 1e2167c..117f1d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,8 +65,10 @@ 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: only `rectangle` (position — edited only on children —, - size, and paint with color and fill style). Components can have children +- Component types: `rectangle` (position — edited only on children —, + size, and paint with color and fill style) and `position` (a plain Flame + `PositionComponent`: position — children only — and size; draws nothing + itself, useful as a container). 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 a8f26a4..62ea559 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -152,7 +152,7 @@ component types only declare which ones they have: | --- | --- | --- | | 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` | +| Component type | `ComponentType` enum + a subclass in `component.dart`'s library (`part` file) | `RectangleComponent implements PositionedComponent, SizedComponent, PaintedComponent`, `PositionComponent implements PositionedComponent, SizedComponent` | | 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` | | Panel wiring | `lib/component_editor/widgets/properties_panel.dart` | `if (component case SizedComponent c) SizeEditor(...)` | @@ -166,7 +166,7 @@ reads the children for every type), and cases in `Component.create`/`Component.fromJson`, `buildFlameComponent`, `ComponentCodeGenerator._flameClass` and `flameMemberNames` (the public members of its Flame class, which child -getters must not override). The panel, bloc, preview and generated code +getters must not override), and an icon in `ComponentTreePanel.iconFor`. The panel, bloc, preview and generated code pick up its properties automatically. **Adding a property:** add the value object and capability interface, a bloc @@ -191,6 +191,11 @@ 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. +Position components draw nothing in Flame, so the preview builds them as +`PreviewPositionComponent`, a `PositionComponent` with a faint dashed +outline, to keep them visible while unselected. The outline is preview +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 @@ -242,7 +247,9 @@ member lists in `dart_names.dart` (`flameMemberNames`). It generates: has children of its own, so the nested children get getters too (`player.head.eye`); leaf children are plain Flame components. -Every generated file starts with `ComponentCodeGenerator.header`. The +Every generated file starts with `ComponentCodeGenerator.header`. +`dart:ui` is only imported when a node of the tree has a paint (an unused +import is a warning that `ignore_for_file: type=lint` doesn't hide). The repository never overwrites or deletes a file without it (`GeneratedFileConflictException`), so hand-written code in the output folder is safe. diff --git a/lib/component_editor/widgets/component_tree_panel.dart b/lib/component_editor/widgets/component_tree_panel.dart index 5254348..2682dca 100644 --- a/lib/component_editor/widgets/component_tree_panel.dart +++ b/lib/component_editor/widgets/component_tree_panel.dart @@ -90,6 +90,7 @@ class ComponentTreePanel extends StatelessWidget { static IconData iconFor(ComponentType type) { return switch (type) { ComponentType.rectangle => Icons.crop_square, + ComponentType.position => Icons.crop_free, }; } diff --git a/lib/component_editor/widgets/preview/flame_mapping.dart b/lib/component_editor/widgets/preview/flame_mapping.dart index a700ce6..613ca70 100644 --- a/lib/component_editor/widgets/preview/flame_mapping.dart +++ b/lib/component_editor/widgets/preview/flame_mapping.dart @@ -1,5 +1,6 @@ import 'package:component_repository/component_repository.dart'; import 'package:flame/components.dart' as flame; +import 'package:flame_component_editor/component_editor/widgets/preview/preview_position_component.dart'; import 'package:flutter/painting.dart'; /// Builds the Flame component that previews the root [component], with its @@ -24,6 +25,8 @@ flame.PositionComponent _placeRoot(flame.PositionComponent root) { flame.PositionComponent _build(Component component) { final target = switch (component) { RectangleComponent() => flame.RectangleComponent(), + // Outlined, since a plain PositionComponent would be invisible. + PositionComponent() => PreviewPositionComponent(), }; applyProperties(target, component); // Children added before mounting are stored right away, in order. diff --git a/lib/component_editor/widgets/preview/preview.dart b/lib/component_editor/widgets/preview/preview.dart index 5f46915..35653e3 100644 --- a/lib/component_editor/widgets/preview/preview.dart +++ b/lib/component_editor/widgets/preview/preview.dart @@ -1,3 +1,4 @@ export 'component_preview_game.dart'; export 'flame_mapping.dart'; +export 'preview_position_component.dart'; export 'selection_highlight.dart'; diff --git a/lib/component_editor/widgets/preview/preview_position_component.dart b/lib/component_editor/widgets/preview/preview_position_component.dart new file mode 100644 index 0000000..d07ff95 --- /dev/null +++ b/lib/component_editor/widgets/preview/preview_position_component.dart @@ -0,0 +1,52 @@ +import 'dart:math' as math; + +import 'package:flame/components.dart' as flame; +import 'package:flutter/painting.dart'; + +/// {@template preview_position_component} +/// The Flame component that previews a position component. +/// +/// A `PositionComponent` draws nothing itself, so the preview outlines it +/// with a faint dashed line, letting users see it while it isn't selected. +/// The outline only exists in the preview: the generated code uses a plain +/// `PositionComponent`. +/// {@endtemplate} +class PreviewPositionComponent extends flame.PositionComponent { + /// {@macro preview_position_component} + new(); + + /// The color of the outline. + static const color = Color(0x66FFFFFF); + + /// The length of each dash, and of the gap after it. + static const dashLength = 4.0; + + static final _paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + + @override + void render(Canvas canvas) { + final rect = size.toRect(); + final corners = [ + rect.topLeft, + rect.topRight, + rect.bottomRight, + rect.bottomLeft, + ]; + for (var i = 0; i < corners.length; i++) { + _drawDashedLine(canvas, corners[i], corners[(i + 1) % corners.length]); + } + } + + static void _drawDashedLine(Canvas canvas, Offset from, Offset to) { + final length = (to - from).distance; + if (length == 0) return; + final direction = (to - from) / length; + for (var start = 0.0; start < length; start += dashLength * 2) { + final end = math.min(start + dashLength, length); + canvas.drawLine(from + direction * start, from + direction * end, _paint); + } + } +} 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 85a8315..c7065a0 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 @@ -44,9 +44,14 @@ class ComponentCodeGenerator { ..writeln() ..writeln('// coverage:ignore-file') ..writeln('// ignore_for_file: type=lint') - ..writeln() - ..writeln("import 'dart:ui';") - ..writeln() + ..writeln(); + // `dart:ui` is only needed for paints; an unused import is a warning. + if (component.nodes.any((node) => node is PaintedComponent)) { + buffer + ..writeln("import 'dart:ui';") + ..writeln(); + } + buffer ..writeln("import 'package:flame/components.dart';") ..writeln(); @@ -122,6 +127,7 @@ class ComponentCodeGenerator { static String _flameClass(Component component) { return switch (component) { RectangleComponent() => 'RectangleComponent', + PositionComponent() => 'PositionComponent', }; } 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 e56e4c0..0af9053 100644 --- a/packages/component_repository/lib/src/code_generation/dart_names.dart +++ b/packages/component_repository/lib/src/code_generation/dart_names.dart @@ -61,6 +61,7 @@ bool isDartKeyword(String name) => _keywords.contains(name); Set flameMemberNames(ComponentType type) { return switch (type) { ComponentType.rectangle => _rectangleComponentMembers, + ComponentType.position => _positionComponentMembers, }; } @@ -78,9 +79,9 @@ const _keywords = { 'sync', 'typedef', 'when', 'yield', }; -/// Public members of Flame's `RectangleComponent` (Flame 2.0.0-dev.1), +/// Public members of Flame's `PositionComponent` (Flame 2.0.0-dev.1), /// including the inherited ones. -const _rectangleComponentMembers = { +const _positionComponentMembers = { 'absoluteAngle', 'absoluteAngleWithoutReflection', 'absoluteCenter', @@ -113,7 +114,6 @@ const _rectangleComponentMembers = { 'debugPaint', 'debugTextPaint', 'decorator', - 'deletePaint', 'descendants', 'distance', 'findGame', @@ -125,19 +125,11 @@ const _rectangleComponentMembers = { 'flipHorizontallyAroundCenter', 'flipVertically', 'flipVerticallyAroundCenter', - 'getAlpha', - 'getEdge', - 'getOpacity', - 'getPaint', - 'getVertex', - 'globalVertices', 'handleHotReload', 'handleResize', 'hasChildren', - 'hasPaintLayers', 'hashCode', 'height', - 'hue', 'isFlippedHorizontally', 'isFlippedVertically', 'isLoaded', @@ -146,20 +138,15 @@ const _rectangleComponentMembers = { 'isMounting', 'isRemoved', 'isRemoving', - 'isSolid', 'key', 'lastChild', 'loadSettled', 'loaded', 'localToParent', 'lookAt', - 'makeOpaque', - 'makeTransparent', - 'manuallyPositioned', 'mounted', 'nativeAngle', 'noSuchMethod', - 'onChanged', 'onChildrenChanged', 'onGameResize', 'onHotReload', @@ -167,22 +154,14 @@ const _rectangleComponentMembers = { 'onMount', 'onParentResize', 'onRemove', - 'opacity', - 'opacityProviderOf', - 'opacityProviderOfList', - 'paint', - 'paintLayers', - 'paintLayersInternal', 'parent', 'parentToLocal', 'position', 'positionOf', 'positionOfAnchor', - 'possibleIntersectionVertices', 'priority', 'propagateToChildren', 'rebalanceChildren', - 'refreshVertices', 'remove', 'removeAll', 'removeFromParent', @@ -192,23 +171,16 @@ const _rectangleComponentMembers = { 'renderChild', 'renderContext', 'renderDebugMode', - 'renderShape', 'renderTree', 'renderTreeWithoutDecorator', 'runtimeType', 'scale', 'scaledSize', - 'setAlpha', 'setByRect', - 'setColor', 'setLoaded', 'setMounted', - 'setOpacity', - 'setPaint', 'setRemoved', - 'shrinkToBounds', 'size', - 'tint', 'toAbsoluteRect', 'toLocal', 'toRect', @@ -218,8 +190,44 @@ const _rectangleComponentMembers = { 'transformMatrix', 'update', 'updateTree', - 'vertices', 'width', 'x', 'y', }; + +/// Public members of Flame's `RectangleComponent` (Flame 2.0.0-dev.1), +/// including the inherited ones: the `PositionComponent` ones, plus those +/// of `HasPaint`, `ShapeComponent` and `PolygonComponent`. +const Set _rectangleComponentMembers = { + ..._positionComponentMembers, + 'deletePaint', + 'getAlpha', + 'getEdge', + 'getOpacity', + 'getPaint', + 'getVertex', + 'globalVertices', + 'hasPaintLayers', + 'hue', + 'isSolid', + 'makeOpaque', + 'makeTransparent', + 'manuallyPositioned', + 'onChanged', + 'opacity', + 'opacityProviderOf', + 'opacityProviderOfList', + 'paint', + 'paintLayers', + 'paintLayersInternal', + 'possibleIntersectionVertices', + 'refreshVertices', + 'renderShape', + 'setAlpha', + 'setColor', + 'setOpacity', + 'setPaint', + 'shrinkToBounds', + 'tint', + 'vertices', +}; diff --git a/packages/component_repository/lib/src/models/component.dart b/packages/component_repository/lib/src/models/component.dart index 3e653e7..5b29e46 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 'position_component.dart'; part 'rectangle_component.dart'; /// {@template component} @@ -21,6 +22,7 @@ sealed class Component extends Equatable { factory create(String name, ComponentType type) { return switch (type) { ComponentType.rectangle => RectangleComponent(name: name), + ComponentType.position => PositionComponent(name: name), }; } @@ -33,6 +35,7 @@ sealed class Component extends Equatable { final type = ComponentType.fromKey(json['type']) ?? ComponentType.rectangle; final component = switch (type) { ComponentType.rectangle => RectangleComponent._fromJson(json), + ComponentType.position => PositionComponent._fromJson(json), }; return component.copyWithChildren(_childrenFromJson(json['children'])); } diff --git a/packages/component_repository/lib/src/models/component_type.dart b/packages/component_repository/lib/src/models/component_type.dart index ec21c22..7f45dcb 100644 --- a/packages/component_repository/lib/src/models/component_type.dart +++ b/packages/component_repository/lib/src/models/component_type.dart @@ -3,7 +3,11 @@ /// Each value maps to a Flame component class. enum ComponentType { /// A rectangle, backed by Flame's `RectangleComponent`. - rectangle(key: 'rectangle', label: 'Rectangle'); + rectangle(key: 'rectangle', label: 'Rectangle'), + + /// A plain component that draws nothing itself, backed by Flame's + /// `PositionComponent`. + position(key: 'position', label: 'Position'); new({required this.key, required this.label}); diff --git a/packages/component_repository/lib/src/models/position_component.dart b/packages/component_repository/lib/src/models/position_component.dart new file mode 100644 index 0000000..b19886d --- /dev/null +++ b/packages/component_repository/lib/src/models/position_component.dart @@ -0,0 +1,78 @@ +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. +/// {@endtemplate} +final class PositionComponent extends Component + implements PositionedComponent, SizedComponent { + /// {@macro position_component} + const new({ + required super.name, + this.position = const ComponentPosition(), + this.size = const ComponentSize(), + super.children, + }); + + /// Reads the properties of a [PositionComponent] from its JSON + /// representation. Use [Component.fromJson], which also reads the + /// children. + factory _fromJson(Map json) { + return PositionComponent( + name: json['name'] as String, + position: ComponentPosition.fromJson(json['position']), + size: ComponentSize.fromJson(json['size']), + ); + } + + @override + ComponentType get type => ComponentType.position; + + @override + final ComponentPosition position; + + @override + final ComponentSize size; + + @override + PositionComponent copyWithPosition(ComponentPosition position) { + return _copyWith(position: position); + } + + @override + PositionComponent copyWithSize(ComponentSize size) { + return _copyWith(size: size); + } + + @override + PositionComponent copyWithName(String name) => _copyWith(name: name); + + @override + PositionComponent copyWithChildren(List children) { + return _copyWith(children: children); + } + + PositionComponent _copyWith({ + String? name, + ComponentPosition? position, + ComponentSize? size, + List? children, + }) { + return PositionComponent( + name: name ?? this.name, + position: position ?? this.position, + size: size ?? this.size, + children: children ?? this.children, + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'position': position.toJson(), + 'size': size.toJson(), + }; + + @override + List get props => [...super.props, position, size]; +} 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 f8d10cd..16e99e2 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 @@ -125,6 +125,62 @@ class \$PlayerHead extends RectangleComponent { ); }); + test('generates position components without paints', () { + expect( + generator.generate( + const PositionComponent( + name: 'player', + size: ComponentSize(width: 10, height: 20), + children: [ + PositionComponent( + name: 'body', + position: ComponentPosition(x: 1, y: 2), + ), + ], + ), + ), + 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(10, 20)) { + add(body); + } + + /// The `body` child component. + final PositionComponent body = PositionComponent( + position: Vector2(1, 2), + size: Vector2(50, 50), + ); +} +'''), + ); + }); + + test('imports dart:ui when any node has a paint', () { + expect( + generator.generate( + const PositionComponent( + name: 'player', + children: [RectangleComponent(name: 'body')], + ), + ), + allOf( + startsWith(_header), + contains('final RectangleComponent body = RectangleComponent('), + ), + ); + }); + test('pads colors to 8 hex digits', () { 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 32f33a8..0bdbcbe 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 @@ -63,5 +63,14 @@ void main() { flameMemberNames(ComponentType.rectangle), containsAll(['size', 'position', 'paint', 'children', 'parent']), ); + expect( + flameMemberNames(ComponentType.position), + containsAll(['size', 'position', 'children', 'parent']), + ); + expect(flameMemberNames(ComponentType.position), isNot(contains('paint'))); + expect( + flameMemberNames(ComponentType.rectangle), + containsAll(flameMemberNames(ComponentType.position)), + ); }); } diff --git a/packages/component_repository/test/src/models/component_test.dart b/packages/component_repository/test/src/models/component_test.dart index f1c798a..47b3890 100644 --- a/packages/component_repository/test/src/models/component_test.dart +++ b/packages/component_repository/test/src/models/component_test.dart @@ -10,6 +10,13 @@ void main() { equals(const RectangleComponent(name: 'Player')), ); }); + + test('creates a position component with default properties', () { + expect( + Component.create('Player', ComponentType.position), + equals(const PositionComponent(name: 'Player')), + ); + }); }); group('fromJson', () { @@ -20,6 +27,26 @@ void main() { ); }); + test('reads a position component with children of other types', () { + expect( + Component.fromJson(const { + 'name': 'Player', + 'type': 'position', + 'size': {'width': 10.0, 'height': 20.0}, + 'children': [ + {'name': 'rectangle1', 'type': 'rectangle'}, + ], + }), + equals( + const PositionComponent( + name: 'Player', + size: ComponentSize(width: 10, height: 20), + children: [RectangleComponent(name: 'rectangle1')], + ), + ), + ); + }); + test('reads the children and skips invalid ones', () { expect( Component.fromJson(const { diff --git a/packages/component_repository/test/src/models/component_tree_test.dart b/packages/component_repository/test/src/models/component_tree_test.dart index d17186d..e05edbd 100644 --- a/packages/component_repository/test/src/models/component_tree_test.dart +++ b/packages/component_repository/test/src/models/component_tree_test.dart @@ -135,6 +135,18 @@ void main() { }); } + test('accepts the members of other Flame classes', () { + const container = PositionComponent( + name: 'Player', + children: [RectangleComponent(name: 'rectangle1')], + ); + expect(container.validateChildName(const [0], 'paint'), isNull); + expect( + container.validateChildName(const [0], 'size'), + equals(ChildNameError.reserved), + ); + }); + for (final name in ['rectangle2', 'Rectangle3', 'player']) { test('rejects "$name" as duplicate', () { expect( 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 c400788..f6e90a7 100644 --- a/packages/component_repository/test/src/models/component_type_test.dart +++ b/packages/component_repository/test/src/models/component_type_test.dart @@ -8,6 +8,7 @@ void main() { ComponentType.fromKey('rectangle'), equals(ComponentType.rectangle), ); + expect(ComponentType.fromKey('position'), equals(ComponentType.position)); }); test('fromKey returns null for unknown keys', () { diff --git a/packages/component_repository/test/src/models/position_component_test.dart b/packages/component_repository/test/src/models/position_component_test.dart new file mode 100644 index 0000000..ece796c --- /dev/null +++ b/packages/component_repository/test/src/models/position_component_test.dart @@ -0,0 +1,100 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(PositionComponent, () { + const component = PositionComponent( + name: 'Player', + position: ComponentPosition(x: 1, y: 2), + size: ComponentSize(width: 10, height: 20), + children: [RectangleComponent(name: 'rectangle1')], + ); + + test('supports value equality', () { + expect( + const PositionComponent(name: 'Player'), + equals(const PositionComponent(name: 'Player')), + ); + expect(component, isNot(equals(const PositionComponent(name: 'Player')))); + expect( + const PositionComponent(name: 'Player'), + isNot(equals(const RectangleComponent(name: 'Player'))), + ); + }); + + 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, isNot(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': 'position', + '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}, + 'size': {'width': 10.0, 'height': 20.0}, + }), + ); + }); + + 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('copyWithSize replaces only the size', () { + const size = ComponentSize(width: 1, height: 2); + final copy = component.copyWithSize(size); + + expect(copy.size, equals(size)); + expect( + copy.toJson()..remove('size'), + equals(component.toJson()..remove('size')), + ); + }); + + 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/test/component_editor/widgets/preview/component_preview_game_test.dart b/test/component_editor/widgets/preview/component_preview_game_test.dart index cf22d7b..7ad724b 100644 --- a/test/component_editor/widgets/preview/component_preview_game_test.dart +++ b/test/component_editor/widgets/preview/component_preview_game_test.dart @@ -4,8 +4,16 @@ import 'package:flame_component_editor/component_editor/widgets/widgets.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/painting.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockCanvas extends Mock implements Canvas; void main() { + setUpAll(() { + registerFallbackValue(Offset.zero); + registerFallbackValue(Paint()); + }); + const component = RectangleComponent( name: 'Player', size: ComponentSize(width: 10, height: 20), @@ -44,6 +52,34 @@ void main() { }, ); + testWithGame( + 'renders position components with a preview-only outline', + () => ComponentPreviewGame( + component: const PositionComponent( + name: 'Player', + size: ComponentSize(width: 10, height: 20), + children: [ + PositionComponent( + name: 'position1', + position: ComponentPosition(x: 5, y: 6), + size: ComponentSize(width: 1, height: 2), + ), + ], + ), + ), + (game) async { + await game.ready(); + + final preview = game.preview; + expect(preview, isA()); + expect(preview.size, equals(flame.Vector2(10, 20))); + final child = preview.children.single as flame.PositionComponent; + expect(child, isA()); + expect(child.position, equals(flame.Vector2(5, 6))); + expect(child.size, equals(flame.Vector2(1, 2))); + }, + ); + group('with children', () { const child = RectangleComponent( name: 'rectangle1', @@ -214,6 +250,28 @@ void main() { }); }); + group(PreviewPositionComponent, () { + test('draws a dashed outline around its size', () { + final canvas = _MockCanvas(); + // Edges of 16 and 8 hold two and one 4px dashes respectively. + PreviewPositionComponent() + ..size.setValues(16, 8) + ..render(canvas); + + final starts = verify(() => canvas.drawLine(captureAny(), any(), any())) + .captured; + expect(starts, hasLength(6)); + expect(starts.first, equals(Offset.zero)); + }); + + test('draws nothing without a size', () { + final canvas = _MockCanvas(); + PreviewPositionComponent().render(canvas); + + verifyNever(() => canvas.drawLine(any(), any(), any())); + }); + }); + group('applyPaint', () { test('maps the fill style', () { final target = flame.RectangleComponent();