Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"cubit",
"cubits",
"flame",
"LTWH",
"mocktail",
"Zanardo"
]
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ three pass.
`file_system_client`, `project_repository`, `component_repository`
(components stored in `<project>/.flame_editor/components/<name>.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 —
Expand Down
34 changes: 20 additions & 14 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.<name>` 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;
Expand Down Expand Up @@ -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 |

Expand Down
13 changes: 13 additions & 0 deletions lib/component_editor/bloc/component_editor_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class ComponentEditorBloc
on<ComponentEditorNameChanged>(_onNameChanged);
on<ComponentEditorPositionChanged>(_onPositionChanged);
on<ComponentEditorAnchorChanged>(_onAnchorChanged);
on<ComponentEditorAngleChanged>(_onAngleChanged);
on<ComponentEditorSizeChanged>(_onSizeChanged);
on<ComponentEditorRadiusChanged>(_onRadiusChanged);
on<ComponentEditorPaintChanged>(_onPaintChanged);
Expand Down Expand Up @@ -194,6 +195,18 @@ class ComponentEditorBloc
});
}

void _onAngleChanged(
ComponentEditorAngleChanged event,
Emitter<ComponentEditorState> emit,
) {
_editSelected(emit, (node) {
if (node case final RotatedComponent node) {
return node.copyWithAngle(event.angle);
}
return node;
});
}

void _onSizeChanged(
ComponentEditorSizeChanged event,
Emitter<ComponentEditorState> emit,
Expand Down
12 changes: 12 additions & 0 deletions lib/component_editor/bloc/component_editor_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ final class ComponentEditorAnchorChanged extends ComponentEditorEvent {
List<Object> 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<Object> get props => [angle];
}

/// The user edited the radius of the selected [RadiusComponent].
final class ComponentEditorRadiusChanged extends ComponentEditorEvent {
/// Creates a [ComponentEditorRadiusChanged] event.
Expand Down
16 changes: 13 additions & 3 deletions lib/component_editor/widgets/preview/flame_mapping.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ 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));
}

flame.PositionComponent _placeRoot(flame.PositionComponent root) {
root
..anchor = flame.Anchor.center
..angle = 0
..position.setZero();
return root;
}
Expand Down Expand Up @@ -96,6 +98,9 @@ T applyProperties<T extends flame.PositionComponent>(
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);
}
Expand All @@ -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);
Expand Down
16 changes: 14 additions & 2 deletions lib/component_editor/widgets/preview/selection_highlight.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
11 changes: 8 additions & 3 deletions lib/component_editor/widgets/properties_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions lib/component_editor/widgets/property_editors/angle_editor.dart
Original file line number Diff line number Diff line change
@@ -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<ComponentAngle> 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)),
),
],
);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export 'anchor_editor.dart';
export 'angle_editor.dart';
export 'color_field.dart';
export 'enum_field.dart';
export 'name_field.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -133,15 +133,18 @@ 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<String> _arguments(
Component component, {
required bool withPosition,
}) {
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)}',
Expand Down
25 changes: 23 additions & 2 deletions packages/component_repository/lib/src/models/circle_component.dart
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
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}
const new({
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,
Expand All @@ -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']),
);
Expand All @@ -41,6 +44,9 @@ final class CircleComponent extends Component
@override
final ComponentAnchor anchor;

@override
final ComponentAngle angle;

@override
final ComponentRadius radius;

Expand All @@ -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);
Expand All @@ -79,6 +90,7 @@ final class CircleComponent extends Component
String? name,
ComponentPosition? position,
ComponentAnchor? anchor,
ComponentAngle? angle,
ComponentRadius? radius,
ComponentPaint? paint,
List<Component>? children,
Expand All @@ -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,
Expand All @@ -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<Object> get props => [...super.props, position, anchor, radius, paint];
List<Object> get props => [
...super.props,
position,
anchor,
angle,
radius,
paint,
];
}
Loading
Loading