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
8 changes: 4 additions & 4 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 — edited only on children —,
size, and paint with color and fill style), `position` (a plain Flame
`PositionComponent`: position — children only — and size; draws nothing
- Component types: `rectangle` (position and anchor — edited only on
children —, size, and paint with color and fill style), `position` (a plain Flame
`PositionComponent`: position and anchor — children only — and size; draws nothing
itself, useful as a container) and `circle` (a Flame `CircleComponent`:
position — children only —, radius, and paint; the radius is its own
position and anchor — children only —, radius, and paint; the radius is its own
`ComponentRadius` property, since Flame circles take a radius, not a
size). Components can have children
(see "Component tree" in [docs/architecture.md](docs/architecture.md)). Properties are shared between types through capability interfaces —
Expand Down
24 changes: 15 additions & 9 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`, `ComponentSize`, `ComponentRadius`, `ComponentPaint` |
| Capability interface | `component_repository/lib/src/models/component_capabilities.dart` | `PositionedComponent`, `SizedComponent`, `RadiusComponent`, `PaintedComponent` |
| Component type | `ComponentType` enum + a subclass in `component.dart`'s library (`part` file) | `RectangleComponent implements PositionedComponent, SizedComponent, PaintedComponent`, `PositionComponent implements PositionedComponent, SizedComponent`, `CircleComponent implements PositionedComponent, RadiusComponent, PaintedComponent` |
| Value object (Equatable, JSON, defaults) | `component_repository/lib/src/models/` | `ComponentPosition`, `ComponentAnchor`, `ComponentSize`, `ComponentRadius`, `ComponentPaint` |
| Capability interface | `component_repository/lib/src/models/component_capabilities.dart` | `PositionedComponent`, `AnchoredComponent`, `SizedComponent`, `RadiusComponent`, `PaintedComponent` |
| Component type | `ComponentType` enum + a subclass in `component.dart`'s library (`part` file) | `RectangleComponent implements PositionedComponent, AnchoredComponent, SizedComponent, PaintedComponent`, `PositionComponent implements PositionedComponent, AnchoredComponent, SizedComponent`, `CircleComponent implements PositionedComponent, AnchoredComponent, RadiusComponent, PaintedComponent` |
| Bloc event | `lib/component_editor/bloc/` | `ComponentEditorSizeChanged` (edits the selected node if it is a `SizedComponent`) |
| Editor widget (`value` + `onChanged`, bloc-agnostic) | `lib/component_editor/widgets/property_editors/` | `SizeEditor`, `RadiusEditor`, `PaintEditor`, built from `NumberField`, `ColorField`, `EnumField` |
| Editor widget (`value` + `onChanged`, bloc-agnostic) | `lib/component_editor/widgets/property_editors/` | `PositionEditor`, `AnchorEditor`, `SizeEditor`, `RadiusEditor`, `PaintEditor`, built from `NumberField`, `ColorField`, `EnumField` |
| Panel wiring | `lib/component_editor/widgets/properties_panel.dart` | `if (component case SizedComponent c) SizeEditor(...)` |
| Flame mapping | `lib/component_editor/widgets/preview/flame_mapping.dart` | `applyPosition`, `applySize`, `applyRadius`, `applyPaint`, `buildFlameComponent` |
| Flame mapping | `lib/component_editor/widgets/preview/flame_mapping.dart` | `applyPosition`, `applyAnchor`, `applySize`, `applyRadius`, `applyPaint`, `buildFlameComponent` |
| Code generation | `component_repository/lib/src/code_generation/` | `ComponentCodeGenerator._flameClass` / `_arguments`, `flameMemberNames` in `dart_names.dart` |

**Adding a component type:** add a `ComponentType` value, a `Component`
Expand Down Expand Up @@ -184,10 +184,12 @@ from the root (`[]` is the root); `ComponentTree` (`nodeAt`, `updateNodeAt`,
edits. The editor state holds `selectedPath`, and every property event edits
`selectedNode`. New children get a unique default name such as `rectangle1`.

The position is only edited on children (`PropertiesPanel` hides it for the
root, which is placed by whoever uses it). In the preview the root is
centered with a center anchor (its stored position is ignored); children keep Flame's default top left anchor
and are positioned relative to their parent. `ComponentPreviewGame.show`
The position and the anchor are only edited on children (`PropertiesPanel`
hides them for the root, which is placed by whoever uses it). In the preview
the root is centered with a center anchor (its stored position and anchor
are ignored); children use their own anchor (`ComponentAnchor`, Flame's
default top left unless edited: one of Flame's nine named anchors or a
custom `x`/`y`) and are positioned relative to their parent. `ComponentPreviewGame.show`
updates the Flame components in place while the tree shape is unchanged
(`haveSameShape`) and rebuilds them otherwise.

Expand Down Expand Up @@ -242,6 +244,10 @@ member lists in `dart_names.dart` (`flameMemberNames`). It generates:
`class PlayerShip extends $PlayerShip`), with a
`{super.position, super.anchor, super.priority}` constructor (the root's
stored position is ignored, like in the preview);
- the children's anchor as an `anchor:` argument, `Anchor.<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);
- **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
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 @@ -35,6 +35,7 @@ class ComponentEditorBloc
on<ComponentEditorNodeRemoved>(_onNodeRemoved);
on<ComponentEditorNameChanged>(_onNameChanged);
on<ComponentEditorPositionChanged>(_onPositionChanged);
on<ComponentEditorAnchorChanged>(_onAnchorChanged);
on<ComponentEditorSizeChanged>(_onSizeChanged);
on<ComponentEditorRadiusChanged>(_onRadiusChanged);
on<ComponentEditorPaintChanged>(_onPaintChanged);
Expand Down Expand Up @@ -181,6 +182,18 @@ class ComponentEditorBloc
});
}

void _onAnchorChanged(
ComponentEditorAnchorChanged event,
Emitter<ComponentEditorState> emit,
) {
_editSelected(emit, (node) {
if (node case final AnchoredComponent node) {
return node.copyWithAnchor(event.anchor);
}
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 @@ -92,6 +92,18 @@ final class ComponentEditorSizeChanged extends ComponentEditorEvent {
List<Object> get props => [size];
}

/// The user edited the anchor of the selected [AnchoredComponent].
final class ComponentEditorAnchorChanged extends ComponentEditorEvent {
/// Creates a [ComponentEditorAnchorChanged] event.
const new(this.anchor);

/// The new anchor.
final ComponentAnchor anchor;

@override
List<Object> get props => [anchor];
}

/// The user edited the radius of the selected [RadiusComponent].
final class ComponentEditorRadiusChanged extends ComponentEditorEvent {
/// Creates a [ComponentEditorRadiusChanged] event.
Expand Down
14 changes: 11 additions & 3 deletions lib/component_editor/widgets/preview/flame_mapping.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import 'package:flutter/painting.dart';
///
/// The root is anchored at its center, at the origin of the world, so it
/// is centered on screen; its stored position is ignored because the root
/// is placed by whoever uses it. Children keep Flame's default top left
/// anchor and are positioned relative to their parent, like in the
/// generated code.
/// is placed by whoever uses it. Children use their own anchor (Flame's
/// default top left one unless edited) and are positioned relative to their
/// parent, like in the generated code.
flame.PositionComponent buildFlameComponent(Component component) {
return _placeRoot(_build(component));
}
Expand Down Expand Up @@ -93,6 +93,9 @@ T applyProperties<T extends flame.PositionComponent>(
if (component case final PositionedComponent positioned) {
applyPosition(target, positioned.position);
}
if (component case final AnchoredComponent anchored) {
applyAnchor(target, anchored.anchor);
}
if (component case final SizedComponent sized) {
applySize(target, sized.size);
}
Expand All @@ -112,6 +115,11 @@ void applyPosition(flame.PositionComponent target, ComponentPosition position) {
target.position.setValues(position.x, position.y);
}

/// Applies [anchor] to [target].
void applyAnchor(flame.PositionComponent target, ComponentAnchor anchor) {
target.anchor = flame.Anchor(anchor.x, anchor.y);
}

/// Applies [size] to [target].
void applySize(flame.PositionComponent target, ComponentSize size) {
target.size.setValues(size.width, size.height);
Expand Down
12 changes: 9 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 and the
/// position are only edited on children: the root name is its file and
/// class name, and the root is placed by whoever uses it.
/// component type gets them without changes here. The name, the position
/// and the anchor are only edited on children: the root name is its file
/// and class name, and the root is placed by whoever uses it.
/// {@endtemplate}
class PropertiesPanel extends StatelessWidget {
/// {@macro properties_panel}
Expand Down Expand Up @@ -53,6 +53,12 @@ class PropertiesPanel extends StatelessWidget {
onChanged: (position) =>
bloc.add(ComponentEditorPositionChanged(position)),
),
if (component case final AnchoredComponent anchored when !isRoot)
AnchorEditor(
value: anchored.anchor,
onChanged: (anchor) =>
bloc.add(ComponentEditorAnchorChanged(anchor)),
),
if (component case final SizedComponent sized)
SizeEditor(
value: sized.size,
Expand Down
99 changes: 99 additions & 0 deletions lib/component_editor/widgets/property_editors/anchor_editor.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import 'package:component_repository/component_repository.dart';
import 'package:flame_component_editor/component_editor/widgets/property_editors/number_field.dart';
import 'package:flame_component_editor/component_editor/widgets/property_editors/property_section.dart';
import 'package:flutter/material.dart';

/// {@template anchor_editor}
/// Edits a [ComponentAnchor]. Used by every [AnchoredComponent] inside
/// another component.
///
/// A 3x3 grid picks one of Flame's named anchors, and the X and Y fields
/// set a custom one.
/// {@endtemplate}
class AnchorEditor extends StatelessWidget {
/// {@macro anchor_editor}
const new({required this.value, required this.onChanged, super.key});

/// The current anchor.
final ComponentAnchor value;

/// Called with the edited anchor.
final ValueChanged<ComponentAnchor> onChanged;

@override
Widget build(BuildContext context) {
final presets = ComponentAnchor.presets.entries.toList();
return PropertySection(
title: 'Anchor',
children: [
Row(
spacing: 12,
children: [
Column(
children: [
for (var row = 0; row < 3; row++)
Row(
children: [
for (final MapEntry(key: name, value: preset)
in presets.skip(row * 3).take(3))
_PresetButton(
name: name,
isSelected: preset == value,
onPressed: () => onChanged(preset),
),
],
),
],
),
Expanded(
child: Column(
spacing: 12,
children: [
NumberField(
label: 'X',
value: value.x,
min: null,
onChanged: (x) => onChanged(value.copyWith(x: x)),
),
NumberField(
label: 'Y',
value: value.y,
min: null,
onChanged: (y) => onChanged(value.copyWith(y: y)),
),
],
),
),
],
),
],
);
}
}

class _PresetButton extends StatelessWidget {
const new({
required this.name,
required this.isSelected,
required this.onPressed,
});

final String name;

final bool isSelected;

final VoidCallback onPressed;

@override
Widget build(BuildContext context) {
return IconButton(
tooltip: name,
isSelected: isSelected,
visualDensity: VisualDensity.compact,
iconSize: 16,
icon: const Icon(Icons.circle_outlined),
selectedIcon: const Icon(Icons.circle),
onPressed: onPressed,
);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export 'anchor_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 @@ -133,14 +133,18 @@ class ComponentCodeGenerator {
}

/// The arguments of the Flame constructor, one per property. The root
/// position is left to whoever uses the component.
/// position and anchor are left to whoever uses the component.
static List<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.
if (component case final AnchoredComponent c
when withPosition && c.anchor != ComponentAnchor.topLeft)
'anchor: ${_anchor(c.anchor)}',
if (component case final SizedComponent c)
'size: ${_vector(c.size.width, c.size.height)}',
if (component case final RadiusComponent c)
Expand All @@ -165,6 +169,13 @@ class ComponentCodeGenerator {
: value.toString();
}

static String _anchor(ComponentAnchor anchor) {
final name = anchor.presetName;
return name != null
? 'Anchor.$name'
: 'Anchor(${_number(anchor.x)}, ${_number(anchor.y)})';
}

static String _paint(ComponentPaint paint) {
final color = paint.color.toRadixString(16).toUpperCase().padLeft(8, '0');
return [
Expand Down
23 changes: 20 additions & 3 deletions packages/component_repository/lib/src/models/circle_component.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
part of 'component.dart';

/// {@template circle_component}
/// A circle with a position, a radius and a paint.
/// A circle with a position, an anchor, a radius and a paint.
/// {@endtemplate}
final class CircleComponent extends Component
implements PositionedComponent, RadiusComponent, PaintedComponent {
implements
PositionedComponent,
AnchoredComponent,
RadiusComponent,
PaintedComponent {
/// {@macro circle_component}
const new({
required super.name,
this.position = const ComponentPosition(),
this.anchor = ComponentAnchor.topLeft,
this.radius = const ComponentRadius(),
this.paint = const ComponentPaint(),
super.children,
Expand All @@ -21,6 +26,7 @@ final class CircleComponent extends Component
return CircleComponent(
name: json['name'] as String,
position: ComponentPosition.fromJson(json['position']),
anchor: ComponentAnchor.fromJson(json['anchor']),
radius: ComponentRadius.fromJson(json['radius']),
paint: ComponentPaint.fromJson(json['paint']),
);
Expand All @@ -32,6 +38,9 @@ final class CircleComponent extends Component
@override
final ComponentPosition position;

@override
final ComponentAnchor anchor;

@override
final ComponentRadius radius;

Expand All @@ -43,6 +52,11 @@ final class CircleComponent extends Component
return _copyWith(position: position);
}

@override
CircleComponent copyWithAnchor(ComponentAnchor anchor) {
return _copyWith(anchor: anchor);
}

@override
CircleComponent copyWithRadius(ComponentRadius radius) {
return _copyWith(radius: radius);
Expand All @@ -64,13 +78,15 @@ final class CircleComponent extends Component
CircleComponent _copyWith({
String? name,
ComponentPosition? position,
ComponentAnchor? anchor,
ComponentRadius? radius,
ComponentPaint? paint,
List<Component>? children,
}) {
return CircleComponent(
name: name ?? this.name,
position: position ?? this.position,
anchor: anchor ?? this.anchor,
radius: radius ?? this.radius,
paint: paint ?? this.paint,
children: children ?? this.children,
Expand All @@ -81,10 +97,11 @@ final class CircleComponent extends Component
Map<String, dynamic> toJson() => {
...super.toJson(),
'position': position.toJson(),
'anchor': anchor.toJson(),
'radius': radius.toJson(),
'paint': paint.toJson(),
};

@override
List<Object> get props => [...super.props, position, radius, paint];
List<Object> get props => [...super.props, position, anchor, radius, paint];
}
Loading
Loading