diff --git a/.github/cspell.json b/.github/cspell.json index 862c39d..8c0f967 100644 --- a/.github/cspell.json +++ b/.github/cspell.json @@ -28,6 +28,8 @@ "cubit", "cubits", "flame", + "hitbox", + "hitboxes", "lerp", "LTRB", "LTWH", diff --git a/CLAUDE.md b/CLAUDE.md index a2158da..4343d9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,8 @@ three pass. (a top bar below the app bar for project-wide actions — the palette button —, component tree on the left to add/remove/select/rename nested children, live Flame preview in the center, with a toolbar above it whose toggle - highlights the selected node with a magenta border and whose zoom + highlights the selected node with a magenta border, another toggle + shows/hides the hitbox outlines, and whose zoom controls (also Cmd/Ctrl + `=`/`-`/`0`) zoom the preview — the zoom is an app-wide preference per component, in `editor_preferences_repository`, never in the component's JSON —, properties of the selected node on the @@ -87,7 +88,13 @@ three pass. itself, useful as a container) and `circle` (a Flame `CircleComponent`: 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 + size) and the hitboxes `rectangleHitbox`, `circleHitbox` and + `polygonHitbox` (Flame's `RectangleHitbox`, `CircleHitbox` and + `PolygonHitbox`: their shape's geometry plus a `ComponentCollision` — + collision type and solid —, no paint; children only, never roots, and + with no children of their own; outlined in orange dashes in the preview, + shown/hidden by a toolbar toggle — see "Hitboxes" in + [docs/architecture.md](docs/architecture.md)). 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 df505ca..5867fc0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,10 +131,10 @@ dependencies: | Data | `packages/file_system_client` | Native directory picker (`file_selector`), directory/file existence, list/read/write/delete files | | Repository | `packages/editor_preferences_repository` | App-wide editor preferences (`shared_preferences`, through `key_value_storage_client`), not stored in the project: the preview zoom of each component, keyed by project path + component name (`component_zoom::`), clamped to `minZoom`–`maxZoom`; the `ZoomLevels` the editor canvases step through | | Repository | `packages/project_repository` | `Project` and `ProjectSettings` models; pick/open projects; last 10 recent projects; read/write project settings (`/.flame_editor/settings.json`) and pick a project-relative output folder | -| Repository | `packages/component_repository` | Sealed `Component` model (one subclass per `ComponentType`), shared property models (`ComponentPosition`, `ComponentSize`, `ComponentPaint`, `ComponentVertices`…), the `PolygonShape` presets new polygons start with, capability interfaces and the `ComponentTree` extension for nested children (including child name validation); list/create/save/delete a project's components (one JSON file each in `/.flame_editor/components/`); `ComponentCodeGenerator` and writing/deleting the generated Dart code (see "Code generation"); the project's `Palette` (`/.flame_editor/palette.json`) and its `PaletteCodeGenerator` (see "Palette") | +| Repository | `packages/component_repository` | Sealed `Component` model (one subclass per `ComponentType`), shared property models (`ComponentPosition`, `ComponentSize`, `ComponentPaint`, `ComponentVertices`…), the `PolygonShape` presets new polygons start with, the hitbox types and their `ComponentCollision`, capability interfaces and the `ComponentTree` extension for nested children (including child name validation); list/create/save/delete a project's components (one JSON file each in `/.flame_editor/components/`); `ComponentCodeGenerator` and writing/deleting the generated Dart code (see "Code generation"); the project's `Palette` (`/.flame_editor/palette.json`) and its `PaletteCodeGenerator` (see "Palette") | | Bloc + view | `lib/home/` | Landing screen: "Open project" button and recent projects list | | Bloc + view | `lib/project/` | Project dashboard: lists components, add (name + type dialog → editor), edit, delete (with confirmation; also deletes the generated code), settings icon (opens the settings dialog). Pushed from home with `ProjectPage.route` | -| Bloc + view | `lib/component_editor/` | Component editor, `ComponentEditorPage.route(project, component)`: a top bar (`EditorTopBar`, below the app bar, above the three panels) for project-wide actions (the palette button), component tree (left, add/remove children, root selected by default), Flame preview (center, magenta border around the selected node; a toolbar above it toggles that highlight and has the zoom controls), properties panel for the selected node (right; children also get a Name field), save button + Cmd/Ctrl+S (also generates the Dart code), unsaved-changes marker in the title, `PopScope` prompt (cancel / discard / save) when leaving with unsaved changes | +| Bloc + view | `lib/component_editor/` | Component editor, `ComponentEditorPage.route(project, component)`: a top bar (`EditorTopBar`, below the app bar, above the three panels) for project-wide actions (the palette button), component tree (left, add/remove children — not under hitboxes —, root selected by default), Flame preview (center, magenta border around the selected node; a toolbar above it toggles that highlight and the hitbox outlines, and has the zoom controls; it scrolls horizontally when the preview is narrow), properties panel for the selected node (right; children also get a Name field), save button + Cmd/Ctrl+S (also generates the Dart code), unsaved-changes marker in the title, `PopScope` prompt (cancel / discard / save) when leaving with unsaved changes | | Bloc + view | `lib/vertices_editor/` | Vertices editor dialog, `VerticesEditorDialog.show(context, vertices:, paint:)`: a `CustomPaint` canvas (`VerticesCanvas`, `VerticesPainter`) where the user drags vertices, adds one from the "+" handle in the middle of an edge, and deletes the selected one (Delete/Backspace, context menu or button; at least 3 are kept). A side bar (`VerticesList`) has X/Y fields per vertex to type exact coordinates, and a delete button per row; selecting a row or focusing its fields selects the vertex. `VerticesEditorCubit` edits a working copy; Apply returns it normalized, Cancel/Esc returns `null`. `VerticesViewTransform` maps local coordinates to the canvas (fitted once when the dialog opens) and does the hit testing. The canvas zooms (`ZoomControls` next to the title, Cmd/Ctrl + `=`/`-`/`0`) relative to that fit, around its center (`VerticesViewTransform.zoomed`); the zoom is held by the cubit and not remembered | | Bloc + view | `lib/palette/` | Palette dialog, `PaletteDialog.show(context, project:, component:)` (opened from the editor's top bar; returns the saved `Palette` or `null`): add, rename, change and remove named colors; colors used by a component (including the unsaved edits of the open one) can't be renamed or removed. `PaletteBloc` saves the palette and generates its code | | Bloc + view | `lib/project_settings/` | Project settings dialog, `ProjectSettingsDialog.show(context, project)`: output path for generated Dart code (default `lib/components`, text field + Browse) | @@ -154,13 +154,13 @@ component types only declare which ones they have: | Piece | Where | Example | | --- | --- | --- | -| Value object (Equatable, JSON, defaults) | `component_repository/lib/src/models/` | `ComponentPosition`, `ComponentAnchor`, `ComponentAngle`, `ComponentSize`, `ComponentRadius`, `ComponentVertices`, `ComponentPaint` | -| Capability interface | `component_repository/lib/src/models/component_capabilities.dart` | `PositionedComponent`, `AnchoredComponent`, `RotatedComponent`, `SizedComponent`, `RadiusComponent`, `VerticesComponent`, `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`, `PolygonComponent implements PositionedComponent, AnchoredComponent, RotatedComponent, VerticesComponent, PaintedComponent` | +| Value object (Equatable, JSON, defaults) | `component_repository/lib/src/models/` | `ComponentPosition`, `ComponentAnchor`, `ComponentAngle`, `ComponentSize`, `ComponentRadius`, `ComponentVertices`, `ComponentPaint`, `ComponentCollision` | +| Capability interface | `component_repository/lib/src/models/component_capabilities.dart` | `PositionedComponent`, `AnchoredComponent`, `RotatedComponent`, `SizedComponent`, `RadiusComponent`, `VerticesComponent`, `PaintedComponent`, `HitboxComponent` | +| 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`, `PolygonComponent implements PositionedComponent, AnchoredComponent, RotatedComponent, VerticesComponent, PaintedComponent`, and the hitboxes, which swap the paint for a `HitboxComponent`: `RectangleHitboxComponent`, `CircleHitboxComponent`, `PolygonHitboxComponent` | | 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`, `AngleEditor`, `SizeEditor`, `RadiusEditor`, `VerticesEditor` (a button that opens the vertices editor dialog), `PaintEditor`, built from `NumberField`, `ColorField`, `EnumField` | +| Editor widget (`value` + `onChanged`, bloc-agnostic) | `lib/component_editor/widgets/property_editors/` | `PositionEditor`, `AnchorEditor`, `AngleEditor`, `SizeEditor`, `RadiusEditor`, `VerticesEditor` (a button that opens the vertices editor dialog), `PaintEditor`, `CollisionEditor`, 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`, `applyAngle`, `applySize`, `applyRadius`, `applyPaint`, `buildFlameComponent` | +| Flame mapping | `lib/component_editor/widgets/preview/flame_mapping.dart` | `applyPosition`, `applyAnchor`, `applyAngle`, `applySize`, `applyRadius`, `applyPaint`, `applyCollision`, `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` @@ -199,6 +199,38 @@ rebuilds it. Flame also changes the list it is given, so the preview and the generated code always pass a new, non-const list, as the first (positional) constructor argument. +### Hitboxes + +`RectangleHitboxComponent`, `CircleHitboxComponent` and +`PolygonHitboxComponent` are Flame's `RectangleHitbox`, `CircleHitbox` and +`PolygonHitbox`: the geometry of their shape (size, radius or vertices, so +the shared editors, highlight and polygon rules apply) without a paint, +plus a `ComponentCollision` (`HitboxComponent`): the collision type +(`HitboxCollisionType`, mirroring Flame's `CollisionType`) and `isSolid`. +The generated code imports `package:flame/collisions.dart` when the tree has +a hitbox, and omits `collisionType`/`isSolid` at Flame's defaults (active, +not solid). + +- **Children only, and leaves.** Flame throws when a hitbox has no + `PositionComponent` ancestor, so `createComponent` rejects hitbox roots + (`ArgumentError`) and the add component dialog doesn't offer them; + `addChildAt` throws under a hitbox, the editor bloc ignores + `ComponentEditorChildAdded` while one is selected and the tree's add + button is disabled. +- **Preview.** Hitboxes draw nothing, so the preview builds + `PreviewRectangleHitbox`, `PreviewCircleHitbox` and `PreviewPolygonHitbox` + (`preview_hitboxes.dart`, sharing the `PreviewHitbox` mixin): the real + Flame hitboxes with an **orange dashed outline**. The preview game has no + collision detection, so they never collide. The toolbar's "Show + hitboxes" toggle (`ComponentEditorState.areHitboxesVisible`, on by + default, not persisted) hides them through Flame's `HasVisibility` + (`ComponentPreviewGame.areHitboxesVisible`). The vertices editor draws + polygon hitboxes with the same orange stroke. +- **Fill-parent gotcha.** Flame's `RectangleHitbox`/`CircleHitbox` fill + their parent when built without a size/radius and without a position. + The preview hitboxes are built with a zero size/radius, and the + generated children always pass a position and a size/radius. + ### Component tree Like in Flame, every `Component` has `children` (stored nested in the same @@ -223,7 +255,9 @@ updates the Flame components in place while the tree shape is unchanged 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`. +only: the generated code uses a plain `PositionComponent`. The dashes are +drawn by the shared helpers in `dashed_outline.dart`, also used by the +hitboxes. The preview highlights the selected node with a magenta border while `isSelectionHighlighted` is on (the toolbar toggle; on by default, not diff --git a/lib/component_editor/bloc/component_editor_bloc.dart b/lib/component_editor/bloc/component_editor_bloc.dart index e63b8b3..c7529ca 100644 --- a/lib/component_editor/bloc/component_editor_bloc.dart +++ b/lib/component_editor/bloc/component_editor_bloc.dart @@ -43,8 +43,10 @@ class ComponentEditorBloc on(_onRadiusChanged); on(_onVerticesChanged); on(_onPaintChanged); + on(_onCollisionChanged); on(_onPaletteChanged); on(_onSelectionHighlightToggled); + on(_onHitboxesVisibilityToggled); on(_onZoomedIn); on(_onZoomedOut); on(_onZoomReset); @@ -158,6 +160,8 @@ class ComponentEditorBloc ComponentEditorChildAdded event, Emitter emit, ) { + // Hitboxes can't have children. + if (state.selectedNode is HitboxComponent) return; final component = state.component; final parentPath = state.selectedPath; final child = Component.create( @@ -294,6 +298,18 @@ class ComponentEditorBloc }); } + void _onCollisionChanged( + ComponentEditorCollisionChanged event, + Emitter emit, + ) { + _editSelected(emit, (node) { + if (node case final HitboxComponent node) { + return node.copyWithCollision(event.collision); + } + return node; + }); + } + void _onPaletteChanged( ComponentEditorPaletteChanged event, Emitter emit, @@ -319,6 +335,13 @@ class ComponentEditorBloc emit(state.copyWith(isSelectionHighlighted: !state.isSelectionHighlighted)); } + void _onHitboxesVisibilityToggled( + ComponentEditorHitboxesVisibilityToggled event, + Emitter emit, + ) { + emit(state.copyWith(areHitboxesVisible: !state.areHitboxesVisible)); + } + Future _onZoomedIn( ComponentEditorZoomedIn event, Emitter emit, diff --git a/lib/component_editor/bloc/component_editor_event.dart b/lib/component_editor/bloc/component_editor_event.dart index 8ece258..3cd66f4 100644 --- a/lib/component_editor/bloc/component_editor_event.dart +++ b/lib/component_editor/bloc/component_editor_event.dart @@ -169,6 +169,18 @@ final class ComponentEditorPaletteChanged extends ComponentEditorEvent { List get props => [palette]; } +/// The user edited the collision of the selected [HitboxComponent]. +final class ComponentEditorCollisionChanged extends ComponentEditorEvent { + /// Creates a [ComponentEditorCollisionChanged] event. + const new(this.collision); + + /// The new collision. + final ComponentCollision collision; + + @override + List get props => [collision]; +} + /// The user toggled the highlight of the selected node in the preview. final class ComponentEditorSelectionHighlightToggled extends ComponentEditorEvent { @@ -176,6 +188,13 @@ final class ComponentEditorSelectionHighlightToggled const new(); } +/// The user toggled the outlines of the hitboxes in the preview. +final class ComponentEditorHitboxesVisibilityToggled + extends ComponentEditorEvent { + /// Creates a [ComponentEditorHitboxesVisibilityToggled] event. + const new(); +} + /// The user zoomed in the preview, to the next of /// [ZoomLevels]. final class ComponentEditorZoomedIn extends ComponentEditorEvent { diff --git a/lib/component_editor/bloc/component_editor_state.dart b/lib/component_editor/bloc/component_editor_state.dart index 7da7670..7cab281 100644 --- a/lib/component_editor/bloc/component_editor_state.dart +++ b/lib/component_editor/bloc/component_editor_state.dart @@ -45,6 +45,7 @@ final class ComponentEditorState extends Equatable { this.takenClassNames = const {}, this.isCodeOutdated = false, this.isSelectionHighlighted = true, + this.areHitboxesVisible = true, this.palette = const Palette(), this.zoom = EditorPreferencesRepository.defaultZoom, }); @@ -81,6 +82,10 @@ final class ComponentEditorState extends Equatable { /// or a radius (see [SizedComponent] and [RadiusComponent]). final bool isSelectionHighlighted; + /// Whether the preview outlines the hitboxes ([HitboxComponent]), which + /// draw nothing themselves. + final bool areHitboxesVisible; + /// The palette of the project, whose colors paints can use (see /// [ComponentPaint.paletteColor]). final Palette palette; @@ -140,6 +145,7 @@ final class ComponentEditorState extends Equatable { Map? takenClassNames, bool? isCodeOutdated, bool? isSelectionHighlighted, + bool? areHitboxesVisible, Palette? palette, double? zoom, }) { @@ -152,6 +158,7 @@ final class ComponentEditorState extends Equatable { isCodeOutdated: isCodeOutdated ?? this.isCodeOutdated, isSelectionHighlighted: isSelectionHighlighted ?? this.isSelectionHighlighted, + areHitboxesVisible: areHitboxesVisible ?? this.areHitboxesVisible, palette: palette ?? this.palette, zoom: zoom ?? this.zoom, ); @@ -166,6 +173,7 @@ final class ComponentEditorState extends Equatable { takenClassNames, isCodeOutdated, isSelectionHighlighted, + areHitboxesVisible, palette, zoom, ]; diff --git a/lib/component_editor/widgets/component_preview.dart b/lib/component_editor/widgets/component_preview.dart index 040b737..820ea5c 100644 --- a/lib/component_editor/widgets/component_preview.dart +++ b/lib/component_editor/widgets/component_preview.dart @@ -8,7 +8,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// {@template component_preview} /// Renders the component being edited with Flame, at /// [ComponentEditorState.zoom], highlighting the selected node unless -/// [ComponentEditorState.isSelectionHighlighted] is off. +/// [ComponentEditorState.isSelectionHighlighted] is off, and outlining the +/// hitboxes unless [ComponentEditorState.areHitboxesVisible] is off. /// /// Paints that use palette colors are drawn with their current value in /// [ComponentEditorState.palette]. @@ -31,6 +32,7 @@ class _ComponentPreviewState extends State { component: _previewed(state), highlightedPath: _highlightedPath(state), zoom: state.zoom, + areHitboxesVisible: state.areHitboxesVisible, ); } @@ -48,6 +50,12 @@ class _ComponentPreviewState extends State { listenWhen: (previous, current) => previous.zoom != current.zoom, listener: (context, state) => _game.zoom = state.zoom, ), + BlocListener( + listenWhen: (previous, current) => + previous.areHitboxesVisible != current.areHitboxesVisible, + listener: (context, state) => + _game.areHitboxesVisible = state.areHitboxesVisible, + ), BlocListener( listenWhen: (previous, current) => previous.component != current.component || diff --git a/lib/component_editor/widgets/component_tree_panel.dart b/lib/component_editor/widgets/component_tree_panel.dart index 0a872e8..f59fa32 100644 --- a/lib/component_editor/widgets/component_tree_panel.dart +++ b/lib/component_editor/widgets/component_tree_panel.dart @@ -9,7 +9,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// tree. /// /// Tapping a node selects it, so the properties panel edits it. New -/// components are added inside the selected node. +/// components are added inside the selected node, unless it is a hitbox, +/// which can't have children. /// {@endtemplate} class ComponentTreePanel extends StatelessWidget { /// {@macro component_tree_panel} @@ -40,10 +41,11 @@ class ComponentTreePanel extends StatelessWidget { PopupMenuButton( tooltip: 'Add child', icon: const Icon(Icons.add), + enabled: component.nodeAt(selectedPath) is! HitboxComponent, onSelected: bloc.add, itemBuilder: (_) => [ for (final type in ComponentType.values) - if (type == ComponentType.polygon) + if (type.hasVertices) // One entry per shape a new polygon can start with. for (final shape in PolygonShape.values) PopupMenuItem( @@ -109,6 +111,9 @@ class ComponentTreePanel extends StatelessWidget { ComponentType.position => Icons.crop_free, ComponentType.circle => Icons.circle_outlined, ComponentType.polygon => Icons.pentagon_outlined, + ComponentType.rectangleHitbox => Icons.select_all, + ComponentType.circleHitbox => Icons.track_changes, + ComponentType.polygonHitbox => Icons.hexagon_outlined, }; } diff --git a/lib/component_editor/widgets/editor_toolbar.dart b/lib/component_editor/widgets/editor_toolbar.dart index 3792090..923da65 100644 --- a/lib/component_editor/widgets/editor_toolbar.dart +++ b/lib/component_editor/widgets/editor_toolbar.dart @@ -6,7 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// {@template editor_toolbar} /// The bar above the preview, with the preview options: the selection -/// highlight and the zoom. +/// highlight, the hitboxes outlines and the zoom. /// {@endtemplate} class EditorToolbar extends StatelessWidget { /// {@macro editor_toolbar} @@ -20,43 +20,94 @@ class EditorToolbar extends StatelessWidget { final isSelectionHighlighted = context.select( (bloc) => bloc.state.isSelectionHighlighted, ); + final areHitboxesVisible = context.select( + (bloc) => bloc.state.areHitboxesVisible, + ); + final bloc = context.read(); final colorScheme = Theme.of(context).colorScheme; return ColoredBox( color: colorScheme.surfaceContainer, child: SizedBox( height: height, - child: Row( - children: [ - const SizedBox(width: 8), - IconButton( - tooltip: 'Highlight selection', - iconSize: 20, - isSelected: isSelectionHighlighted, - icon: const Icon(Icons.highlight_alt), - // Selected toggles get a filled background, so it's clear at - // a glance whether the highlight is on. - style: ButtonStyle( - backgroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.selected) - ? colorScheme.primaryContainer - : null, + // Scrolls rather than overflows when the preview is narrow. + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + _ToolbarToggle( + tooltip: 'Highlight selection', + icon: Icons.highlight_alt, + isSelected: isSelectionHighlighted, + onPressed: () => bloc.add( + const ComponentEditorSelectionHighlightToggled(), + ), + ), + _ToolbarToggle( + tooltip: 'Show hitboxes', + icon: Icons.border_style, + isSelected: areHitboxesVisible, + onPressed: () => bloc.add( + const ComponentEditorHitboxesVisibilityToggled(), + ), + ), + ], + ), + const _EditorZoomControls(), + ], ), - foregroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.selected) - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - ), - onPressed: () => context.read().add( - const ComponentEditorSelectionHighlightToggled(), ), ), - const Spacer(), - const _EditorZoomControls(), - const SizedBox(width: 8), - ], + ), + ), + ), + ); + } +} + +class _ToolbarToggle extends StatelessWidget { + const new({ + required this.tooltip, + required this.icon, + required this.isSelected, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final bool isSelected; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return IconButton( + tooltip: tooltip, + iconSize: 20, + isSelected: isSelected, + icon: Icon(icon), + // Selected toggles get a filled background, so it's clear at a glance + // whether they are on. + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? colorScheme.primaryContainer + : null, + ), + foregroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, ), ), + onPressed: onPressed, ); } } diff --git a/lib/component_editor/widgets/preview/component_preview_game.dart b/lib/component_editor/widgets/preview/component_preview_game.dart index e78533c..11089a2 100644 --- a/lib/component_editor/widgets/preview/component_preview_game.dart +++ b/lib/component_editor/widgets/preview/component_preview_game.dart @@ -2,23 +2,31 @@ import 'package:component_repository/component_repository.dart'; import 'package:flame/components.dart' as flame; import 'package:flame/game.dart'; import 'package:flame_component_editor/component_editor/widgets/preview/flame_mapping.dart'; +import 'package:flame_component_editor/component_editor/widgets/preview/preview_hitboxes.dart'; import 'package:flame_component_editor/component_editor/widgets/preview/selection_highlight.dart'; import 'package:flutter/painting.dart'; /// {@template component_preview_game} /// A Flame game that renders a [Component] and its children, centered on -/// screen and scaled by [zoom], and highlights one of them. +/// screen and scaled by [zoom], and highlights one of them. Hitboxes are +/// outlined while [areHitboxesVisible]. /// /// The game only renders: the component being edited is owned by the /// `ComponentEditorBloc` and pushed here with [show]. /// {@endtemplate} class ComponentPreviewGame extends FlameGame { /// {@macro component_preview_game} - new({required this._component, this._highlightedPath, this._zoom = 1}); + new({ + required this._component, + this._highlightedPath, + this._zoom = 1, + this._areHitboxesVisible = true, + }); Component _component; List? _highlightedPath; double _zoom; + bool _areHitboxesVisible; late flame.PositionComponent _preview; /// The Flame component currently rendering the previewed root @@ -36,6 +44,13 @@ class ComponentPreviewGame extends FlameGame { if (isLoaded) _applyZoom(); } + /// Whether the hitboxes are outlined (see [PreviewHitbox]). + bool get areHitboxesVisible => _areHitboxesVisible; + set areHitboxesVisible(bool value) { + _areHitboxesVisible = value; + if (isLoaded) _applyHitboxesVisibility(); + } + @override Color backgroundColor() => const Color(0xFF1E1E1E); @@ -77,9 +92,17 @@ class ComponentPreviewGame extends FlameGame { if (isLoaded) _preview.removeFromParent(); _preview = buildFlameComponent(_component); world.add(_preview); + _applyHitboxesVisibility(); _updateHighlight(); } + void _applyHitboxesVisibility() { + for (final hitbox + in _preview.descendants(includeSelf: true).whereType()) { + hitbox.isVisible = _areHitboxesVisible; + } + } + void _updateHighlight() { final path = _highlightedPath; highlight.target = diff --git a/lib/component_editor/widgets/preview/dashed_outline.dart b/lib/component_editor/widgets/preview/dashed_outline.dart new file mode 100644 index 0000000..b98f0b3 --- /dev/null +++ b/lib/component_editor/widgets/preview/dashed_outline.dart @@ -0,0 +1,44 @@ +import 'dart:math' as math; + +import 'package:flutter/painting.dart'; + +/// The length of each dash of the preview outlines, and of the gap after +/// it. +const dashLength = 4.0; + +/// Draws the closed outline through [points] on [canvas] with [paint], as a +/// dashed line. +void drawDashedPolygon(Canvas canvas, List points, Paint paint) { + for (var i = 0; i < points.length; i++) { + _drawDashedLine(canvas, points[i], points[(i + 1) % points.length], paint); + } +} + +/// Draws a circle of [radius] around [center] on [canvas] with [paint], as +/// a dashed line. +void drawDashedCircle( + Canvas canvas, + Offset center, + double radius, + Paint paint, +) { + if (radius <= 0) return; + final circumference = 2 * math.pi * radius; + // A whole number of dashes and gaps, so the last gap closes the circle. + final dashes = math.max(1, (circumference / (dashLength * 2)).round()); + final step = 2 * math.pi / dashes; + final rect = Rect.fromCircle(center: center, radius: radius); + for (var i = 0; i < dashes; i++) { + canvas.drawArc(rect, i * step, step / 2, false, paint); + } +} + +void _drawDashedLine(Canvas canvas, Offset from, Offset to, Paint paint) { + 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/lib/component_editor/widgets/preview/flame_mapping.dart b/lib/component_editor/widgets/preview/flame_mapping.dart index cf3011c..c5b968a 100644 --- a/lib/component_editor/widgets/preview/flame_mapping.dart +++ b/lib/component_editor/widgets/preview/flame_mapping.dart @@ -1,5 +1,7 @@ import 'package:component_repository/component_repository.dart'; +import 'package:flame/collisions.dart' as flame; import 'package:flame/components.dart' as flame; +import 'package:flame_component_editor/component_editor/widgets/preview/preview_hitboxes.dart'; import 'package:flame_component_editor/component_editor/widgets/preview/preview_position_component.dart'; import 'package:flutter/painting.dart'; @@ -28,12 +30,17 @@ flame.PositionComponent _build(Component component) { final target = switch (component) { RectangleComponent() => flame.RectangleComponent(), CircleComponent() => flame.CircleComponent(), - // Flame changes the list it is given, so it gets a new one. - PolygonComponent(:final vertices) => flame.PolygonComponent([ - for (final vertex in vertices.vertices) flame.Vector2(vertex.x, vertex.y), - ]), + PolygonComponent(:final vertices) => flame.PolygonComponent( + _vectors(vertices), + ), // Outlined, since a plain PositionComponent would be invisible. PositionComponent() => PreviewPositionComponent(), + // Hitboxes draw nothing either, so they are outlined too. + RectangleHitboxComponent() => PreviewRectangleHitbox(), + CircleHitboxComponent() => PreviewCircleHitbox(), + PolygonHitboxComponent(:final vertices) => PreviewPolygonHitbox( + _vectors(vertices), + ), }; applyProperties(target, component); // Children added before mounting are stored right away, in order. @@ -41,6 +48,11 @@ flame.PositionComponent _build(Component component) { return target; } +/// A new list of the [vertices], since Flame changes the list it is given. +List _vectors(ComponentVertices vertices) => [ + for (final vertex in vertices.vertices) flame.Vector2(vertex.x, vertex.y), +]; + /// Whether [a] and [b] have the same types arranged in the same tree, so /// that the Flame components built for [a] can be updated to show [b]. /// @@ -124,6 +136,10 @@ T applyProperties( when target is flame.HasPaint) { applyPaint(target as flame.HasPaint, painted.paint); } + if (component case final HitboxComponent hitbox + when target is flame.ShapeHitbox) { + applyCollision(target as flame.ShapeHitbox, hitbox.collision); + } return target; } @@ -161,3 +177,14 @@ void applyPaint(flame.HasPaint target, ComponentPaint paint) { PaintStyle.stroke => PaintingStyle.stroke, }; } + +/// Applies [collision] to [target]. +void applyCollision(flame.ShapeHitbox target, ComponentCollision collision) { + target + ..collisionType = switch (collision.type) { + HitboxCollisionType.active => flame.CollisionType.active, + HitboxCollisionType.passive => flame.CollisionType.passive, + HitboxCollisionType.inactive => flame.CollisionType.inactive, + } + ..isSolid = collision.isSolid; +} diff --git a/lib/component_editor/widgets/preview/preview.dart b/lib/component_editor/widgets/preview/preview.dart index 35653e3..5ba9f68 100644 --- a/lib/component_editor/widgets/preview/preview.dart +++ b/lib/component_editor/widgets/preview/preview.dart @@ -1,4 +1,6 @@ export 'component_preview_game.dart'; +export 'dashed_outline.dart'; export 'flame_mapping.dart'; +export 'preview_hitboxes.dart'; export 'preview_position_component.dart'; export 'selection_highlight.dart'; diff --git a/lib/component_editor/widgets/preview/preview_hitboxes.dart b/lib/component_editor/widgets/preview/preview_hitboxes.dart new file mode 100644 index 0000000..bcb8d31 --- /dev/null +++ b/lib/component_editor/widgets/preview/preview_hitboxes.dart @@ -0,0 +1,79 @@ +import 'package:flame/collisions.dart' as flame; +import 'package:flame/components.dart' as flame; +import 'package:flame_component_editor/component_editor/widgets/preview/dashed_outline.dart'; +import 'package:flutter/painting.dart'; + +/// {@template preview_hitbox} +/// A Flame hitbox in the preview. +/// +/// Hitboxes draw nothing themselves, so the preview outlines them with an +/// orange dashed line, hidden with [isVisible] when the user hides the +/// hitboxes. The outline only exists in the preview: the generated code +/// uses Flame's hitboxes. +/// +/// The preview game has no collision detection, so these hitboxes never +/// collide. +/// {@endtemplate} +mixin PreviewHitbox on flame.PositionComponent, flame.HasVisibility { + /// The color of the outline. + static const color = Color(0xFFFF9800); + + /// The paint of the outline. + static final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 1; +} + +/// {@macro preview_hitbox} +class PreviewRectangleHitbox extends flame.RectangleHitbox + with flame.HasVisibility, PreviewHitbox { + /// {@macro preview_hitbox} + /// + /// It is given a size, otherwise Flame would make it fill its parent. + new() : super(size: flame.Vector2.zero()); + + @override + void render(Canvas canvas) { + final rect = size.toRect(); + drawDashedPolygon(canvas, [ + rect.topLeft, + rect.topRight, + rect.bottomRight, + rect.bottomLeft, + ], PreviewHitbox.paint); + } +} + +/// {@macro preview_hitbox} +class PreviewCircleHitbox extends flame.CircleHitbox + with flame.HasVisibility, PreviewHitbox { + /// {@macro preview_hitbox} + /// + /// It is given a radius, otherwise Flame would make it fill its parent. + new() : super(radius: 0); + + @override + void render(Canvas canvas) { + drawDashedCircle( + canvas, + (size / 2).toOffset(), + radius, + PreviewHitbox.paint, + ); + } +} + +/// {@macro preview_hitbox} +class PreviewPolygonHitbox extends flame.PolygonHitbox + with flame.HasVisibility, PreviewHitbox { + /// {@macro preview_hitbox} + new(super.vertices); + + @override + void render(Canvas canvas) { + drawDashedPolygon(canvas, [ + for (final vertex in vertices) vertex.toOffset(), + ], PreviewHitbox.paint); + } +} diff --git a/lib/component_editor/widgets/preview/preview_position_component.dart b/lib/component_editor/widgets/preview/preview_position_component.dart index d07ff95..9b1d274 100644 --- a/lib/component_editor/widgets/preview/preview_position_component.dart +++ b/lib/component_editor/widgets/preview/preview_position_component.dart @@ -1,6 +1,5 @@ -import 'dart:math' as math; - import 'package:flame/components.dart' as flame; +import 'package:flame_component_editor/component_editor/widgets/preview/dashed_outline.dart'; import 'package:flutter/painting.dart'; /// {@template preview_position_component} @@ -18,9 +17,6 @@ class PreviewPositionComponent extends flame.PositionComponent { /// 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 @@ -29,24 +25,11 @@ class PreviewPositionComponent extends flame.PositionComponent { @override void render(Canvas canvas) { final rect = size.toRect(); - final corners = [ + drawDashedPolygon(canvas, [ 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); - } + ], _paint); } } diff --git a/lib/component_editor/widgets/properties_panel.dart b/lib/component_editor/widgets/properties_panel.dart index ba4f9cc..da3f188 100644 --- a/lib/component_editor/widgets/properties_panel.dart +++ b/lib/component_editor/widgets/properties_panel.dart @@ -1,5 +1,6 @@ import 'package:component_repository/component_repository.dart'; import 'package:flame_component_editor/component_editor/bloc/component_editor_bloc.dart'; +import 'package:flame_component_editor/component_editor/widgets/preview/preview_hitboxes.dart'; import 'package:flame_component_editor/component_editor/widgets/property_editors/property_editors.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -86,6 +87,11 @@ class PropertiesPanel extends StatelessWidget { PaintedComponent(:final paint) => paint.copyWith( color: paint.resolvedColor(palette), ), + // Drawn like in the preview. + HitboxComponent() => ComponentPaint( + color: PreviewHitbox.color.toARGB32(), + style: PaintStyle.stroke, + ), _ => const ComponentPaint(), }, onChanged: (vertices) => @@ -97,6 +103,12 @@ class PropertiesPanel extends StatelessWidget { palette: palette, onChanged: (paint) => bloc.add(ComponentEditorPaintChanged(paint)), ), + if (component case final HitboxComponent hitbox) + CollisionEditor( + value: hitbox.collision, + onChanged: (collision) => + bloc.add(ComponentEditorCollisionChanged(collision)), + ), ], ); } diff --git a/lib/component_editor/widgets/property_editors/collision_editor.dart b/lib/component_editor/widgets/property_editors/collision_editor.dart new file mode 100644 index 0000000..6e62438 --- /dev/null +++ b/lib/component_editor/widgets/property_editors/collision_editor.dart @@ -0,0 +1,43 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/component_editor/widgets/property_editors/enum_field.dart'; +import 'package:flame_component_editor/component_editor/widgets/property_editors/property_section.dart'; +import 'package:flutter/material.dart'; + +/// {@template collision_editor} +/// Edits a [ComponentCollision]: the collision type and whether the hitbox +/// is solid. +/// {@endtemplate} +class CollisionEditor extends StatelessWidget { + /// {@macro collision_editor} + const new({required this.value, required this.onChanged, super.key}); + + /// The collision being edited. + final ComponentCollision value; + + /// Called with the updated collision. + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return PropertySection( + title: 'Collision', + children: [ + EnumField( + label: 'Type', + values: HitboxCollisionType.values, + value: value.type, + labelOf: (type) => type.label, + onChanged: (type) => onChanged(value.copyWith(type: type)), + ), + SwitchListTile( + title: const Text('Solid'), + subtitle: const Text('Also collides with hitboxes inside it'), + contentPadding: EdgeInsets.zero, + dense: true, + value: value.isSolid, + onChanged: (isSolid) => onChanged(value.copyWith(isSolid: isSolid)), + ), + ], + ); + } +} diff --git a/lib/component_editor/widgets/property_editors/property_editors.dart b/lib/component_editor/widgets/property_editors/property_editors.dart index 8fc8a83..751a4b7 100644 --- a/lib/component_editor/widgets/property_editors/property_editors.dart +++ b/lib/component_editor/widgets/property_editors/property_editors.dart @@ -1,5 +1,6 @@ export 'anchor_editor.dart'; export 'angle_editor.dart'; +export 'collision_editor.dart'; export 'color_field.dart'; export 'enum_field.dart'; export 'name_field.dart'; diff --git a/lib/project/widgets/add_component_dialog.dart b/lib/project/widgets/add_component_dialog.dart index e5a56fc..e52ee0a 100644 --- a/lib/project/widgets/add_component_dialog.dart +++ b/lib/project/widgets/add_component_dialog.dart @@ -79,11 +79,13 @@ class _AddComponentDialogState extends State { if (type != null) setState(() => _type = type); }, dropdownMenuEntries: [ + // Hitboxes can only be children. for (final type in ComponentType.values) - DropdownMenuEntry(value: type, label: type.label), + if (!type.isHitbox) + DropdownMenuEntry(value: type, label: type.label), ], ), - if (_type == ComponentType.polygon) + if (_type.hasVertices) DropdownMenu( label: const Text('Shape'), expandedInsets: EdgeInsets.zero, 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 fc57210..5e9d871 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 @@ -55,6 +55,9 @@ class ComponentCodeGenerator { ..writeln("import 'dart:ui';") ..writeln(); } + if (component.nodes.any((node) => node is HitboxComponent)) { + buffer.writeln("import 'package:flame/collisions.dart';"); + } buffer.writeln("import 'package:flame/components.dart';"); if (component.usedPaletteColors.any(palette.contains)) { buffer.writeln("import '${PaletteCodeGenerator.fileName}';"); @@ -139,6 +142,9 @@ class ComponentCodeGenerator { PositionComponent() => 'PositionComponent', CircleComponent() => 'CircleComponent', PolygonComponent() => 'PolygonComponent', + RectangleHitboxComponent() => 'RectangleHitbox', + CircleHitboxComponent() => 'CircleHitbox', + PolygonHitboxComponent() => 'PolygonHitbox', }; } @@ -168,6 +174,14 @@ class ComponentCodeGenerator { 'radius: ${_number(c.radius.radius)}', if (component case final PaintedComponent c) 'paint: ${_paint(c.paint, palette)}', + // Flame defaults to active, non solid hitboxes. + if (component case final HitboxComponent c + when c.collision.type != HitboxCollisionType.active) + 'collisionType: CollisionType.${c.collision.type.name}', + if (component case HitboxComponent( + collision: ComponentCollision(isSolid: true), + )) + 'isSolid: true', ]; } 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 967b3bb..4323a3f 100644 --- a/packages/component_repository/lib/src/code_generation/dart_names.dart +++ b/packages/component_repository/lib/src/code_generation/dart_names.dart @@ -64,6 +64,11 @@ Set flameMemberNames(ComponentType type) { ComponentType.position => _positionComponentMembers, ComponentType.circle => _circleComponentMembers, ComponentType.polygon => _polygonComponentMembers, + // Hitboxes have no children, so no getters: the members of their shape + // are enough. + ComponentType.rectangleHitbox => _rectangleComponentMembers, + ComponentType.circleHitbox => _circleComponentMembers, + ComponentType.polygonHitbox => _polygonComponentMembers, }; } diff --git a/packages/component_repository/lib/src/component_repository.dart b/packages/component_repository/lib/src/component_repository.dart index a7ebfe9..a76cb62 100644 --- a/packages/component_repository/lib/src/component_repository.dart +++ b/packages/component_repository/lib/src/component_repository.dart @@ -133,12 +133,18 @@ class ComponentRepository { /// `PlayerShip`), already exists. Throws a /// [ComponentClassConflictException] if another component already /// generates its class (see [ComponentTree.generatedClassNames]). + /// + /// Hitboxes ([ComponentType.isHitbox]) can only be children, so [type] + /// can't be one: that throws an [ArgumentError]. Future createComponent( String projectPath, String name, ComponentType type, { PolygonShape polygonShape = PolygonShape.triangle, }) async { + if (type.isHitbox) { + throw ArgumentError.value(type, 'type', 'Hitboxes can only be children'); + } final className = dartClassName(name); final fileName = dartFileName(name); if (!_validName.hasMatch(name) || diff --git a/packages/component_repository/lib/src/models/circle_hitbox_component.dart b/packages/component_repository/lib/src/models/circle_hitbox_component.dart new file mode 100644 index 0000000..a9e59ed --- /dev/null +++ b/packages/component_repository/lib/src/models/circle_hitbox_component.dart @@ -0,0 +1,129 @@ +part of 'component.dart'; + +/// {@template circle_hitbox_component} +/// A round hitbox with a position, an anchor, an angle, a radius and a +/// collision. +/// {@endtemplate} +final class CircleHitboxComponent extends Component + implements + PositionedComponent, + AnchoredComponent, + RotatedComponent, + RadiusComponent, + HitboxComponent { + /// {@macro circle_hitbox_component} + const new({ + required super.name, + this.position = const ComponentPosition(), + this.anchor = ComponentAnchor.topLeft, + this.angle = ComponentAngle.zero, + this.radius = const ComponentRadius(), + this.collision = const ComponentCollision(), + super.children, + }); + + /// Reads the properties of a [CircleHitboxComponent] from its JSON + /// representation. Use [Component.fromJson], which also reads the + /// children. + factory _fromJson(Map json) { + return CircleHitboxComponent( + 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']), + collision: ComponentCollision.fromJson(json['collision']), + ); + } + + @override + ComponentType get type => ComponentType.circleHitbox; + + @override + final ComponentPosition position; + + @override + final ComponentAnchor anchor; + + @override + final ComponentAngle angle; + + @override + final ComponentRadius radius; + + @override + final ComponentCollision collision; + + @override + CircleHitboxComponent copyWithPosition(ComponentPosition position) { + return _copyWith(position: position); + } + + @override + CircleHitboxComponent copyWithAnchor(ComponentAnchor anchor) { + return _copyWith(anchor: anchor); + } + + @override + CircleHitboxComponent copyWithAngle(ComponentAngle angle) { + return _copyWith(angle: angle); + } + + @override + CircleHitboxComponent copyWithRadius(ComponentRadius radius) { + return _copyWith(radius: radius); + } + + @override + CircleHitboxComponent copyWithCollision(ComponentCollision collision) { + return _copyWith(collision: collision); + } + + @override + CircleHitboxComponent copyWithName(String name) => _copyWith(name: name); + + @override + CircleHitboxComponent copyWithChildren(List children) { + return _copyWith(children: children); + } + + CircleHitboxComponent _copyWith({ + String? name, + ComponentPosition? position, + ComponentAnchor? anchor, + ComponentAngle? angle, + ComponentRadius? radius, + ComponentCollision? collision, + List? children, + }) { + return CircleHitboxComponent( + name: name ?? this.name, + position: position ?? this.position, + anchor: anchor ?? this.anchor, + angle: angle ?? this.angle, + radius: radius ?? this.radius, + collision: collision ?? this.collision, + children: children ?? this.children, + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'position': position.toJson(), + 'anchor': anchor.toJson(), + 'angle': angle.toJson(), + 'radius': radius.toJson(), + 'collision': collision.toJson(), + }; + + @override + List get props => [ + ...super.props, + position, + anchor, + angle, + radius, + collision, + ]; +} diff --git a/packages/component_repository/lib/src/models/component.dart b/packages/component_repository/lib/src/models/component.dart index 4e7686c..e45e8c8 100644 --- a/packages/component_repository/lib/src/models/component.dart +++ b/packages/component_repository/lib/src/models/component.dart @@ -2,9 +2,12 @@ import 'package:component_repository/component_repository.dart'; import 'package:equatable/equatable.dart'; part 'circle_component.dart'; +part 'circle_hitbox_component.dart'; part 'polygon_component.dart'; +part 'polygon_hitbox_component.dart'; part 'position_component.dart'; part 'rectangle_component.dart'; +part 'rectangle_hitbox_component.dart'; /// {@template component} /// A Flame component definition that belongs to a project. @@ -22,7 +25,8 @@ sealed class Component extends Equatable { /// Creates a component of [type] called [name] with default properties. /// - /// Polygons start with the vertices of [polygonShape]. + /// Polygons and polygon hitboxes start with the vertices of + /// [polygonShape]. factory create( String name, ComponentType type, { @@ -36,6 +40,12 @@ sealed class Component extends Equatable { name: name, vertices: ComponentVertices(polygonShape.vertices), ), + ComponentType.rectangleHitbox => RectangleHitboxComponent(name: name), + ComponentType.circleHitbox => CircleHitboxComponent(name: name), + ComponentType.polygonHitbox => PolygonHitboxComponent( + name: name, + vertices: ComponentVertices(polygonShape.vertices), + ), }; } @@ -51,6 +61,9 @@ sealed class Component extends Equatable { ComponentType.position => PositionComponent._fromJson(json), ComponentType.circle => CircleComponent._fromJson(json), ComponentType.polygon => PolygonComponent._fromJson(json), + ComponentType.rectangleHitbox => RectangleHitboxComponent._fromJson(json), + ComponentType.circleHitbox => CircleHitboxComponent._fromJson(json), + ComponentType.polygonHitbox => PolygonHitboxComponent._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 863b6ea..d49a631 100644 --- a/packages/component_repository/lib/src/models/component_capabilities.dart +++ b/packages/component_repository/lib/src/models/component_capabilities.dart @@ -83,3 +83,15 @@ abstract interface class VerticesComponent { /// Returns a copy of this component with [vertices] replaced. Component copyWithVertices(ComponentVertices vertices); } + +/// A [Component] that is a Flame hitbox, with a [ComponentCollision]. +/// +/// Hitboxes are only children, and have no children of their own: Flame +/// requires a hitbox to be inside a `PositionComponent`. +abstract interface class HitboxComponent { + /// How the hitbox collides with others. + ComponentCollision get collision; + + /// Returns a copy of this component with [collision] replaced. + Component copyWithCollision(ComponentCollision collision); +} diff --git a/packages/component_repository/lib/src/models/component_collision.dart b/packages/component_repository/lib/src/models/component_collision.dart new file mode 100644 index 0000000..1712286 --- /dev/null +++ b/packages/component_repository/lib/src/models/component_collision.dart @@ -0,0 +1,72 @@ +import 'package:equatable/equatable.dart'; + +/// Which other hitboxes a hitbox collides with. +/// +/// Mirrors Flame's `CollisionType` without depending on Flame. +enum HitboxCollisionType { + /// Collides with active and passive hitboxes. + active(label: 'Active'), + + /// Collides with active hitboxes only. + passive(label: 'Passive'), + + /// Doesn't collide with any hitbox. + inactive(label: 'Inactive'); + + new({required this.label}); + + /// Human readable name. + final String label; + + /// Returns the type called [name], or `null` if there is none. + static HitboxCollisionType? fromName(Object? name) { + for (final type in values) { + if (type.name == name) return type; + } + return null; + } +} + +/// {@template component_collision} +/// The collision property shared by every hitbox: its collision [type] and +/// whether it [isSolid]. +/// {@endtemplate} +class ComponentCollision extends Equatable { + /// {@macro component_collision} + const new({this.type = HitboxCollisionType.active, this.isSolid = false}); + + /// Creates a [ComponentCollision] from its JSON representation. + /// + /// Missing or invalid values fall back to their defaults. + factory fromJson(Object? json) { + final map = json is Map ? json : const {}; + final isSolid = map['isSolid']; + return ComponentCollision( + type: + HitboxCollisionType.fromName(map['type']) ?? + HitboxCollisionType.active, + isSolid: isSolid is bool && isSolid, + ); + } + + /// Which other hitboxes the hitbox collides with. + final HitboxCollisionType type; + + /// Whether the hitbox also collides with hitboxes fully inside it, rather + /// than only with those crossing its edges. + final bool isSolid; + + /// Returns a copy of this collision with the given fields replaced. + ComponentCollision copyWith({HitboxCollisionType? type, bool? isSolid}) { + return ComponentCollision( + type: type ?? this.type, + isSolid: isSolid ?? this.isSolid, + ); + } + + /// Converts this collision to its JSON representation. + Map toJson() => {'type': type.name, 'isSolid': isSolid}; + + @override + List get props => [type, isSolid]; +} diff --git a/packages/component_repository/lib/src/models/component_tree.dart b/packages/component_repository/lib/src/models/component_tree.dart index 55b1383..e57e78a 100644 --- a/packages/component_repository/lib/src/models/component_tree.dart +++ b/packages/component_repository/lib/src/models/component_tree.dart @@ -64,7 +64,13 @@ extension ComponentTree on Component { /// Returns a copy of this tree with [child] added as the last child of the /// node at [path]. + /// + /// Hitboxes ([HitboxComponent]) can't have children, and can't be added + /// to another hitbox. Component addChildAt(List path, Component child) { + if (nodeAt(path) is HitboxComponent) { + throw ArgumentError.value(path, 'path', 'Hitboxes cannot have children'); + } return updateNodeAt( path, (node) => node.copyWithChildren([...node.children, child]), diff --git a/packages/component_repository/lib/src/models/component_type.dart b/packages/component_repository/lib/src/models/component_type.dart index d38fdce..eca278a 100644 --- a/packages/component_repository/lib/src/models/component_type.dart +++ b/packages/component_repository/lib/src/models/component_type.dart @@ -13,7 +13,16 @@ enum ComponentType { circle(key: 'circle', label: 'Circle'), /// A polygon, backed by Flame's `PolygonComponent`. - polygon(key: 'polygon', label: 'Polygon'); + polygon(key: 'polygon', label: 'Polygon'), + + /// A rectangular hitbox, backed by Flame's `RectangleHitbox`. + rectangleHitbox(key: 'rectangleHitbox', label: 'Rectangle hitbox'), + + /// A round hitbox, backed by Flame's `CircleHitbox`. + circleHitbox(key: 'circleHitbox', label: 'Circle hitbox'), + + /// A polygonal hitbox, backed by Flame's `PolygonHitbox`. + polygonHitbox(key: 'polygonHitbox', label: 'Polygon hitbox'); new({required this.key, required this.label}); @@ -30,4 +39,15 @@ enum ComponentType { /// Human readable name. final String label; + + /// Whether components of this type are hitboxes, which can only be + /// children and have no children of their own (see `HitboxComponent`). + bool get isHitbox => switch (this) { + rectangleHitbox || circleHitbox || polygonHitbox => true, + rectangle || position || circle || polygon => false, + }; + + /// Whether components of this type are shaped by vertices, and start + /// with a `PolygonShape`. + bool get hasVertices => this == polygon || this == polygonHitbox; } diff --git a/packages/component_repository/lib/src/models/models.dart b/packages/component_repository/lib/src/models/models.dart index 9951091..5b69950 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_anchor.dart'; export 'component_angle.dart'; export 'component_capabilities.dart'; +export 'component_collision.dart'; export 'component_paint.dart'; export 'component_position.dart'; export 'component_radius.dart'; diff --git a/packages/component_repository/lib/src/models/polygon_hitbox_component.dart b/packages/component_repository/lib/src/models/polygon_hitbox_component.dart new file mode 100644 index 0000000..bcbf937 --- /dev/null +++ b/packages/component_repository/lib/src/models/polygon_hitbox_component.dart @@ -0,0 +1,131 @@ +part of 'component.dart'; + +/// {@template polygon_hitbox_component} +/// A polygonal hitbox with a position, an anchor, an angle, vertices and a +/// collision. +/// +/// Its size is the bounding box of its vertices. +/// {@endtemplate} +final class PolygonHitboxComponent extends Component + implements + PositionedComponent, + AnchoredComponent, + RotatedComponent, + VerticesComponent, + HitboxComponent { + /// {@macro polygon_hitbox_component} + const new({ + required super.name, + this.position = const ComponentPosition(), + this.anchor = ComponentAnchor.topLeft, + this.angle = ComponentAngle.zero, + this.vertices = const ComponentVertices(), + this.collision = const ComponentCollision(), + super.children, + }); + + /// Reads the properties of a [PolygonHitboxComponent] from its JSON + /// representation. Use [Component.fromJson], which also reads the + /// children. + factory _fromJson(Map json) { + return PolygonHitboxComponent( + name: json['name'] as String, + position: ComponentPosition.fromJson(json['position']), + anchor: ComponentAnchor.fromJson(json['anchor']), + angle: ComponentAngle.fromJson(json['angle']), + vertices: ComponentVertices.fromJson(json['vertices']), + collision: ComponentCollision.fromJson(json['collision']), + ); + } + + @override + ComponentType get type => ComponentType.polygonHitbox; + + @override + final ComponentPosition position; + + @override + final ComponentAnchor anchor; + + @override + final ComponentAngle angle; + + @override + final ComponentVertices vertices; + + @override + final ComponentCollision collision; + + @override + PolygonHitboxComponent copyWithPosition(ComponentPosition position) { + return _copyWith(position: position); + } + + @override + PolygonHitboxComponent copyWithAnchor(ComponentAnchor anchor) { + return _copyWith(anchor: anchor); + } + + @override + PolygonHitboxComponent copyWithAngle(ComponentAngle angle) { + return _copyWith(angle: angle); + } + + @override + PolygonHitboxComponent copyWithVertices(ComponentVertices vertices) { + return _copyWith(vertices: vertices); + } + + @override + PolygonHitboxComponent copyWithCollision(ComponentCollision collision) { + return _copyWith(collision: collision); + } + + @override + PolygonHitboxComponent copyWithName(String name) => _copyWith(name: name); + + @override + PolygonHitboxComponent copyWithChildren(List children) { + return _copyWith(children: children); + } + + PolygonHitboxComponent _copyWith({ + String? name, + ComponentPosition? position, + ComponentAnchor? anchor, + ComponentAngle? angle, + ComponentVertices? vertices, + ComponentCollision? collision, + List? children, + }) { + return PolygonHitboxComponent( + name: name ?? this.name, + position: position ?? this.position, + anchor: anchor ?? this.anchor, + angle: angle ?? this.angle, + vertices: vertices ?? this.vertices, + collision: collision ?? this.collision, + children: children ?? this.children, + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'position': position.toJson(), + 'anchor': anchor.toJson(), + 'angle': angle.toJson(), + 'vertices': vertices.toJson(), + 'collision': collision.toJson(), + }; + + @override + List get props => [ + ...super.props, + position, + anchor, + angle, + vertices, + collision, + ]; +} diff --git a/packages/component_repository/lib/src/models/rectangle_hitbox_component.dart b/packages/component_repository/lib/src/models/rectangle_hitbox_component.dart new file mode 100644 index 0000000..3b014c2 --- /dev/null +++ b/packages/component_repository/lib/src/models/rectangle_hitbox_component.dart @@ -0,0 +1,129 @@ +part of 'component.dart'; + +/// {@template rectangle_hitbox_component} +/// A rectangular hitbox with a position, an anchor, an angle, a size and a +/// collision. +/// {@endtemplate} +final class RectangleHitboxComponent extends Component + implements + PositionedComponent, + AnchoredComponent, + RotatedComponent, + SizedComponent, + HitboxComponent { + /// {@macro rectangle_hitbox_component} + const new({ + required super.name, + this.position = const ComponentPosition(), + this.anchor = ComponentAnchor.topLeft, + this.angle = ComponentAngle.zero, + this.size = const ComponentSize(), + this.collision = const ComponentCollision(), + super.children, + }); + + /// Reads the properties of a [RectangleHitboxComponent] from its JSON + /// representation. Use [Component.fromJson], which also reads the + /// children. + factory _fromJson(Map json) { + return RectangleHitboxComponent( + name: json['name'] as String, + position: ComponentPosition.fromJson(json['position']), + anchor: ComponentAnchor.fromJson(json['anchor']), + angle: ComponentAngle.fromJson(json['angle']), + size: ComponentSize.fromJson(json['size']), + collision: ComponentCollision.fromJson(json['collision']), + ); + } + + @override + ComponentType get type => ComponentType.rectangleHitbox; + + @override + final ComponentPosition position; + + @override + final ComponentAnchor anchor; + + @override + final ComponentAngle angle; + + @override + final ComponentSize size; + + @override + final ComponentCollision collision; + + @override + RectangleHitboxComponent copyWithPosition(ComponentPosition position) { + return _copyWith(position: position); + } + + @override + RectangleHitboxComponent copyWithAnchor(ComponentAnchor anchor) { + return _copyWith(anchor: anchor); + } + + @override + RectangleHitboxComponent copyWithAngle(ComponentAngle angle) { + return _copyWith(angle: angle); + } + + @override + RectangleHitboxComponent copyWithSize(ComponentSize size) { + return _copyWith(size: size); + } + + @override + RectangleHitboxComponent copyWithCollision(ComponentCollision collision) { + return _copyWith(collision: collision); + } + + @override + RectangleHitboxComponent copyWithName(String name) => _copyWith(name: name); + + @override + RectangleHitboxComponent copyWithChildren(List children) { + return _copyWith(children: children); + } + + RectangleHitboxComponent _copyWith({ + String? name, + ComponentPosition? position, + ComponentAnchor? anchor, + ComponentAngle? angle, + ComponentSize? size, + ComponentCollision? collision, + List? children, + }) { + return RectangleHitboxComponent( + name: name ?? this.name, + position: position ?? this.position, + anchor: anchor ?? this.anchor, + angle: angle ?? this.angle, + size: size ?? this.size, + collision: collision ?? this.collision, + children: children ?? this.children, + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'position': position.toJson(), + 'anchor': anchor.toJson(), + 'angle': angle.toJson(), + 'size': size.toJson(), + 'collision': collision.toJson(), + }; + + @override + List get props => [ + ...super.props, + position, + anchor, + angle, + size, + collision, + ]; +} 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 5208d2d..33014a4 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 @@ -389,6 +389,80 @@ class $PlayerHead extends PositionComponent { ); }); + test('generates hitboxes with their collision, importing the Flame ' + 'collisions', () { + expect( + generator.generate( + const PositionComponent( + name: 'player', + children: [ + RectangleHitboxComponent(name: 'body'), + CircleHitboxComponent( + name: 'head', + position: ComponentPosition(x: 1, y: 2), + radius: ComponentRadius(radius: 5), + collision: ComponentCollision( + type: HitboxCollisionType.passive, + isSolid: true, + ), + ), + PolygonHitboxComponent( + name: 'tail', + vertices: ComponentVertices([ + ComponentVertex(0, 0), + ComponentVertex(10, 0), + ComponentVertex(0, 10), + ]), + collision: ComponentCollision( + type: HitboxCollisionType.inactive, + ), + ), + ], + ), + ), + 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/collisions.dart'; +import 'package:flame/components.dart'; + +/// The `player` component. +class $Player extends PositionComponent { + /// Creates a [$Player]. + $Player({super.position, super.angle, super.anchor, super.priority}) + : super(size: Vector2(50, 50)) { + addAll([body, head, tail]); + } + + /// The `body` child component. + final RectangleHitbox body = RectangleHitbox( + position: Vector2(0, 0), + size: Vector2(50, 50), + ); + + /// The `head` child component. + final CircleHitbox head = CircleHitbox( + position: Vector2(1, 2), + radius: 5, + collisionType: CollisionType.passive, + isSolid: true, + ); + + /// The `tail` child component. + final PolygonHitbox tail = PolygonHitbox( + [Vector2(0, 0), Vector2(10, 0), Vector2(0, 10)], + position: Vector2(0, 0), + collisionType: CollisionType.inactive, + ); +} +'''), + ); + }); + test('imports dart:ui when any node has a paint', () { expect( generator.generate( diff --git a/packages/component_repository/test/src/component_repository_test.dart b/packages/component_repository/test/src/component_repository_test.dart index 0437fb0..30d5196 100644 --- a/packages/component_repository/test/src/component_repository_test.dart +++ b/packages/component_repository/test/src/component_repository_test.dart @@ -94,6 +94,18 @@ void main() { ).called(1); }); + test('throws an ArgumentError for hitboxes', () async { + await expectLater( + repository.createComponent( + projectPath, + 'Player', + ComponentType.rectangleHitbox, + ), + throwsArgumentError, + ); + verifyNever(() => fileSystem.writeFile(any(), any())); + }); + test('creates polygons with the given shape', () async { when(() => fileSystem.listFiles(directory, extension: '.json')) .thenAnswer((_) async => []); diff --git a/packages/component_repository/test/src/models/circle_hitbox_component_test.dart b/packages/component_repository/test/src/models/circle_hitbox_component_test.dart new file mode 100644 index 0000000..741c4ad --- /dev/null +++ b/packages/component_repository/test/src/models/circle_hitbox_component_test.dart @@ -0,0 +1,165 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(CircleHitboxComponent, () { + const component = CircleHitboxComponent( + name: 'Player', + position: ComponentPosition(x: 1, y: 2), + anchor: ComponentAnchor.center, + angle: ComponentAngle(radians: 1.5), + radius: ComponentRadius(radius: 10), + collision: ComponentCollision( + type: HitboxCollisionType.passive, + isSolid: true, + ), + children: [RectangleComponent(name: 'rectangle1')], + ); + + test('supports value equality', () { + expect( + const CircleHitboxComponent(name: 'Player'), + equals(const CircleHitboxComponent(name: 'Player')), + ); + expect( + component, + isNot(equals(const CircleHitboxComponent(name: 'Player'))), + ); + }); + + test('is a circle hitbox with a position, a radius and a collision', () { + expect(component.type, equals(ComponentType.circleHitbox)); + expect(component, isA()); + expect(component, isA()); + 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': 'circleHitbox', + 'children': [ + { + 'name': 'rectangle1', + 'type': 'rectangle', + 'children': [], + 'position': {'x': 0.0, 'y': 0.0}, + 'anchor': {'x': 0.0, 'y': 0.0}, + 'angle': {'radians': 0.0}, + 'size': {'width': 50.0, 'height': 50.0}, + 'paint': {'color': 0xFFFFFFFF, 'style': 'fill'}, + }, + ], + 'position': {'x': 1.0, 'y': 2.0}, + 'anchor': {'x': 0.5, 'y': 0.5}, + 'angle': {'radians': 1.5}, + 'radius': {'radius': 10.0}, + 'collision': {'type': 'passive', 'isSolid': true}, + }), + ); + }); + + 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('copyWithAnchor replaces only the anchor', () { + const anchor = ComponentAnchor(x: 0.25, y: 1); + final copy = component.copyWithAnchor(anchor); + + expect(copy.anchor, equals(anchor)); + expect( + copy.toJson()..remove('anchor'), + equals(component.toJson()..remove('anchor')), + ); + }); + + test('is anchored at its top left corner when saved without an ' + 'anchor', () { + final json = component.toJson()..remove('anchor'); + + expect( + (Component.fromJson(json) as AnchoredComponent).anchor, + equals(ComponentAnchor.topLeft), + ); + }); + + test('copyWithAngle replaces only the angle', () { + const angle = ComponentAngle(radians: 3); + final copy = component.copyWithAngle(angle); + + expect(copy.angle, equals(angle)); + expect( + copy.toJson()..remove('angle'), + equals(component.toJson()..remove('angle')), + ); + }); + + test('is not rotated when saved without an angle', () { + final json = component.toJson()..remove('angle'); + + expect( + (Component.fromJson(json) as RotatedComponent).angle, + equals(ComponentAngle.zero), + ); + }); + + test('copyWithRadius replaces only the radius', () { + const radius = ComponentRadius(radius: 3); + final copy = component.copyWithRadius(radius); + + expect(copy.radius, equals(radius)); + expect( + copy.toJson()..remove('radius'), + equals(component.toJson()..remove('radius')), + ); + }); + + test('copyWithCollision replaces only the collision', () { + const collision = ComponentCollision(isSolid: true); + final copy = component.copyWithCollision(collision); + + expect(copy.collision, equals(collision)); + expect( + copy.toJson()..remove('collision'), + equals(component.toJson()..remove('collision')), + ); + }); + + 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_collision_test.dart b/packages/component_repository/test/src/models/component_collision_test.dart new file mode 100644 index 0000000..add5ad0 --- /dev/null +++ b/packages/component_repository/test/src/models/component_collision_test.dart @@ -0,0 +1,63 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(ComponentCollision, () { + test('defaults to an active hitbox that is not solid', () { + const collision = ComponentCollision(); + + expect(collision.type, equals(HitboxCollisionType.active)); + expect(collision.isSolid, isFalse); + }); + + test('round-trips through JSON', () { + const collision = ComponentCollision( + type: HitboxCollisionType.inactive, + isSolid: true, + ); + + expect(collision.toJson(), equals({'type': 'inactive', 'isSolid': true})); + expect( + ComponentCollision.fromJson(collision.toJson()), + equals(collision), + ); + }); + + test('fromJson falls back to the defaults', () { + expect(ComponentCollision.fromJson(null), const ComponentCollision()); + expect( + ComponentCollision.fromJson(const {'type': 'bouncy', 'isSolid': 1}), + const ComponentCollision(), + ); + }); + + test('copyWith replaces only the given fields', () { + const collision = ComponentCollision(type: HitboxCollisionType.passive); + + expect( + collision.copyWith(isSolid: true), + const ComponentCollision( + type: HitboxCollisionType.passive, + isSolid: true, + ), + ); + expect( + collision.copyWith(type: HitboxCollisionType.active), + const ComponentCollision(), + ); + }); + }); + + group(HitboxCollisionType, () { + test('fromName returns the matching type', () { + expect( + HitboxCollisionType.fromName('passive'), + equals(HitboxCollisionType.passive), + ); + }); + + test('fromName returns null for unknown names', () { + expect(HitboxCollisionType.fromName('bouncy'), isNull); + }); + }); +} diff --git a/packages/component_repository/test/src/models/component_test.dart b/packages/component_repository/test/src/models/component_test.dart index 6edbcbb..53b4664 100644 --- a/packages/component_repository/test/src/models/component_test.dart +++ b/packages/component_repository/test/src/models/component_test.dart @@ -47,9 +47,48 @@ void main() { ), ); }); + + test('creates hitboxes with default properties', () { + expect( + Component.create('hitbox', ComponentType.rectangleHitbox), + equals(const RectangleHitboxComponent(name: 'hitbox')), + ); + expect( + Component.create('hitbox', ComponentType.circleHitbox), + equals(const CircleHitboxComponent(name: 'hitbox')), + ); + expect( + Component.create( + 'hitbox', + ComponentType.polygonHitbox, + polygonShape: PolygonShape.square, + ), + equals( + const PolygonHitboxComponent( + name: 'hitbox', + vertices: ComponentVertices(ComponentVertices.square), + ), + ), + ); + }); }); group('fromJson', () { + test('reads hitboxes', () { + expect( + Component.fromJson(const {'name': 'a', 'type': 'rectangleHitbox'}), + equals(const RectangleHitboxComponent(name: 'a')), + ); + expect( + Component.fromJson(const {'name': 'a', 'type': 'circleHitbox'}), + equals(const CircleHitboxComponent(name: 'a')), + ); + expect( + Component.fromJson(const {'name': 'a', 'type': 'polygonHitbox'}), + equals(const PolygonHitboxComponent(name: 'a')), + ); + }); + test('reads a polygon', () { expect( Component.fromJson(const {'name': 'Player', 'type': 'polygon'}), 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 8620a49..65d1c05 100644 --- a/packages/component_repository/test/src/models/component_tree_test.dart +++ b/packages/component_repository/test/src/models/component_tree_test.dart @@ -45,6 +45,20 @@ void main() { expect(updated.nodeAt(const [1]).children, equals([child])); }); + test('addChildAt throws when the node is a hitbox', () { + const withHitbox = RectangleComponent( + name: 'Player', + children: [RectangleHitboxComponent(name: 'rectangleHitbox1')], + ); + + expect( + () => withHitbox.addChildAt(const [ + 0, + ], const RectangleComponent(name: 'rectangle1')), + throwsArgumentError, + ); + }); + test('removeNodeAt removes the node and its children', () { final updated = tree.removeNodeAt(const [0]); 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 248a435..19b17cf 100644 --- a/packages/component_repository/test/src/models/component_type_test.dart +++ b/packages/component_repository/test/src/models/component_type_test.dart @@ -11,6 +11,36 @@ void main() { expect(ComponentType.fromKey('position'), equals(ComponentType.position)); expect(ComponentType.fromKey('circle'), equals(ComponentType.circle)); expect(ComponentType.fromKey('polygon'), equals(ComponentType.polygon)); + expect( + ComponentType.fromKey('rectangleHitbox'), + equals(ComponentType.rectangleHitbox), + ); + expect( + ComponentType.fromKey('circleHitbox'), + equals(ComponentType.circleHitbox), + ); + expect( + ComponentType.fromKey('polygonHitbox'), + equals(ComponentType.polygonHitbox), + ); + }); + + test('isHitbox is true only for hitboxes', () { + expect( + ComponentType.values.where((type) => type.isHitbox), + equals([ + ComponentType.rectangleHitbox, + ComponentType.circleHitbox, + ComponentType.polygonHitbox, + ]), + ); + }); + + test('hasVertices is true for polygons and polygon hitboxes', () { + expect( + ComponentType.values.where((type) => type.hasVertices), + equals([ComponentType.polygon, ComponentType.polygonHitbox]), + ); }); test('fromKey returns null for unknown keys', () { diff --git a/packages/component_repository/test/src/models/polygon_hitbox_component_test.dart b/packages/component_repository/test/src/models/polygon_hitbox_component_test.dart new file mode 100644 index 0000000..46823d6 --- /dev/null +++ b/packages/component_repository/test/src/models/polygon_hitbox_component_test.dart @@ -0,0 +1,173 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(PolygonHitboxComponent, () { + const component = PolygonHitboxComponent( + name: 'Player', + position: ComponentPosition(x: 1, y: 2), + anchor: ComponentAnchor.center, + angle: ComponentAngle(radians: 1.5), + vertices: ComponentVertices(ComponentVertices.square), + collision: ComponentCollision( + type: HitboxCollisionType.passive, + isSolid: true, + ), + children: [RectangleComponent(name: 'rectangle1')], + ); + + test('supports value equality', () { + expect( + const PolygonHitboxComponent(name: 'Player'), + equals(const PolygonHitboxComponent(name: 'Player')), + ); + expect( + component, + isNot(equals(const PolygonHitboxComponent(name: 'Player'))), + ); + }); + + test('is a polygon hitbox with a position, vertices and a collision', () { + expect(component.type, equals(ComponentType.polygonHitbox)); + expect(component, isA()); + expect(component, isA()); + expect(component, isA()); + expect(component, isA()); + expect(component, isNot(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': 'polygonHitbox', + 'children': [ + { + 'name': 'rectangle1', + 'type': 'rectangle', + 'children': [], + 'position': {'x': 0.0, 'y': 0.0}, + 'anchor': {'x': 0.0, 'y': 0.0}, + 'angle': {'radians': 0.0}, + 'size': {'width': 50.0, 'height': 50.0}, + 'paint': {'color': 0xFFFFFFFF, 'style': 'fill'}, + }, + ], + 'position': {'x': 1.0, 'y': 2.0}, + 'anchor': {'x': 0.5, 'y': 0.5}, + 'angle': {'radians': 1.5}, + 'vertices': { + 'vertices': [ + {'x': 0.0, 'y': 0.0}, + {'x': 50.0, 'y': 0.0}, + {'x': 50.0, 'y': 50.0}, + {'x': 0.0, 'y': 50.0}, + ], + }, + 'collision': {'type': 'passive', 'isSolid': true}, + }), + ); + }); + + 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('copyWithAnchor replaces only the anchor', () { + const anchor = ComponentAnchor(x: 0.25, y: 1); + final copy = component.copyWithAnchor(anchor); + + expect(copy.anchor, equals(anchor)); + expect( + copy.toJson()..remove('anchor'), + equals(component.toJson()..remove('anchor')), + ); + }); + + test('is anchored at its top left corner when saved without an ' + 'anchor', () { + final json = component.toJson()..remove('anchor'); + + expect( + (Component.fromJson(json) as AnchoredComponent).anchor, + equals(ComponentAnchor.topLeft), + ); + }); + + test('copyWithAngle replaces only the angle', () { + const angle = ComponentAngle(radians: 3); + final copy = component.copyWithAngle(angle); + + expect(copy.angle, equals(angle)); + expect( + copy.toJson()..remove('angle'), + equals(component.toJson()..remove('angle')), + ); + }); + + test('is not rotated when saved without an angle', () { + final json = component.toJson()..remove('angle'); + + expect( + (Component.fromJson(json) as RotatedComponent).angle, + equals(ComponentAngle.zero), + ); + }); + + test('copyWithVertices replaces only the vertices', () { + const vertices = ComponentVertices(); + final copy = component.copyWithVertices(vertices); + + expect(copy.vertices, equals(vertices)); + expect( + copy.toJson()..remove('vertices'), + equals(component.toJson()..remove('vertices')), + ); + }); + + test('copyWithCollision replaces only the collision', () { + const collision = ComponentCollision(isSolid: true); + final copy = component.copyWithCollision(collision); + + expect(copy.collision, equals(collision)); + expect( + copy.toJson()..remove('collision'), + equals(component.toJson()..remove('collision')), + ); + }); + + 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/rectangle_hitbox_component_test.dart b/packages/component_repository/test/src/models/rectangle_hitbox_component_test.dart new file mode 100644 index 0000000..dfa58e2 --- /dev/null +++ b/packages/component_repository/test/src/models/rectangle_hitbox_component_test.dart @@ -0,0 +1,164 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(RectangleHitboxComponent, () { + const component = RectangleHitboxComponent( + name: 'Player', + position: ComponentPosition(x: 1, y: 2), + anchor: ComponentAnchor.center, + angle: ComponentAngle(radians: 1.5), + size: ComponentSize(width: 10, height: 20), + collision: ComponentCollision( + type: HitboxCollisionType.passive, + isSolid: true, + ), + children: [RectangleComponent(name: 'rectangle1')], + ); + + test('supports value equality', () { + expect( + const RectangleHitboxComponent(name: 'Player'), + equals(const RectangleHitboxComponent(name: 'Player')), + ); + expect( + component, + isNot(equals(const RectangleHitboxComponent(name: 'Player'))), + ); + }); + + test('is a rectangle hitbox with a position, a size and a collision', () { + expect(component.type, equals(ComponentType.rectangleHitbox)); + expect(component, isA()); + expect(component, isA()); + expect(component, isA()); + expect(component, 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': 'rectangleHitbox', + 'children': [ + { + 'name': 'rectangle1', + 'type': 'rectangle', + 'children': [], + 'position': {'x': 0.0, 'y': 0.0}, + 'anchor': {'x': 0.0, 'y': 0.0}, + 'angle': {'radians': 0.0}, + 'size': {'width': 50.0, 'height': 50.0}, + 'paint': {'color': 0xFFFFFFFF, 'style': 'fill'}, + }, + ], + 'position': {'x': 1.0, 'y': 2.0}, + 'anchor': {'x': 0.5, 'y': 0.5}, + 'angle': {'radians': 1.5}, + 'size': {'width': 10.0, 'height': 20.0}, + 'collision': {'type': 'passive', 'isSolid': true}, + }), + ); + }); + + 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('copyWithAnchor replaces only the anchor', () { + const anchor = ComponentAnchor(x: 0.25, y: 1); + final copy = component.copyWithAnchor(anchor); + + expect(copy.anchor, equals(anchor)); + expect( + copy.toJson()..remove('anchor'), + equals(component.toJson()..remove('anchor')), + ); + }); + + test('is anchored at its top left corner when saved without an ' + 'anchor', () { + final json = component.toJson()..remove('anchor'); + + expect( + (Component.fromJson(json) as AnchoredComponent).anchor, + equals(ComponentAnchor.topLeft), + ); + }); + + test('copyWithAngle replaces only the angle', () { + const angle = ComponentAngle(radians: 3); + final copy = component.copyWithAngle(angle); + + expect(copy.angle, equals(angle)); + expect( + copy.toJson()..remove('angle'), + equals(component.toJson()..remove('angle')), + ); + }); + + test('is not rotated when saved without an angle', () { + final json = component.toJson()..remove('angle'); + + expect( + (Component.fromJson(json) as RotatedComponent).angle, + equals(ComponentAngle.zero), + ); + }); + + test('copyWithSize replaces only the size', () { + const size = ComponentSize(width: 1, height: 2); + final copy = component.copyWithSize(size); + + expect(copy.size, equals(size)); + expect( + copy.toJson()..remove('size'), + equals(component.toJson()..remove('size')), + ); + }); + + test('copyWithCollision replaces only the collision', () { + const collision = ComponentCollision(isSolid: true); + final copy = component.copyWithCollision(collision); + + expect(copy.collision, equals(collision)); + expect( + copy.toJson()..remove('collision'), + equals(component.toJson()..remove('collision')), + ); + }); + + 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/bloc/component_editor_bloc_test.dart b/test/component_editor/bloc/component_editor_bloc_test.dart index 98a1ff8..1419919 100644 --- a/test/component_editor/bloc/component_editor_bloc_test.dart +++ b/test/component_editor/bloc/component_editor_bloc_test.dart @@ -201,6 +201,61 @@ void main() { ], ); + group('ComponentEditorCollisionChanged', () { + const collision = ComponentCollision(isSolid: true); + const hitbox = CircleHitboxComponent(name: 'Player'); + + blocTest( + 'updates the collision of a hitbox', + build: () => ComponentEditorBloc( + project: project, + component: hitbox, + componentRepository: componentRepository, + projectRepository: projectRepository, + editorPreferencesRepository: editorPreferencesRepository, + ), + act: (bloc) => + bloc.add(const ComponentEditorCollisionChanged(collision)), + expect: () => const [ + ComponentEditorState( + component: CircleHitboxComponent( + name: 'Player', + collision: collision, + ), + savedComponent: hitbox, + ), + ], + ); + + blocTest( + 'ignores components that are not hitboxes', + build: buildBloc, + act: (bloc) => + bloc.add(const ComponentEditorCollisionChanged(collision)), + expect: () => const [], + ); + }); + + test('shows the hitboxes by default', () { + expect(buildBloc().state.areHitboxesVisible, isTrue); + }); + + blocTest( + 'ComponentEditorHitboxesVisibilityToggled toggles the hitboxes', + build: buildBloc, + act: (bloc) => bloc + ..add(const ComponentEditorHitboxesVisibilityToggled()) + ..add(const ComponentEditorHitboxesVisibilityToggled()), + expect: () => const [ + ComponentEditorState( + component: component, + savedComponent: component, + areHitboxesVisible: false, + ), + ComponentEditorState(component: component, savedComponent: component), + ], + ); + test('highlights the selection by default', () { expect(buildBloc().state.isSelectionHighlighted, isTrue); }); @@ -647,6 +702,22 @@ void main() { expect: () => const [], ); + blocTest( + 'ComponentEditorChildAdded adds no child to a hitbox', + build: buildBloc, + seed: () => const ComponentEditorState( + component: RectangleComponent( + name: 'Player', + children: [RectangleHitboxComponent(name: 'rectangleHitbox1')], + ), + savedComponent: tree, + selectedPath: [0], + ), + act: (bloc) => + bloc.add(const ComponentEditorChildAdded(ComponentType.rectangle)), + expect: () => const [], + ); + blocTest( 'ComponentEditorChildAdded adds a child to the selected node and ' 'selects it', diff --git a/test/component_editor/view/component_editor_page_test.dart b/test/component_editor/view/component_editor_page_test.dart index ba02a62..1a61a30 100644 --- a/test/component_editor/view/component_editor_page_test.dart +++ b/test/component_editor/view/component_editor_page_test.dart @@ -213,6 +213,60 @@ void main() { .called(1); }); + group('hitboxes toggle', () { + IconButton toggle(WidgetTester tester) => tester.widget( + find.ancestor( + of: find.byIcon(Icons.border_style), + matching: find.byType(IconButton), + ), + ); + + testWidgets('is selected while the hitboxes are shown', (tester) async { + await tester.pumpApp(buildSubject()); + + expect(toggle(tester).isSelected, isTrue); + }); + + testWidgets('dispatches hitboxes toggles', (tester) async { + await tester.pumpApp(buildSubject()); + + await tester.tap(find.byTooltip('Show hitboxes')); + + verify(() => bloc.add(const ComponentEditorHitboxesVisibilityToggled())) + .called(1); + }); + + testWidgets('preview outlines the hitboxes while shown', (tester) async { + const withHitbox = PositionComponent( + name: 'Player', + children: [RectangleHitboxComponent(name: 'rectangleHitbox1')], + ); + const state = ComponentEditorState( + component: withHitbox, + savedComponent: withHitbox, + ); + final states = StreamController(); + whenListen(bloc, states.stream, initialState: state); + await tester.pumpApp(buildSubject()); + await tester.pump(); + final game = tester + .widget>( + find.byType(GameWidget), + ) + .game!; + PreviewHitbox hitbox() => + game.preview.children.whereType().single; + expect(hitbox().isVisible, isTrue); + + states.add(state.copyWith(areHitboxesVisible: false)); + await tester.pump(); + + expect(game.areHitboxesVisible, isFalse); + expect(hitbox().isVisible, isFalse); + await states.close(); + }); + }); + testWidgets('preview highlights the selection while enabled', ( tester, ) async { @@ -473,6 +527,79 @@ void main() { ).called(1); }); + testWidgets('adds hitboxes, with the picked shape for polygons', ( + tester, + ) async { + stubTree(); + await tester.pumpApp(buildSubject()); + + await tester.tap(find.byTooltip('Add child')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Rectangle hitbox'), findsOneWidget); + expect(find.text('Circle hitbox'), findsOneWidget); + expect(find.text('Polygon hitbox (triangle)'), findsOneWidget); + await tester.tap(find.text('Polygon hitbox (square)')); + + verify( + () => bloc.add( + const ComponentEditorChildAdded( + ComponentType.polygonHitbox, + polygonShape: PolygonShape.square, + ), + ), + ).called(1); + }); + + testWidgets('edits the collision of a hitbox, which has no paint and ' + 'no children', (tester) async { + const withHitbox = PositionComponent( + name: 'Player', + children: [CircleHitboxComponent(name: 'circleHitbox1')], + ); + when(() => bloc.state).thenReturn( + const ComponentEditorState( + component: withHitbox, + savedComponent: withHitbox, + selectedPath: [0], + ), + ); + await tester.pumpApp(buildSubject()); + + expect( + tester + .widget>( + find.byType(PopupMenuButton), + ) + .enabled, + isFalse, + ); + + await tester.scrollUntilVisible( + find.byType(CollisionEditor), + 100, + scrollable: find + .descendant( + of: find.byType(PropertiesPanel), + matching: find.byType(Scrollable), + ) + .first, + ); + expect(find.byType(RadiusEditor), findsOneWidget); + expect(find.byType(PaintEditor), findsNothing); + tester + .widget(find.byType(CollisionEditor)) + .onChanged(const ComponentCollision(isSolid: true)); + + verify( + () => bloc.add( + const ComponentEditorCollisionChanged( + ComponentCollision(isSolid: true), + ), + ), + ).called(1); + }); + testWidgets('cannot remove the root', (tester) async { stubTree(); 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 7d48a69..6970562 100644 --- a/test/component_editor/widgets/preview/component_preview_game_test.dart +++ b/test/component_editor/widgets/preview/component_preview_game_test.dart @@ -1,4 +1,5 @@ import 'package:component_repository/component_repository.dart'; +import 'package:flame/collisions.dart' as flame; import 'package:flame/components.dart' as flame; import 'package:flame_component_editor/component_editor/widgets/widgets.dart'; import 'package:flame_test/flame_test.dart'; @@ -123,6 +124,104 @@ void main() { }, ); + group('hitboxes', () { + const hitboxes = PositionComponent( + name: 'Player', + children: [ + RectangleHitboxComponent( + name: 'rectangleHitbox1', + position: ComponentPosition(x: 1, y: 2), + size: ComponentSize(width: 10, height: 20), + collision: ComponentCollision( + type: HitboxCollisionType.passive, + isSolid: true, + ), + ), + CircleHitboxComponent( + name: 'circleHitbox1', + radius: ComponentRadius(radius: 5), + ), + PolygonHitboxComponent( + name: 'polygonHitbox1', + vertices: ComponentVertices(ComponentVertices.square), + ), + ], + ); + + testWithGame( + 'renders hitboxes as Flame hitboxes with a preview-only outline', + () => ComponentPreviewGame(component: hitboxes), + (game) async { + await game.ready(); + + final [rectangle, circle, polygon] = game.preview.children.toList(); + expect(rectangle, isA()); + rectangle as PreviewRectangleHitbox; + // Keeps its own size rather than filling its parent. + expect(rectangle.size, equals(flame.Vector2(10, 20))); + expect(rectangle.position, equals(flame.Vector2(1, 2))); + expect(rectangle.collisionType, equals(flame.CollisionType.passive)); + expect(rectangle.isSolid, isTrue); + expect(circle, isA()); + // Keeps its own radius rather than filling its parent. + expect((circle as PreviewCircleHitbox).radius, equals(5)); + expect(circle.collisionType, equals(flame.CollisionType.active)); + expect(polygon, isA()); + for (final hitbox in [rectangle, circle, polygon]) { + expect((hitbox as PreviewHitbox).isVisible, isTrue); + } + }, + ); + + testWithGame( + 'areHitboxesVisible hides the hitboxes, also after a rebuild', + () => ComponentPreviewGame(component: hitboxes), + (game) async { + await game.ready(); + + game.areHitboxesVisible = false; + expect( + game.preview.children.whereType().map( + (hitbox) => hitbox.isVisible, + ), + everyElement(isFalse), + ); + + game.show(hitboxes.removeNodeAt(const [0])); + await game.ready(); + expect( + game.preview.children.whereType(), + everyElement( + isA().having( + (h) => h.isVisible, + 'isVisible', + false, + ), + ), + ); + }, + ); + + testWithGame( + 'starts with the hitboxes hidden', + () => ComponentPreviewGame( + component: hitboxes, + areHitboxesVisible: false, + ), + (game) async { + await game.ready(); + + expect(game.areHitboxesVisible, isFalse); + expect( + game.preview.children.whereType().map( + (hitbox) => hitbox.isVisible, + ), + everyElement(isFalse), + ); + }, + ); + }); + group('with children', () { const child = RectangleComponent( name: 'rectangle1', @@ -484,6 +583,43 @@ void main() { }); }); + group('preview hitboxes', () { + test('rectangle hitboxes draw a dashed outline around their size', () { + final canvas = _MockCanvas(); + PreviewRectangleHitbox() + ..size.setValues(16, 8) + ..render(canvas); + + verify(() => canvas.drawLine(any(), any(), PreviewHitbox.paint)) + .called(6); + }); + + test('circle hitboxes draw a dashed circle', () { + final canvas = _MockCanvas(); + // A circumference of 16 pi (about 50) holds six 4px dashes and gaps. + PreviewCircleHitbox() + ..radius = 8 + ..render(canvas); + + verify( + () => canvas.drawArc(any(), any(), any(), false, PreviewHitbox.paint), + ).called(6); + }); + + test('polygon hitboxes draw a dashed outline along their vertices', () { + final canvas = _MockCanvas(); + // Edges of 4, 4 and about 5.7 hold one 4px dash each. + PreviewPolygonHitbox([ + flame.Vector2.zero(), + flame.Vector2(4, 0), + flame.Vector2(0, 4), + ]).render(canvas); + + verify(() => canvas.drawLine(any(), any(), PreviewHitbox.paint)) + .called(3); + }); + }); + group('applyPaint', () { test('maps the fill style', () { final target = flame.RectangleComponent(); diff --git a/test/component_editor/widgets/property_editors/collision_editor_test.dart b/test/component_editor/widgets/property_editors/collision_editor_test.dart new file mode 100644 index 0000000..41d3abe --- /dev/null +++ b/test/component_editor/widgets/property_editors/collision_editor_test.dart @@ -0,0 +1,42 @@ +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(CollisionEditor, () { + Future> pump(WidgetTester tester) async { + final changes = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CollisionEditor( + value: const ComponentCollision(), + onChanged: changes.add, + ), + ), + ), + ); + return changes; + } + + testWidgets('edits the collision type', (tester) async { + final changes = await pump(tester); + + await tester.tap(find.text('Passive')); + + expect( + changes, + equals([const ComponentCollision(type: HitboxCollisionType.passive)]), + ); + }); + + testWidgets('edits whether the hitbox is solid', (tester) async { + final changes = await pump(tester); + + await tester.tap(find.text('Solid')); + + expect(changes, equals([const ComponentCollision(isSolid: true)])); + }); + }); +} diff --git a/test/project/view/project_page_test.dart b/test/project/view/project_page_test.dart index 703d7b2..eb89fac 100644 --- a/test/project/view/project_page_test.dart +++ b/test/project/view/project_page_test.dart @@ -275,6 +275,22 @@ void main() { expect(find.text('Rectangle'), findsWidgets); }); + testWidgets('does not offer hitboxes, which are only children', ( + tester, + ) async { + await tester.pumpApp(buildSubject()); + await tester.tap(find.text('Add component')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + + expect(find.text('Polygon'), findsWidgets); + expect(find.text('Rectangle hitbox'), findsNothing); + expect(find.text('Circle hitbox'), findsNothing); + expect(find.text('Polygon hitbox'), findsNothing); + }); + testWidgets('asks for the shape of polygons', (tester) async { await tester.pumpApp(buildSubject()); await tester.tap(find.text('Add component'));