diff --git a/doc/flame/components/components.md b/doc/flame/components/components.md index f02935f0a4c..e8d1eee728c 100644 --- a/doc/flame/components/components.md +++ b/doc/flame/components/components.md @@ -146,6 +146,38 @@ class MyComponent extends PositionComponent with TapCallbacks { ``` +### Custom update traversal and pausing + +The engine drives the update pass through a flattened traversal list owned by the game, so +`updateTree` is non-virtual and cannot be overridden. Components that need to control how their +subtree is updated (changing the effective `dt`, skipping children, or updating them manually) +should implement the `CustomTraversal` marker and override the `updateSubtree` method: + +```dart +class SlowMotionArea extends Component implements CustomTraversal { + @override + void updateSubtree(double dt) => super.updateSubtree(dt / 2); +} +``` + +The engine treats every `CustomTraversal` component as a traversal barrier: it appears in the +flattened list itself and its `updateSubtree` drives its subtree. `updateSubtree` lives on +`Component`, but it is only invoked for components carrying the marker. Mixins that provide a +custom traversal (like `HasTimeScale`) declare `implements CustomTraversal`, so their users do not +need to add the marker themselves, and chain via `super.updateSubtree`. + +To temporarily stop updating a component and its whole subtree, set `updatePaused` to true. While +paused, no `update` calls happen in the subtree, but rendering and event handling continue, and +pending lifecycle events (adds and removes) are still processed: + +```dart +enemySquad.updatePaused = true; // freeze the squad +enemySquad.updatePaused = false; // resume it +``` + +This is unrelated to `Game.paused`, which stops the whole game loop including rendering. + + ### Composability of components Sometimes it is useful to wrap other components inside of your component. For example by grouping @@ -331,7 +363,7 @@ flameGame.findByKeyName('player'); ### Querying child components -The children that have been added to a component live in a `QueryableOrderedSet` called +The children that have been added to a component live in a `ComponentList` called `children`. To query for a specific type of components in the set, the `query()` function can be used. By default `strictMode` is `false` in the children set, but if you set it to true, then the queries will have to be registered with `children.register` before a query can be used. diff --git a/doc/flame/migration.md b/doc/flame/migration.md index e0047302499..f7aa1373fe2 100644 --- a/doc/flame/migration.md +++ b/doc/flame/migration.md @@ -25,3 +25,89 @@ GameWidget.managed( gameFactory: MyGame.new, ); ``` + + +### `children` is now a `ComponentList` instead of an `OrderedSet` + +The `ordered_set` package is no longer used; children live in a Flame-owned `ComponentList` that +is significantly faster. The iterable surface, `query()`, `register()`, and `reversed()` are +unchanged, so most code compiles as is. If you imported `package:ordered_set` types to annotate +variables, use `ComponentList` (from `package:flame/components.dart`) instead: + +```dart +// Before +import 'package:ordered_set/ordered_set.dart'; +OrderedSet children = component.children; + +// After +ComponentList children = component.children; +``` + +Two behavioral notes: + +- `query()` results are now always in priority order. +- Mutating `children` while iterating it now tolerates removals and appends at the end; only + position-shifting operations (a mid-list insertion, a reorder, or tombstone compaction) throw + `ConcurrentModificationError`. + + +### `Component.childrenFactory` is removed + +The global children-container factory is gone. Override `createComponentList()` on the component +instead. The constructor accepts an optional `Comparator` that replaces priority +ordering for that parent, which gives custom orderings such as y-sort a supported home: + +```dart +// Before +Component.childrenFactory = () => OrderedSet.mapping((c) => c.priority); + +// After +class YSortedWorld extends World { + @override + ComponentList createComponentList() { + return ComponentList( + comparator: (a, b) => (a as PositionComponent) + .position.y + .compareTo((b as PositionComponent).position.y), + ); + } +} +``` + + +### `Component.updateTree` is non-virtual + +The update pass runs over a flattened traversal list owned by the game, so `updateTree` can no +longer be overridden. If you overrode it, implement the `CustomTraversal` marker interface and +override `Component.updateSubtree` instead; call `super.updateSubtree(dt)` to run the standard +traversal: + +```dart +// Before +class SlowMotionArea extends Component { + @override + void updateTree(double dt) => super.updateTree(dt / 2); +} + +// After +class SlowMotionArea extends Component implements CustomTraversal { + @override + void updateSubtree(double dt) => super.updateSubtree(dt / 2); +} +``` + +`HasTimeScale` usage is unchanged (`with HasTimeScale` still works; the mixin carries the marker +itself). To simply stop updating a subtree, the new `updatePaused` flag replaces the common +gating override: `component.updatePaused = true` pauses the update pass for the component and its +whole subtree while rendering, event handling, and lifecycle processing continue. + + +### `Route.stopTime()` no longer zeroes `timeScale` + +A stopped route is now paused through `updatePaused` instead of `timeScale = 0`: + +- While stopped, `timeScale` keeps its previous value, so a slow-motion factor survives a + stop/resume cycle. +- Assigning a new `timeScale` no longer resumes a stopped route; use `resumeTime()` or + `updatePaused = false`. +- Pending lifecycle events (adding or removing components) on a stopped route now still complete. diff --git a/examples/lib/stories/collision_detection/quadtree_example.dart b/examples/lib/stories/collision_detection/quadtree_example.dart index aacc93e352d..9cec3ac5b72 100644 --- a/examples/lib/stories/collision_detection/quadtree_example.dart +++ b/examples/lib/stories/collision_detection/quadtree_example.dart @@ -363,15 +363,16 @@ mixin GameCollidable on PositionComponent { //#region Utils +/// Lets the subtree update once and then pauses it; set [updateOnce] back to +/// true to let it update once more. mixin UpdateOnce on PositionComponent { - bool updateOnce = true; + bool get updateOnce => !updatePaused; + set updateOnce(bool value) => updatePaused = !value; @override - void updateTree(double dt) { - if (updateOnce) { - super.updateTree(dt); - updateOnce = false; - } + void update(double dt) { + super.update(dt); + updatePaused = true; } } diff --git a/examples/lib/stories/components/time_scale_example.dart b/examples/lib/stories/components/time_scale_example.dart index 567ffcbd957..53f68da120d 100644 --- a/examples/lib/stories/components/time_scale_example.dart +++ b/examples/lib/stories/components/time_scale_example.dart @@ -102,9 +102,9 @@ class _Chopper extends SpriteAnimationComponent } @override - void updateTree(double dt) { + void update(double dt) { position.setFrom(position + _moveDirection * _speed * dt); - super.updateTree(dt); + super.update(dt); } @override diff --git a/examples/lib/stories/experimental/layout_component_example_1.dart b/examples/lib/stories/experimental/layout_component_example_1.dart index 90e288d9583..f7ad4de8361 100644 --- a/examples/lib/stories/experimental/layout_component_example_1.dart +++ b/examples/lib/stories/experimental/layout_component_example_1.dart @@ -99,7 +99,7 @@ class LayoutDemo1 extends LinearLayoutComponent { _expandedMode = value; removeAll(children.toList()); addAll( - createComponentList( + createLayoutChildren( expandedMode: expandedMode, padding: padding, inflateChild: paddingInflateChild, @@ -122,7 +122,7 @@ class LayoutDemo1 extends LinearLayoutComponent { FutureOr onLoad() { super.onLoad(); addAll( - createComponentList( + createLayoutChildren( expandedMode: expandedMode, padding: padding, inflateChild: paddingInflateChild, @@ -137,7 +137,7 @@ class LayoutDemo1 extends LinearLayoutComponent { /// This needs to be a method rather than a static list /// because each of these components needs to be recreated. /// Otherwise, they'll be operated on by reference and re-parented. - static List createComponentList({ + static List createLayoutChildren({ required bool expandedMode, required EdgeInsets padding, required bool inflateChild, diff --git a/examples/lib/stories/experimental/layout_component_example_2.dart b/examples/lib/stories/experimental/layout_component_example_2.dart index 8b75ec7f331..15a5ebba3f7 100644 --- a/examples/lib/stories/experimental/layout_component_example_2.dart +++ b/examples/lib/stories/experimental/layout_component_example_2.dart @@ -82,7 +82,7 @@ class LayoutDemo2 extends LinearLayoutComponent { FutureOr onLoad() { super.onLoad(); addAll( - createComponentList( + createLayoutChildren( direction: direction, ), ); @@ -95,7 +95,7 @@ class LayoutDemo2 extends LinearLayoutComponent { /// This needs to be a method rather than a static list /// because each of these components needs to be recreated. /// Otherwise, they'll be operated on by reference and re-parented. - static List createComponentList({ + static List createLayoutChildren({ required Direction direction, }) { return [ diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index 134b3554c2c..5f090af6bdc 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -12,6 +12,7 @@ export 'src/components/components_notifier.dart'; export 'src/components/core/component.dart'; export 'src/components/core/component_key.dart'; export 'src/components/core/component_render_context.dart'; +export 'src/components/core/custom_traversal.dart'; export 'src/components/custom_painter_component.dart'; export 'src/components/fps_component.dart'; export 'src/components/fps_text_component.dart'; diff --git a/packages/flame/lib/src/camera/camera_component.dart b/packages/flame/lib/src/camera/camera_component.dart index 8b0998f77a7..b96dd9a1c5c 100644 --- a/packages/flame/lib/src/camera/camera_component.dart +++ b/packages/flame/lib/src/camera/camera_component.dart @@ -203,16 +203,6 @@ class CameraComponent extends Component { canvas.save(); try { currentCameras.add(this); - void renderWorld(Canvas canvas) { - canvas.transform2D(viewfinder.transform); - world!.renderFromCamera(canvas); - - // Render the viewfinder elements, which will be in front of - // the world, - // but with the same transforms applied to them. - viewfinder.renderTree(canvas); - } - final postProcessors = children.query(); if (postProcessors.isNotEmpty) { assert( @@ -223,13 +213,11 @@ class CameraComponent extends Component { postProcessor.render( canvas, viewport.virtualSize, - renderWorld, - (context) { - renderContext.currentPostProcess = context; - }, + _renderWorld, + _updatePostProcessContext, ); } else { - renderWorld(canvas); + _renderWorld(canvas); } } finally { currentCameras.removeLast(); @@ -242,6 +230,24 @@ class CameraComponent extends Component { canvas.restore(); } + /// Renders the world and the viewfinder elements through the camera + /// transform. An instance method rather than a local function, so that the + /// render pass does not allocate a closure per camera per frame. + void _renderWorld(Canvas canvas) { + canvas.transform2D(viewfinder.transform); + world!.renderFromCamera(canvas); + // Render the viewfinder elements, which will be in front of the world, + // but with the same transforms applied to them. + viewfinder.renderTree(canvas); + } + + // Not a setter: this is passed as a `ValueSetter` tear-off to + // `PostProcess.render`. + // ignore: use_setters_to_change_properties + void _updatePostProcessContext(PostProcess? context) { + renderContext.currentPostProcess = context; + } + /// Converts from the global (canvas) coordinate space to /// local (camera = viewport + viewfinder). /// diff --git a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart index f79cc6e33e0..e446d5ce2b3 100644 --- a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart +++ b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart @@ -17,7 +17,21 @@ class Sweep> extends Broadphase { @override void update() { - items.sort((a, b) => a.aabb.min.x.compareTo(b.aabb.min.x)); + // Between two ticks the hitboxes only move a little, so [items] is + // always nearly sorted: an insertion sort runs in close to linear time + // here, where a general-purpose sort would pay its full O(n log n) on + // every tick. This also avoids allocating a comparator closure per tick. + final items = this.items; + for (var i = 1; i < items.length; i++) { + final item = items[i]; + final minX = item.aabb.min.x; + var previousIndex = i - 1; + while (previousIndex >= 0 && items[previousIndex].aabb.min.x > minX) { + items[previousIndex + 1] = items[previousIndex]; + previousIndex--; + } + items[previousIndex + 1] = item; + } } @override @@ -44,7 +58,10 @@ class Sweep> extends Broadphase { _prospectPool.acquire(item, activeItem); } } else { - _active.remove(activeItem); + // The order of the active list does not matter, so the removal can + // swap in the last element instead of searching and shifting. + _active[i] = _active.last; + _active.removeLast(); } } _active.add(item); diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index c8198d496d5..6362dff944f 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -10,8 +10,8 @@ import 'package:flame/src/components/core/component_tree_root.dart'; import 'package:flame/src/effects/provider_interfaces.dart'; import 'package:flutter/painting.dart'; import 'package:meta/meta.dart'; -import 'package:ordered_set/ordered_set.dart'; -import 'package:ordered_set/read_only_ordered_set.dart'; + +part 'component_list.dart'; /// [Component]s are the basic building blocks for a [FlameGame]. /// @@ -73,6 +73,7 @@ class Component { int? priority, this.key, }) : _priority = priority ?? 0 { + _isTraversalBarrier = this is CustomTraversal; if (children != null) { addAll(children); } @@ -272,9 +273,9 @@ class Component { /// /// ```dart /// coin.parent = inventory; - /// // The inventory.children set does not include coin yet. + /// // The inventory.children list does not include coin yet. /// await game.lifecycleEventsProcessed; - /// // The inventory.children set now includes coin. + /// // The inventory.children list now includes coin. /// ``` Component? get parent => _parent; Component? _parent; @@ -287,57 +288,57 @@ class Component { } /// This field should be used internally for functionality when you don't need - /// to create a component set for the children if one doesn't already exist. + /// to create a children list if one doesn't already exist. /// /// This makes it possible to have lighter components that don't have any /// children. - OrderedSet? _children; + ComponentList? _children; + + /// The [ComponentList] that currently stores this component, if any. + /// Maintained by [ComponentList]. + ComponentList? _containerList; + + /// This component's slot index within [_containerList]'s backing array, or + /// -1 when the component is not in any container. Maintained by + /// [ComponentList]. + int _containerIndex = -1; /// This field should be used internally for functionality when you need to - /// make sure that the component set is created if it doesn't already exist. - OrderedSet get _internalChildren => - _children ??= createComponentSet(); + /// make sure that the children list is created if it doesn't already exist. + ComponentList get _internalChildren => _children ??= createComponentList(); - void rebalanceChildren() { - if (_children != null) { - _children!.rebalanceAll(); - } - } + /// Restores the priority ordering of the [children], after one or more of + /// them have changed their [priority]. + void rebalanceChildren() => _children?.rebalance(); /// The children components of this component. /// - /// This getter will automatically create the [OrderedSet] container within + /// This getter will automatically create the [ComponentList] container within /// the current object if it didn't exist before. Check the [hasChildren] /// property in order to avoid instantiating the children container. - ReadOnlyOrderedSet get children => - _children ??= createComponentSet(); + ComponentList get children => _children ??= createComponentList(); /// Whether this component has any children. /// Avoids the creation of the children container if not necessary. bool get hasChildren => _children?.isNotEmpty ?? false; - /// `Component.childrenFactory` is the default method for creating children - /// containers within all components. Replace this method if you want to have - /// customized (non-default) [OrderedSet] instances in your project. - static OrderedSet Function() childrenFactory = () { - return OrderedSet.mapping( - _componentPriorityMapper, - strictMode: strictQueryMode, - ); - }; - - /// Whether OrderedSet's strict mode mode should be enabled for all children - /// sets. + /// Whether the strict query mode should be enabled for all children lists; + /// see [ComponentList.strictMode]. static bool strictQueryMode = false; - static num _componentPriorityMapper(Component component) { - return component.priority; - } - /// This method creates the children container for the current component. - /// Override this method if you need to have a custom [OrderedSet] within - /// a particular class. - OrderedSet createComponentSet() => childrenFactory(); + /// Override this method if you need a customized [ComponentList] for a + /// particular class, for example one with a custom ordering: + /// + /// ```dart + /// @override + /// ComponentList createComponentList() { + /// return ComponentList( + /// comparator: (a, b) => a.someValue.compareTo(b.someValue), + /// ); + /// } + /// ``` + ComponentList createComponentList() => ComponentList(); /// Returns the closest parent further up the hierarchy that satisfies type=T, /// or null if no such parent can be found. @@ -514,7 +515,7 @@ class Component { /// its [children] yet. /// /// After this method completes, the component is added to the parent's - /// children set, and then the flag [isMounted] set to true. + /// children list, and then the flag [isMounted] set to true. /// /// Example: /// ```dart @@ -562,12 +563,125 @@ class Component { /// This method traverses the component tree and calls [update] on all its /// children according to their [priority] order, relative to the /// priority of the direct siblings, not the children or the ancestors. + /// + /// This method is non-virtual: components that need to customize how their + /// subtree is traversed (changing the effective [dt], skipping children, + /// or updating them manually) should mix in `CustomTraversal` and override + /// its `updateSubtree` method instead. The marker mixin lets the engine's + /// flattened update pass treat such components as traversal barriers + /// instead of silently skipping their custom logic. + @nonVirtual void updateTree(double dt) { + if (_updatePaused) { + return; + } + if (_isTraversalBarrier) { + updateSubtree(dt); + } else { + defaultUpdateSubtree(dt); + } + } + + /// Updates this component and its subtree. + /// + /// The engine only invokes this method for components that are marked with + /// the [CustomTraversal] mixin, either directly or through a mixin that + /// `implements` it (such as `HasTimeScale`). The marker is what makes the + /// flattened update pass treat the component as a traversal barrier; + /// overriding this method without the marker has no effect. + /// + /// Call `super.updateSubtree` to run the surrounding traversal (the + /// standard one, or the next custom traversal in the mixin chain), + /// possibly with a modified time delta. + void updateSubtree(double dt) => defaultUpdateSubtree(dt); + + /// Whether the update pass is paused for this component and its entire + /// subtree. + /// + /// While `true`, neither this component's [update] nor any update of its + /// descendants will run; rendering and event handling continue as usual. + /// This is a lighter-weight alternative to removing the subtree, and is + /// unrelated to `Game.paused`, which stops the whole game loop. + /// + /// Toggling this takes effect from the next update pass. + bool get updatePaused => _updatePaused; + bool _updatePaused = false; + set updatePaused(bool value) { + if (_updatePaused != value) { + _updatePaused = value; + // The flattened update list excludes paused subtrees, so a toggle must + // invalidate it. + ComponentList.structureVersion++; + } + } + + /// Whether this component manages its own subtree traversal. Evaluated + /// once in the constructor, so that the per-frame traversal loops pay a + /// plain field load instead of a type check. + bool _isTraversalBarrier = false; + + /// Runs one update pass over a flattened traversal list produced by + /// [updateAndFlattenInto]. + @internal + static void updateFlatList(List list, double dt) { + for (var i = 0; i < list.length; i++) { + final component = list[i]; + if (component._isTraversalBarrier) { + component.updateSubtree(dt); + } else { + component.update(dt); + } + } + } + + /// Combined update pass and flatten: updates this component's subtree + /// recursively while appending the visited components to [out], in + /// pre-order with children in priority order, stopping at (but including) + /// `CustomTraversal` barriers and skipping paused subtrees. Used by the + /// root on ticks where the structure changed, so that the flat-list + /// rebuild does not cost a separate pass over the tree. + @internal + void updateAndFlattenInto(List out, double dt) { + final children = _children; + if (children == null) { + return; + } + children._compact(); + final elements = children._elements; + for (var i = 0; i < elements.length; i++) { + final child = elements[i]; + if (child == null || child._updatePaused) { + continue; + } + out.add(child); + if (child._isTraversalBarrier) { + child.updateSubtree(dt); + } else { + child.update(dt); + child.updateAndFlattenInto(out, dt); + } + } + } + + /// The engine's standard update traversal: update this component, then + /// update the children in priority order. + /// + /// This is the default behavior of `CustomTraversal.updateSubtree`; + /// custom traversals can call it to delegate to the standard behavior. + @protected + void defaultUpdateSubtree(double dt) { update(dt); final children = _children; if (children != null) { - for (final child in children) { - child.updateTree(dt); + // The update pass doubles as the safe point where tombstones left by + // removals since the last tick are compacted away. + children._compact(); + final elements = children._elements; + for (var i = 0; i < elements.length; i++) { + final child = elements[i]; + if (child != null && !child._updatePaused) { + child.updateTree(dt); + } } } } @@ -587,19 +701,16 @@ class Component { /// cleans them up afterwards. @protected void renderChild(Canvas canvas, Component child) { - int? originalLength; - final hasContext = _renderContexts.isNotEmpty; - if (hasContext) { - originalLength = child._renderContexts.length; - child._renderContexts.addAll(_renderContexts); + final contexts = _renderContexts; + if (contexts == null || contexts.isEmpty) { + child.renderTree(canvas); + return; } + final childContexts = child._renderContexts ??= []; + final originalLength = childContexts.length; + childContexts.addAll(contexts); child.renderTree(canvas); - if (hasContext) { - child._renderContexts.removeRange( - originalLength!, - child._renderContexts.length, - ); - } + childContexts.removeRange(originalLength, childContexts.length); } /// Called once after all children have been rendered in [renderTree]. @@ -612,14 +723,18 @@ class Component { void renderTree(Canvas canvas) { final context = renderContext; if (context != null) { - _renderContexts.add(context); + (_renderContexts ??= []).add(context); } render(canvas); final children = _children; if (children != null) { - for (final child in children) { - renderChild(canvas, child); + final elements = children._elements; + for (var i = 0; i < elements.length; i++) { + final child = elements[i]; + if (child != null) { + renderChild(canvas, child); + } } afterChildrenRendered(canvas); } @@ -630,7 +745,7 @@ class Component { } if (context != null) { - _renderContexts.removeLast(); + _renderContexts!.removeLast(); } } @@ -856,8 +971,10 @@ class Component { ) sync* { nestedContexts?.add(locationContext); if (_children != null) { - for (final child in _children!.reversed()) { - if (child is IgnoreEvents && child.ignoreEvents) { + final elements = _children!._elements; + for (var i = elements.length - 1; i >= 0; i--) { + final child = elements[i]; + if (child == null || (child is IgnoreEvents && child.ignoreEvents)) { continue; } T? childPoint = locationContext; @@ -1120,26 +1237,46 @@ class Component { _removeCompleter = null; } + /// Reusable buffer for [_remove]. A fresh list is used in the rare case of + /// a re-entrant teardown (e.g. a nested game disposed from `onRemove`). + static final List _teardownBuffer = []; + void _remove(Component parent) { parent._internalChildren.remove(this); - propagateToChildren( - (Component component) { - component - ..onRemove() - .._unregisterKey() - .._clearMountedBit() - .._clearRemovingBit() - .._setRemovedBit() - .._removeCompleter?.complete() - .._removeCompleter = null - .._parent!.onChildrenChanged(component, ChildrenChangeType.removed); - return true; - }, - includeSelf: true, - ); + final buffer = _teardownBuffer.isEmpty ? _teardownBuffer : []; + _collectTeardown(buffer); + for (var i = 0; i < buffer.length; i++) { + final component = buffer[i]; + component + ..onRemove() + .._unregisterKey() + .._clearMountedBit() + .._clearRemovingBit() + .._setRemovedBit() + .._removeCompleter?.complete() + .._removeCompleter = null + .._parent!.onChildrenChanged(component, ChildrenChangeType.removed); + } + buffer.clear(); _parent = null; } + /// Collects this component and all its descendants into [out] in teardown + /// order: leaves first, siblings in reverse order, ancestors after their + /// subtrees. This matches the order that + /// `descendants(reversed: true, includeSelf: true)` would produce, without + /// allocating generator frames on every removal. + void _collectTeardown(List out) { + final children = _children; + if (children != null) { + final elements = children._elements; + for (var i = elements.length - 1; i >= 0; i--) { + elements[i]?._collectTeardown(out); + } + } + out.add(this); + } + void _unregisterKey() { if (key != null) { final game = findGame(); @@ -1153,14 +1290,16 @@ class Component { //#region Context - final QueueList _renderContexts = QueueList(); + /// The stack of render contexts inherited from ancestors during the render + /// pass. Created lazily: most components never provide or receive one. + List? _renderContexts; /// Override this method if you want your component to provide a custom /// render context to all its children (recursively). ComponentRenderContext? get renderContext => null; T? findRenderContext() { - return _renderContexts.whereType().lastOrNull; + return _renderContexts?.whereType().lastOrNull; } //#endregion @@ -1191,8 +1330,9 @@ class Component { /// The color that the debug output should be rendered with. Color debugColor = const Color(0xFFFF00FF); - final ValueCache _debugPaintCache = ValueCache(); - final ValueCache _debugTextPaintCache = ValueCache(); + late final ValueCache _debugPaintCache = ValueCache(); + late final ValueCache _debugTextPaintCache = + ValueCache(); /// The [debugColor] represented as a [Paint] object. Paint get debugPaint { diff --git a/packages/flame/lib/src/components/core/component_list.dart b/packages/flame/lib/src/components/core/component_list.dart new file mode 100644 index 00000000000..033a92127b8 --- /dev/null +++ b/packages/flame/lib/src/components/core/component_list.dart @@ -0,0 +1,561 @@ +part of 'component.dart'; + +/// A fast, ordered container for a [Component]'s children. +/// +/// The children are stored in a single flat [List], sorted by +/// [Component.priority] (or by a custom comparator, see +/// [Component.createComponentList]); components that compare equal keep the +/// order in which they were added. This is the same ordering that the old +/// `OrderedSet`-based container provided, but backed by a contiguous array +/// instead of a splay tree of hash sets, which makes iteration, additions, +/// removals and reorders significantly faster and allocation-free. +/// +/// Performance characteristics: +/// - iteration: O(n) over a contiguous array, without any allocations on the +/// internal engine paths; +/// - [add]: O(1) when the component sorts at or after the end, which is the +/// common case, and O(n) for a mid-list insertion; +/// - [remove] and [contains]: O(1), using the slot index that is stored on +/// the component itself; +/// - [rebalance] (after priority changes): O(n) when the list turns out to +/// still be sorted, otherwise O(n log n) with a stable sort. +/// +/// A removal does not shift the array, it leaves a `null` "tombstone" in +/// place. Tombstones are invisible to iteration and are compacted away at the +/// start of the next update tick, or earlier if they grow to dominate the +/// array. +/// +/// Mutating the container while iterating it is allowed in the ways that the +/// component lifecycle needs: removals take effect immediately (the removed +/// component is simply not visited), and additions at the end may or may not +/// be visited by an ongoing iteration. Operations that shift the positions of +/// existing elements (a mid-list insertion, [rebalance], or tombstone +/// compaction) invalidate live iterators, which will then throw a +/// [ConcurrentModificationError]. +/// +/// The contents of this container are managed by the [Component] lifecycle: +/// use [Component.add], [Component.remove] and their related methods to +/// modify which components are in it. +class ComponentList extends Iterable { + ComponentList({bool? strictMode, this.comparator}) + : strictMode = strictMode ?? Component.strictQueryMode; + + /// Whether calling [query] for an unregistered type throws an error + /// (`true`), or transparently registers the type on first use (`false`). + final bool strictMode; + + /// An optional custom ordering, replacing the default ordering by + /// [Component.priority]. Supply one via [Component.createComponentList]. + /// + /// The ordering is stable in both cases: elements that compare equal keep + /// their insertion order. If the values that the comparator reads change + /// after insertion, call [Component.rebalanceChildren] to restore the + /// ordering (priority changes on mounted components do this automatically). + final Comparator? comparator; + + /// The relative order of [a] and [b]: by the custom [comparator] if one + /// was supplied, otherwise by [Component.priority]. + int _compareOrder(Component a, Component b) { + final comparator = this.comparator; + if (comparator == null) { + final priorityA = a._priority; + final priorityB = b._priority; + return priorityA < priorityB + ? -1 + : priorityA > priorityB + ? 1 + : 0; + } + return comparator(a, b); + } + + /// The backing store; `null` entries are tombstones left by removals. + final List _elements = []; + + /// The number of live (non-tombstone) elements. + int _length = 0; + + /// The number of tombstones currently present in [_elements]. + int _tombstones = 0; + + /// Incremented whenever existing elements change their position within + /// [_elements] (mid-list insertion, sorting, or compaction). Iterators use + /// this to detect concurrent structural modification. + int _shiftCount = 0; + + /// Compaction is deferred until this many tombstones have accumulated + /// (unless the whole list empties out, or a structural operation needs a + /// dense array anyway). + static const int _tombstoneCompactionThreshold = 16; + + /// The per-type query caches, created by [register], keyed by type for + /// [query] lookups. + Map>? _queries; + + /// The same caches as [_queries], as a list, so that the add/remove hot + /// paths can iterate them without allocating a map-values iterator. + List<_QueryCache>? _queryCaches; + + /// A monotonically increasing counter, bumped on every membership or order + /// change of any [ComponentList] (adds, removes, clears, reorders). The + /// root's flattened update list compares against this to know when it must + /// be rebuilt. Note that this is bumped by structural changes anywhere, + /// including in other games or detached trees, so a bump means "possibly + /// changed", never the reverse. + @internal + static int structureVersion = 0; + + @override + int get length => _length; + + @override + bool get isEmpty => _length == 0; + + @override + bool get isNotEmpty => _length != 0; + + @override + Iterator get iterator => _ComponentListIterator(this); + + /// The elements of this list in reverse order. + /// + /// Unlike the rest of the [Iterable] interface, this is a method and not a + /// getter, for historical reasons. The returned iterable is a lazy view: it + /// costs nothing to create and always reflects the current contents. + Iterable reversed() => _ReversedComponentListView(this); + + @override + bool contains(Object? element) { + return element is Component && identical(element._containerList, this); + } + + @override + Component get first { + final elements = _elements; + for (var i = 0; i < elements.length; i++) { + final element = elements[i]; + if (element != null) { + return element; + } + } + throw StateError('No element'); + } + + @override + Component get last { + final elements = _elements; + for (var i = elements.length - 1; i >= 0; i--) { + final element = elements[i]; + if (element != null) { + return element; + } + } + throw StateError('No element'); + } + + @override + Component elementAt(int index) { + RangeError.checkNotNegative(index, 'index'); + final elements = _elements; + if (_tombstones == 0) { + if (index >= elements.length) { + throw IndexError.withLength(index, _length, indexable: this); + } + return elements[index]!; + } + var live = 0; + for (var i = 0; i < elements.length; i++) { + final element = elements[i]; + if (element != null && live++ == index) { + return element; + } + } + throw IndexError.withLength(index, _length, indexable: this); + } + + @override + void forEach(void Function(Component element) action) { + final elements = _elements; + for (var i = 0; i < elements.length; i++) { + final element = elements[i]; + if (element != null) { + action(element); + } + } + } + + /// Adds [component] to this list, keeping the priority ordering; returns + /// whether the component was added (`false` if it already was in the list). + /// + /// This is internal machinery: adding a component here does not make it go + /// through the component lifecycle. Use [Component.add] instead. + @internal + bool add(Component component) { + if (identical(component._containerList, this)) { + return false; + } + assert( + component._containerList == null, + 'A component cannot be contained by two children containers at once', + ); + final elements = _elements; + if (_length == 0 && elements.isNotEmpty) { + // The array contains only tombstones; reset it. + elements.clear(); + _tombstones = 0; + } + var lastIndex = elements.length - 1; + while (lastIndex >= 0 && elements[lastIndex] == null) { + lastIndex--; + } + if (lastIndex >= 0 && _compareOrder(elements[lastIndex]!, component) > 0) { + _insertSorted(component); + } else { + component._containerIndex = elements.length; + elements.add(component); + } + component._containerList = this; + _length++; + structureVersion++; + final caches = _queryCaches; + if (caches != null) { + for (var i = 0; i < caches.length; i++) { + final cache = caches[i]; + if (cache.check(component)) { + cache.insertSorted(component); + } + } + } + return true; + } + + /// Inserts [component] before all existing elements that sort after it, + /// and after all elements that sort the same or before it. + void _insertSorted(Component component) { + _compact(); + final elements = _elements; + var low = 0; + var high = elements.length; + while (low < high) { + final middle = (low + high) >>> 1; + if (_compareOrder(elements[middle]!, component) <= 0) { + low = middle + 1; + } else { + high = middle; + } + } + elements.insert(low, component); + component._containerIndex = low; + for (var i = low + 1; i < elements.length; i++) { + elements[i]!._containerIndex = i; + } + _shiftCount++; + } + + /// Removes [component] from this list; returns whether it was present. + /// + /// This is internal machinery: removing a component here does not make it + /// go through the component lifecycle. Use [Component.remove] instead. + @internal + bool remove(Component component) { + if (!identical(component._containerList, this)) { + return false; + } + final index = component._containerIndex; + assert(identical(_elements[index], component)); + _elements[index] = null; + component._containerList = null; + component._containerIndex = -1; + _length--; + _tombstones++; + structureVersion++; + final caches = _queryCaches; + if (caches != null) { + for (var i = 0; i < caches.length; i++) { + final cache = caches[i]; + if (cache.check(component)) { + cache.data.remove(component); + } + } + } + if (_length == 0) { + _elements.clear(); + _tombstones = 0; + } else if (_tombstones >= _tombstoneCompactionThreshold && + _tombstones * 2 >= _elements.length) { + _compact(); + } + return true; + } + + /// Removes all elements from this list. + /// + /// This is internal machinery: clearing this list does not make the + /// components go through the component lifecycle. Use [Component.removeAll] + /// instead. + @internal + void clear() { + final elements = _elements; + for (var i = 0; i < elements.length; i++) { + final element = elements[i]; + if (element != null) { + element._containerList = null; + element._containerIndex = -1; + } + } + elements.clear(); + _length = 0; + _tombstones = 0; + _shiftCount++; + structureVersion++; + final caches = _queryCaches; + if (caches != null) { + for (var i = 0; i < caches.length; i++) { + caches[i].data.clear(); + } + } + } + + /// Restores the priority ordering after one or more elements have changed + /// their [Component.priority]. + /// + /// This is invoked automatically, at most once per parent per game tick, + /// when priorities of mounted components change. The sort is stable: + /// components with equal priority keep their relative order. + void rebalance() { + _compact(); + final elements = _elements; + var isSorted = true; + for (var i = 1; i < elements.length; i++) { + if (_compareOrder(elements[i - 1]!, elements[i]!) > 0) { + isSorted = false; + break; + } + } + if (isSorted) { + return; + } + _shiftCount++; + structureVersion++; + // Ties are broken by the pre-sort index, which makes the (unstable) + // built-in sort behave as a stable sort. + elements.sort((a, b) { + final byOrder = _compareOrder(a!, b!); + return byOrder != 0 ? byOrder : a._containerIndex - b._containerIndex; + }); + for (var i = 0; i < elements.length; i++) { + elements[i]!._containerIndex = i; + } + final caches = _queryCaches; + if (caches != null) { + for (var i = 0; i < caches.length; i++) { + caches[i].resort(); + } + } + } + + /// Rewrites the backing array without its tombstones, restoring exact + /// element indices. + void _compact() { + if (_tombstones == 0) { + return; + } + final elements = _elements; + var write = 0; + for (var read = 0; read < elements.length; read++) { + final element = elements[read]; + if (element != null) { + if (write != read) { + elements[write] = element; + element._containerIndex = write; + } + write++; + } + } + elements.length = write; + _tombstones = 0; + _shiftCount++; + } + + /// Whether type [C] has been registered as a queryable type. + bool isRegistered() { + return _queries?.containsKey(C) ?? false; + } + + /// Registers [C] as a queryable type, so that [query] can answer in O(1). + /// + /// If the type is already registered this is a no-op. Registering a type on + /// a non-empty list costs one pass over the existing elements, so it is + /// recommended to register the desired types as early as possible. + void register() { + final queries = _queries ??= {}; + if (queries.containsKey(C)) { + return; + } + final data = []; + final elements = _elements; + for (var i = 0; i < elements.length; i++) { + final element = elements[i]; + if (element is C) { + data.add(element); + } + } + final cache = _QueryCache(data); + queries[C] = cache; + (_queryCaches ??= []).add(cache); + } + + /// All elements of type [C], in priority order, in O(1). + /// + /// The type [C] must have been [register]ed beforehand, unless [strictMode] + /// is false, in which case the registration happens on the first query. + Iterable query() { + final cache = _queries?[C]; + if (cache == null) { + if (strictMode) { + throw StateError('Cannot query unregistered query $C'); + } + register(); + return query(); + } + // The cached list itself is returned, but typed as an Iterable to prevent + // accidental modification of the cache from the outside. + return cache.data as Iterable; + } + + @override + Iterable whereType() { + final cache = _queries?[C]; + if (cache != null) { + return cache.data as Iterable; + } + return super.whereType(); + } +} + +class _ComponentListIterator implements Iterator { + _ComponentListIterator(this._list) : _shiftCount = _list._shiftCount; + + final ComponentList _list; + final int _shiftCount; + int _index = -1; + Component? _current; + + @override + Component get current => _current!; + + @pragma('vm:prefer-inline') + @pragma('wasm:prefer-inline') + @override + bool moveNext() { + final list = _list; + if (list._shiftCount != _shiftCount) { + throw ConcurrentModificationError(list); + } + final elements = list._elements; + for (var i = _index + 1; i < elements.length; i++) { + final element = elements[i]; + if (element != null) { + _index = i; + _current = element; + return true; + } + } + _index = elements.length; + _current = null; + return false; + } +} + +class _ReversedComponentListView extends Iterable { + _ReversedComponentListView(this._list); + + final ComponentList _list; + + @override + int get length => _list._length; + + @override + bool get isEmpty => _list._length == 0; + + @override + bool get isNotEmpty => _list._length != 0; + + @override + Iterator get iterator => _ReversedComponentListIterator(_list); +} + +class _ReversedComponentListIterator implements Iterator { + _ReversedComponentListIterator(ComponentList list) + : _list = list, + _shiftCount = list._shiftCount, + _index = list._elements.length; + + final ComponentList _list; + final int _shiftCount; + int _index; + Component? _current; + + @override + Component get current => _current!; + + @pragma('vm:prefer-inline') + @pragma('wasm:prefer-inline') + @override + bool moveNext() { + final list = _list; + if (list._shiftCount != _shiftCount) { + throw ConcurrentModificationError(list); + } + final elements = list._elements; + for (var i = _index - 1; i >= 0; i--) { + final element = elements[i]; + if (element != null) { + _index = i; + _current = element; + return true; + } + } + _index = -1; + _current = null; + return false; + } +} + +/// A cached, always up-to-date result of `query()`: the subset of the +/// elements that are of type [C], in the same order as the main array. +class _QueryCache { + _QueryCache(this.data); + + final List data; + + bool check(Component component) => component is C; + + /// Inserts [component] into [data], keeping it ordered consistently with + /// the main backing array (which orders by priority). + void insertSorted(Component component) { + final list = data; + final index = component._containerIndex; + if (list.isEmpty || list.last._containerIndex < index) { + list.add(component as C); + return; + } + var low = 0; + var high = list.length; + while (low < high) { + final middle = (low + high) >>> 1; + if (list[middle]._containerIndex < index) { + low = middle + 1; + } else { + high = middle; + } + } + list.insert(low, component as C); + } + + /// Re-sorts the cache after the main array has been re-sorted (at which + /// point every element's index is up to date again). + void resort() { + data.sort((a, b) => a._containerIndex - b._containerIndex); + } +} diff --git a/packages/flame/lib/src/components/core/component_tree_root.dart b/packages/flame/lib/src/components/core/component_tree_root.dart index f5b0b44e913..4cdd8b470a3 100644 --- a/packages/flame/lib/src/components/core/component_tree_root.dart +++ b/packages/flame/lib/src/components/core/component_tree_root.dart @@ -109,6 +109,38 @@ class ComponentTreeRoot extends Component { bool get hasLifecycleEvents => queue.isNotEmpty; + /// The flattened pre-order list of all components below this root, stopping + /// at (and including) `CustomTraversal` barriers, whose subtrees are + /// traversed by their own `updateSubtree` implementations. + final List _flatUpdateList = []; + + /// The [ComponentList.structureVersion] that [_flatUpdateList] was built + /// against. + int _flatVersion = -1; + + /// Updates every component below this root using the flattened traversal + /// list, rebuilding the list first when the tree structure has possibly + /// changed since the previous tick. + /// + /// The visit order is identical to the recursive + /// `Component.defaultUpdateSubtree` traversal: pre-order, children in + /// priority order. Components mixing in `CustomTraversal` are treated as + /// barriers: they appear in the list themselves, and their `updateSubtree` + /// drives their subtree. + @internal + void updateChildrenFlat(double dt) { + if (_flatVersion != ComponentList.structureVersion) { + // The version is captured before the pass: structural changes made by + // update callbacks (pause toggles, detached-tree edits) invalidate the + // list that is being built and must trigger a rebuild next tick. + _flatVersion = ComponentList.structureVersion; + _flatUpdateList.clear(); + updateAndFlattenInto(_flatUpdateList, dt); + } else { + Component.updateFlatList(_flatUpdateList, dt); + } + } + /// A future that will complete once all lifecycle events have been /// processed. /// @@ -137,10 +169,16 @@ class ComponentTreeRoot extends Component { } void processLifecycleEvents() { + if (!hasLifecycleEvents) { + // The completer is only ever created while events are queued, so there + // is nothing to complete here either. + assert(_lifecycleEventsCompleter == null); + return; + } // reorder events to process later grouped by parent - final reorderParents = {}; + Set? reorderParents; LifecycleEventStatus handleReorderEvent(Component parent) { - reorderParents.add(parent); + (reorderParents ??= {}).add(parent); return LifecycleEventStatus.done; } @@ -151,7 +189,8 @@ class ComponentTreeRoot extends Component { for (final event in queue) { final child = event.child!; final parent = event.parent!; - if (_blocked.contains(child) || _blocked.contains(parent)) { + if (_blocked.isNotEmpty && + (_blocked.contains(child) || _blocked.contains(parent))) { continue; } @@ -176,8 +215,10 @@ class ComponentTreeRoot extends Component { _blocked.clear(); } - for (final parent in reorderParents) { - parent.rebalanceChildren(); + if (reorderParents != null) { + for (final parent in reorderParents!) { + parent.rebalanceChildren(); + } } if (!hasLifecycleEvents && _lifecycleEventsCompleter != null) { diff --git a/packages/flame/lib/src/components/core/custom_traversal.dart b/packages/flame/lib/src/components/core/custom_traversal.dart new file mode 100644 index 00000000000..20a25b91bf0 --- /dev/null +++ b/packages/flame/lib/src/components/core/custom_traversal.dart @@ -0,0 +1,27 @@ +import 'package:flame/src/components/core/component.dart'; + +/// The marker interface for components that manage the update traversal of +/// their own subtree, instead of the engine's standard "update self, then +/// update every child in priority order" recursion. +/// +/// Implement this (and override [Component.updateSubtree]) to customize the +/// traversal. The interface is both the capability and the barrier marker: the +/// engine's flattened update pass treats every [CustomTraversal] component +/// as a barrier, calls its [Component.updateSubtree], and lets that method +/// drive the subtree. This is also why [Component.updateTree] is +/// non-virtual: an updateTree override without a marker would be silently +/// skipped by the flattened traversal. +/// +/// ```dart +/// class SlowMotionArea extends Component implements CustomTraversal { +/// @override +/// void updateSubtree(double dt) => super.updateSubtree(dt / 2); +/// } +/// ``` +/// +/// Mixins that provide a custom traversal (like `HasTimeScale`) should be +/// declared `on Component implements CustomTraversal`: the `implements` +/// carries the marker, so that their users do not need to add +/// [CustomTraversal] themselves, and `super.updateSubtree` chains through +/// any other custom traversal in the mixin application order. +abstract interface class CustomTraversal implements Component {} diff --git a/packages/flame/lib/src/components/mixins/has_decorator.dart b/packages/flame/lib/src/components/mixins/has_decorator.dart index b3da7be9104..9617ad1a773 100644 --- a/packages/flame/lib/src/components/mixins/has_decorator.dart +++ b/packages/flame/lib/src/components/mixins/has_decorator.dart @@ -16,12 +16,16 @@ import 'package:flame/src/rendering/decorator.dart'; mixin HasDecorator on Component { Decorator? decorator; + /// Cached `super.renderTree` tear-off, so that the render pass does not + /// allocate a fresh closure for [Decorator.applyChain] on every frame. + void Function(Canvas)? _superRenderTree; + @override void renderTree(Canvas canvas) { if (decorator == null) { super.renderTree(canvas); } else { - decorator!.applyChain(super.renderTree, canvas); + decorator!.applyChain(_superRenderTree ??= super.renderTree, canvas); } } } diff --git a/packages/flame/lib/src/components/mixins/has_time_scale.dart b/packages/flame/lib/src/components/mixins/has_time_scale.dart index 7d3ab9a49a6..02a316d870c 100644 --- a/packages/flame/lib/src/components/mixins/has_time_scale.dart +++ b/packages/flame/lib/src/components/mixins/has_time_scale.dart @@ -1,11 +1,12 @@ import 'package:flame/src/components/core/component.dart'; +import 'package:flame/src/components/core/custom_traversal.dart'; /// This mixin allows components to control their speed as compared to the /// normal speed. Only framerate independent logic will benefit from [timeScale] /// changes. /// /// Note: Modified [timeScale] will be applied to all children as well. -mixin HasTimeScale on Component { +mixin HasTimeScale on Component implements CustomTraversal { /// The ratio of components tick speed and normal tick speed. /// It defaults to 1.0, which means the component moves normally. /// A value of 0.5 means the component moves half the normal speed @@ -26,13 +27,8 @@ mixin HasTimeScale on Component { } @override - void update(double dt) { - super.update(dt * (parent == null ? _timeScale : 1.0)); - } - - @override - void updateTree(double dt) { - super.updateTree(dt * (parent != null ? _timeScale : 1.0)); + void updateSubtree(double dt) { + super.updateSubtree(dt * _timeScale); } /// Pauses the component by setting the time scale to 0.0. diff --git a/packages/flame/lib/src/components/position_component.dart b/packages/flame/lib/src/components/position_component.dart index 4eb3c2fa08c..80f74c1ca41 100644 --- a/packages/flame/lib/src/components/position_component.dart +++ b/packages/flame/lib/src/components/position_component.dart @@ -518,9 +518,13 @@ class PositionComponent extends Component } } + /// Cached `super.renderTree` tear-off, so that the render pass does not + /// allocate a fresh closure for [Decorator.applyChain] on every frame. + void Function(Canvas)? _superRenderTree; + @override void renderTree(Canvas canvas) { - decorator.applyChain(super.renderTree, canvas); + decorator.applyChain(_superRenderTree ??= super.renderTree, canvas); } @internal diff --git a/packages/flame/lib/src/components/router/route.dart b/packages/flame/lib/src/components/router/route.dart index 76beebde07d..d76d4adcf06 100644 --- a/packages/flame/lib/src/components/router/route.dart +++ b/packages/flame/lib/src/components/router/route.dart @@ -90,16 +90,20 @@ class Route extends PositionComponent /// Completely stops time for the managed page. /// - /// When the time is stopped, the [updateTree] method of the page is not - /// called at all, which can save computational resources. However, this also - /// means that the lifecycle events on the page will not be processed, and - /// therefore no components will be able to be added or removed from the - /// page. - void stopTime() => timeScale = 0; - - /// Resumes normal time progression for the page, if it was previously slowed - /// down or stopped. - void resumeTime() => timeScale = 1.0; + /// While stopped, neither the page nor any of its descendants are updated + /// at all (see [Component.updatePaused]), which saves computational + /// resources. The page keeps rendering, and pending lifecycle events + /// (adding or removing components) still complete. The [timeScale] is left + /// untouched, so a slow-motion factor survives a stop/resume cycle of the + /// route below a popup. + void stopTime() => updatePaused = true; + + /// Resumes normal time progression for the page, if it was previously + /// slowed down or stopped. + void resumeTime() { + updatePaused = false; + timeScale = 1.0; + } /// Applies the provided [Decorator] to the page. /// @@ -162,17 +166,14 @@ class Route extends PositionComponent } } + /// Cached `super.renderTree` tear-off, so that the render pass does not + /// allocate a fresh closure for [Decorator.applyChain] on every frame. + void Function(Canvas)? _superRenderTree; + @override void renderTree(Canvas canvas) { if (isRendered) { - _renderEffect.applyChain(super.renderTree, canvas); - } - } - - @override - void updateTree(double dt) { - if (timeScale > 0) { - super.updateTree(dt); + _renderEffect.applyChain(_superRenderTree ??= super.renderTree, canvas); } } diff --git a/packages/flame/lib/src/effects/combined_effect.dart b/packages/flame/lib/src/effects/combined_effect.dart index f5555c5fce5..86ca7905404 100644 --- a/packages/flame/lib/src/effects/combined_effect.dart +++ b/packages/flame/lib/src/effects/combined_effect.dart @@ -1,3 +1,4 @@ +import 'package:flame/components.dart'; import 'package:flame/effects.dart'; /// An effect that runs multiple effects simultaneously. @@ -16,7 +17,7 @@ import 'package:flame/effects.dart'; /// infinitely. This is equivalent to setting `repeatCount = infinity`. If both /// the `infinite` and the `repeatCount` parameters are given, then `infinite` /// takes precedence. -class CombinedEffect extends Effect { +class CombinedEffect extends Effect implements CustomTraversal { CombinedEffect( List effects, { bool alternate = false, @@ -53,7 +54,7 @@ class CombinedEffect extends Effect { void apply(double progress) {} @override - void updateTree(double dt) { + void updateSubtree(double dt) { update(dt); // Do not update children: the controller will take care of it } diff --git a/packages/flame/lib/src/effects/sequence_effect.dart b/packages/flame/lib/src/effects/sequence_effect.dart index 28bd1486338..4ff2286b497 100644 --- a/packages/flame/lib/src/effects/sequence_effect.dart +++ b/packages/flame/lib/src/effects/sequence_effect.dart @@ -1,3 +1,4 @@ +import 'package:flame/src/components/core/custom_traversal.dart'; import 'package:flame/src/effects/controllers/effect_controller.dart'; import 'package:flame/src/effects/controllers/infinite_effect_controller.dart'; import 'package:flame/src/effects/controllers/repeated_effect_controller.dart'; @@ -45,7 +46,7 @@ EffectController _createController({ /// [EffectController] as a parameter. This is because the timing of a sequence /// effect depends on the timings of individual effects, and cannot be /// represented as a regular effect controller. -class SequenceEffect extends Effect { +class SequenceEffect extends Effect implements CustomTraversal { SequenceEffect( List effects, { bool alternate = false, @@ -74,7 +75,7 @@ class SequenceEffect extends Effect { void apply(double progress) {} @override - void updateTree(double dt) { + void updateSubtree(double dt) { update(dt); // Do not update children: the controller will take care of it } diff --git a/packages/flame/lib/src/events/callbacks/double_tap_callbacks.dart b/packages/flame/lib/src/events/callbacks/double_tap_callbacks.dart index 6489374760f..3f465f35837 100644 --- a/packages/flame/lib/src/events/callbacks/double_tap_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/double_tap_callbacks.dart @@ -1,5 +1,6 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; +import 'package:meta/meta.dart'; /// [DoubleTapCallbacks] adds the ability to receive double-tap events in a /// component. @@ -25,8 +26,17 @@ mixin DoubleTapCallbacks on Component { void onDoubleTapCancel(DoubleTapCancelEvent event) {} @override + @mustCallSuper void onMount() { super.onMount(); DoubleTapDispatcher.addDispatcher(this); + findRootGame()?.adjustPointerEventHandlerCount(1); + } + + @override + @mustCallSuper + void onRemove() { + findRootGame()?.adjustPointerEventHandlerCount(-1); + super.onRemove(); } } diff --git a/packages/flame/lib/src/events/callbacks/drag_callbacks.dart b/packages/flame/lib/src/events/callbacks/drag_callbacks.dart index bb05312471f..c8b15a865b1 100644 --- a/packages/flame/lib/src/events/callbacks/drag_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/drag_callbacks.dart @@ -68,11 +68,13 @@ mixin DragCallbacks on Component { hasDrag: true, hasScale: false, ); + findRootGame()?.adjustPointerEventHandlerCount(1); } @override @mustCallSuper void onRemove() { + findRootGame()?.adjustPointerEventHandlerCount(-1); MultiDragScaleDispatcher.removeDispatcher( this, hasDrag: true, diff --git a/packages/flame/lib/src/events/callbacks/scale_callbacks.dart b/packages/flame/lib/src/events/callbacks/scale_callbacks.dart index 3481232abf0..92f95912cfb 100644 --- a/packages/flame/lib/src/events/callbacks/scale_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/scale_callbacks.dart @@ -30,11 +30,13 @@ mixin ScaleCallbacks on Component { hasDrag: false, hasScale: true, ); + findRootGame()?.adjustPointerEventHandlerCount(1); } @override @mustCallSuper void onRemove() { + findRootGame()?.adjustPointerEventHandlerCount(-1); MultiDragScaleDispatcher.removeDispatcher( this, hasDrag: false, diff --git a/packages/flame/lib/src/events/callbacks/secondary_tap_callbacks.dart b/packages/flame/lib/src/events/callbacks/secondary_tap_callbacks.dart index 5cbeaea0ad7..bf26fd890e5 100644 --- a/packages/flame/lib/src/events/callbacks/secondary_tap_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/secondary_tap_callbacks.dart @@ -23,5 +23,13 @@ mixin SecondaryTapCallbacks on Component { void onMount() { super.onMount(); NonPrimaryTapDispatcher.addDispatcher(this); + findRootGame()?.adjustPointerEventHandlerCount(1); + } + + @override + @mustCallSuper + void onRemove() { + findRootGame()?.adjustPointerEventHandlerCount(-1); + super.onRemove(); } } diff --git a/packages/flame/lib/src/events/callbacks/tap_callbacks.dart b/packages/flame/lib/src/events/callbacks/tap_callbacks.dart index 4c898ed3658..cf28c818fea 100644 --- a/packages/flame/lib/src/events/callbacks/tap_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/tap_callbacks.dart @@ -23,5 +23,13 @@ mixin TapCallbacks on Component { void onMount() { super.onMount(); MultiTapDispatcher.addDispatcher(this); + findRootGame()?.adjustPointerEventHandlerCount(1); + } + + @override + @mustCallSuper + void onRemove() { + findRootGame()?.adjustPointerEventHandlerCount(-1); + super.onRemove(); } } diff --git a/packages/flame/lib/src/game/flame_game.dart b/packages/flame/lib/src/game/flame_game.dart index dd5eecba7d5..bfbe0ea50c7 100644 --- a/packages/flame/lib/src/game/flame_game.dart +++ b/packages/flame/lib/src/game/flame_game.dart @@ -39,7 +39,7 @@ import 'package:meta/meta.dart'; /// constructor; otherwise, a runtime assertion error is thrown. class FlameGame extends ComponentTreeRoot with Game - implements ReadOnlySizeProvider { + implements ReadOnlySizeProvider, CustomTraversal { FlameGame({ super.children, W? world, @@ -178,14 +178,12 @@ class FlameGame extends ComponentTreeRoot } @override - void updateTree(double dt) { + void updateSubtree(double dt) { processLifecycleEvents(); if (parent != null) { update(dt); } - for (final component in children) { - component.updateTree(dt); - } + updateChildrenFlat(dt); } /// This passes the new size along to every component in the tree via their @@ -243,6 +241,20 @@ class FlameGame extends ComponentTreeRoot point.y < canvasSize.y; } + /// The number of currently mounted components (in this game or any nested + /// game) that can receive pointer events. Maintained by the event callback + /// mixins ([TapCallbacks], [DragCallbacks], [DoubleTapCallbacks], + /// [ScaleCallbacks], [SecondaryTapCallbacks]) on the root game, so that + /// [containsEventHandlerAt] can skip hit testing entirely for games + /// without any pointer-event handlers. + int _pointerEventHandlerCount = 0; + + @internal + void adjustPointerEventHandlerCount(int delta) { + _pointerEventHandlerCount += delta; + assert(_pointerEventHandlerCount >= 0); + } + @override bool containsEventHandlerAt(Vector2 position) { // Deprecated game-level detector mixins handle events for the entire @@ -262,6 +274,9 @@ class FlameGame extends ComponentTreeRoot this is MultiTouchDragDetector) { return true; } + if (_pointerEventHandlerCount == 0) { + return false; + } for (final component in super.componentsAtPoint(position)) { if (component is TapCallbacks || component is DragCallbacks || diff --git a/packages/flame/lib/src/post_process/post_process_component.dart b/packages/flame/lib/src/post_process/post_process_component.dart index cfa597b261a..eb8712fba3a 100644 --- a/packages/flame/lib/src/post_process/post_process_component.dart +++ b/packages/flame/lib/src/post_process/post_process_component.dart @@ -105,22 +105,25 @@ class PostProcessComponent extends PositionComponent { return superSize; } + /// Cached render chain, so that the render pass does not allocate fresh + /// closures for `Decorator.applyChain` on every frame. + void Function(Canvas)? _renderChain; + @override @mustCallSuper void renderTree(Canvas canvas) { - decorator.applyChain( - (canvas) { - postProcess.render( - canvas, - size, - super.renderTreeWithoutDecorator, - (context) { - _renderContext.postProcess = postProcess; - }, - ); - }, - canvas, - ); + decorator.applyChain(_renderChain ??= _buildRenderChain(), canvas); + } + + void Function(Canvas) _buildRenderChain() { + final renderTree = super.renderTreeWithoutDecorator; + void updateContext(PostProcess? context) { + _renderContext.postProcess = postProcess; + } + + return (canvas) { + postProcess.render(canvas, size, renderTree, updateContext); + }; } } diff --git a/packages/flame/lib/src/rendering/decorator.dart b/packages/flame/lib/src/rendering/decorator.dart index b9457223ef1..a5f3190b6ab 100644 --- a/packages/flame/lib/src/rendering/decorator.dart +++ b/packages/flame/lib/src/rendering/decorator.dart @@ -31,16 +31,26 @@ class Decorator { /// The next decorator in the chain, or null if there is none. Decorator? _next; + /// Cached closure that forwards the draw call to the rest of the chain, + /// so that no closure needs to be allocated per frame. It is keyed by the + /// identity of the [_chainedDrawSource] it wraps: callers that pass the + /// same (cached) draw callback every frame reuse the same chain closure. + late void Function(Canvas) _chainedDraw; + void Function(Canvas)? _chainedDrawSource; + /// Applies this and all subsequent decorators if any. /// /// This method is the main method through which the decorator is applied. void applyChain(void Function(Canvas) draw, Canvas canvas) { - apply( - _next == null - ? draw - : (nextCanvas) => _next!.applyChain(draw, nextCanvas), - canvas, - ); + if (_next == null) { + apply(draw, canvas); + } else { + if (!identical(_chainedDrawSource, draw)) { + _chainedDrawSource = draw; + _chainedDraw = (nextCanvas) => _next!.applyChain(draw, nextCanvas); + } + apply(_chainedDraw, canvas); + } } /// Applies visual effect while [draw]ing on the [canvas]. diff --git a/packages/flame/pubspec.yaml b/packages/flame/pubspec.yaml index cc6e3c086df..7894212d75f 100644 --- a/packages/flame/pubspec.yaml +++ b/packages/flame/pubspec.yaml @@ -22,7 +22,6 @@ dependencies: flutter: sdk: flutter meta: ^1.12.0 - ordered_set: ^8.0.0 vector_math: ^2.1.4 dev_dependencies: diff --git a/packages/flame/test/components/component_test.dart b/packages/flame/test/components/component_test.dart index 619aeee3ea2..0c52fd90177 100644 --- a/packages/flame/test/components/component_test.dart +++ b/packages/flame/test/components/component_test.dart @@ -6,8 +6,6 @@ import 'package:flame/src/components/core/component_tree_root.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:ordered_set/mapping_ordered_set.dart'; -import 'package:ordered_set/ordered_set.dart'; import '../custom_component.dart'; @@ -1661,23 +1659,34 @@ void main() { }); group('miscellaneous', () { - testWithFlameGame('childrenFactory', (game) async { + testWithFlameGame('createComponentList override', (game) async { final component0 = Component(); expect(component0.children.strictMode, false); - Component.childrenFactory = () => OrderedSet.mapping( - (e) => e.priority, - // ignore: avoid_redundant_argument_values - strictMode: true, - ); - final component1 = Component(); + final component1 = _CustomListComponent(); final component2 = Component(); component1.add(component2); component2.add(Component()); - expect(component1.children, isInstanceOf()); + expect(component1.children, isInstanceOf()); expect(component1.children.strictMode, isTrue); - expect(component2.children, isInstanceOf()); - expect(component2.children.strictMode, isTrue); + expect(component2.children.strictMode, isFalse); + }); + + testWithFlameGame('custom children comparator', (game) async { + final parent = _ReverseOrderedComponent(); + final children = List.generate(5, (i) => Component(priority: i)); + parent.addAll(children); + await game.ensureAdd(parent); + + expect( + parent.children.toList(), + equals(children.reversed.toList()), + ); + + // Reordering after a priority change keeps following the comparator. + children.first.priority = 10; + game.update(0); + expect(parent.children.first, children.first); }); testWithFlameGame('initially same debugMode as parent', (game) async { @@ -2188,3 +2197,17 @@ class _RemoveAllChildrenComponent extends Component { removeAll(children); } } + +class _CustomListComponent extends Component { + @override + ComponentList createComponentList() => ComponentList(strictMode: true); +} + +class _ReverseOrderedComponent extends Component { + @override + ComponentList createComponentList() { + return ComponentList( + comparator: (a, b) => b.priority.compareTo(a.priority), + ); + } +} diff --git a/packages/flame/test/components/component_tree_golden_test.dart b/packages/flame/test/components/component_tree_golden_test.dart new file mode 100644 index 00000000000..7e71a913724 --- /dev/null +++ b/packages/flame/test/components/component_tree_golden_test.dart @@ -0,0 +1,223 @@ +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +/// Golden tests that pin the observable behavior of the component tree: +/// lifecycle event ordering in same-tick edge cases, hit-test order, and +/// the ordering guarantees for children with equal priorities. +/// +/// These behaviors must survive any change to the children container or the +/// traversal machinery. +void main() { + group('Lifecycle ordering', () { + testWithFlameGame('add to a new parent during remove, same tick', ( + game, + ) async { + final parent1 = Component(); + final parent2 = Component(); + await game.world.ensureAdd(parent1); + await game.world.ensureAdd(parent2); + final child = _EventLogComponent(); + await parent1.ensureAdd(child); + expect(child.events, ['onMount']); + + parent1.remove(child); + parent2.add(child); + game.update(0); + await game.ready(); + + expect(child.parent, parent2); + expect(child.isMounted, isTrue); + expect(parent1.children, isEmpty); + expect(parent2.children, [child]); + expect(child.events, ['onMount', 'onRemove', 'onMount']); + }); + + testWithFlameGame('move to a new parent while old parent is removed', ( + game, + ) async { + final parent1 = Component(); + final parent2 = Component(); + await game.world.ensureAdd(parent1); + await game.world.ensureAdd(parent2); + final child = _EventLogComponent(); + await parent1.ensureAdd(child); + + child.parent = parent2; + parent1.removeFromParent(); + game.update(0); + await game.ready(); + + expect(parent1.isMounted, isFalse); + expect(child.parent, parent2); + expect(child.isMounted, isTrue); + expect(parent2.children, [child]); + expect(child.events, ['onMount', 'onRemove', 'onMount']); + }); + + testWithFlameGame('remove and re-add to the same parent, same tick', ( + game, + ) async { + final parent = Component(); + await game.world.ensureAdd(parent); + final child = _EventLogComponent(); + await parent.ensureAdd(child); + + parent.remove(child); + parent.add(child); + game.update(0); + await game.ready(); + + expect(child.parent, parent); + expect(child.isMounted, isTrue); + expect(parent.children, [child]); + }); + }); + + group('Hit-test order', () { + testWithFlameGame('componentsAtPoint is front-to-back by priority', ( + game, + ) async { + final bottom = _HittablePositionComponent(priority: 0); + final middle = _HittablePositionComponent(priority: 1); + final top = _HittablePositionComponent(priority: 2); + // Added in an order that differs from the priority order. + await game.world.ensureAddAll([middle, top, bottom]); + + final hits = game.componentsAtPoint(Vector2(405, 305)).toList(); + final hitComponents = hits + .whereType<_HittablePositionComponent>() + .toList(); + expect(hitComponents, [top, middle, bottom]); + }); + + testWithFlameGame('children hit before their parents', (game) async { + final parent = _HittablePositionComponent(priority: 0); + final child = _HittablePositionComponent(priority: 0); + parent.add(child); + await game.world.ensureAdd(parent); + + final hits = game + .componentsAtPoint(Vector2(405, 305)) + .whereType<_HittablePositionComponent>() + .toList(); + expect(hits, [child, parent]); + }); + + testWithFlameGame('equal-priority siblings hit in reverse add order', ( + game, + ) async { + final first = _HittablePositionComponent(priority: 0); + final second = _HittablePositionComponent(priority: 0); + final third = _HittablePositionComponent(priority: 0); + await game.world.ensureAddAll([first, second, third]); + + final hits = game + .componentsAtPoint(Vector2(405, 305)) + .whereType<_HittablePositionComponent>() + .toList(); + expect(hits, [third, second, first]); + }); + }); + + group('Equal-priority ordering', () { + testWithFlameGame('insertion order is kept through mounting', ( + game, + ) async { + final children = List.generate(10, (i) => Component()); + final parent = Component(children: children); + await game.world.ensureAdd(parent); + expect(parent.children.toList(), children); + }); + + testWithFlameGame('insertion order survives a sibling priority change', ( + game, + ) async { + final children = List.generate(10, (i) => Component()); + final parent = Component(children: children); + await game.world.ensureAdd(parent); + + // Push the first child to the back; everyone else keeps their order. + children.first.priority = 1; + game.update(0); + await game.ready(); + + expect( + parent.children.toList(), + [...children.skip(1), children.first], + ); + + // Return it to the same priority as the others: it must now sort after + // them (a fresh sort key, not its original insertion position). + children.first.priority = 0; + game.update(0); + await game.ready(); + + expect( + parent.children.toList(), + [...children.skip(1), children.first], + ); + }); + + testWithFlameGame('insertion order is kept when the parent remounts', ( + game, + ) async { + final children = List.generate(10, (i) => Component()); + final parent = Component(children: children); + await game.world.ensureAdd(parent); + + parent.removeFromParent(); + game.update(0); + await game.ready(); + expect(parent.isMounted, isFalse); + + await game.world.ensureAdd(parent); + expect(parent.children.toList(), children); + }); + + testWithFlameGame('update and render visit equal priorities in add order', ( + game, + ) async { + final order = []; + final children = List.generate( + 5, + (i) => _UpdateLogComponent(i, order), + ); + final parent = Component(children: children); + await game.world.ensureAdd(parent); + + game.update(0); + expect(order, [0, 1, 2, 3, 4]); + }); + }); +} + +class _EventLogComponent extends Component { + final List events = []; + + @override + void onMount() { + events.add('onMount'); + } + + @override + void onRemove() { + events.add('onRemove'); + } +} + +class _HittablePositionComponent extends PositionComponent { + _HittablePositionComponent({super.priority}) : super(size: Vector2.all(10)); +} + +class _UpdateLogComponent extends Component { + _UpdateLogComponent(this.id, this.order); + + final int id; + final List order; + + @override + void update(double dt) { + order.add(id); + } +} diff --git a/packages/flame/test/components/core/custom_traversal_test.dart b/packages/flame/test/components/core/custom_traversal_test.dart new file mode 100644 index 00000000000..1f9b62fd0ac --- /dev/null +++ b/packages/flame/test/components/core/custom_traversal_test.dart @@ -0,0 +1,98 @@ +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('CustomTraversal', () { + testWithFlameGame('default updateSubtree matches standard traversal', ( + game, + ) async { + final child = _DtRecorder(); + final parent = _PlainBarrier(children: [child]); + await game.world.ensureAdd(parent); + + game.update(0.5); + expect(child.recordedDts, [0.5]); + }); + + testWithFlameGame('updateSubtree can modify the dt for the subtree', ( + game, + ) async { + final child = _DtRecorder(); + final grandChild = _DtRecorder(); + child.add(grandChild); + final parent = _HalfSpeedBarrier(children: [child]); + await game.world.ensureAdd(parent); + + game.update(1.0); + expect(child.recordedDts, [0.5]); + expect(grandChild.recordedDts, [0.5]); + }); + + testWithFlameGame('updateSubtree can skip the subtree entirely', ( + game, + ) async { + final child = _DtRecorder(); + final parent = _FrozenBarrier(children: [child]); + await game.world.ensureAdd(parent); + + game.update(1.0); + expect(parent.recordedDts, isEmpty); + expect(child.recordedDts, isEmpty); + + parent.frozen = false; + game.update(1.0); + expect(parent.recordedDts, [1.0]); + expect(child.recordedDts, [1.0]); + }); + + testWithFlameGame('barrier subtrees still process nested barriers', ( + game, + ) async { + final inner = _DtRecorder(); + final innerBarrier = _HalfSpeedBarrier(children: [inner]); + final outerBarrier = _HalfSpeedBarrier(children: [innerBarrier]); + await game.world.ensureAdd(outerBarrier); + + game.update(1.0); + expect(inner.recordedDts, [0.25]); + }); + }); +} + +class _DtRecorder extends Component { + final List recordedDts = []; + + @override + void update(double dt) { + recordedDts.add(dt); + } +} + +class _PlainBarrier extends Component implements CustomTraversal { + _PlainBarrier({super.children}); +} + +class _HalfSpeedBarrier extends Component implements CustomTraversal { + _HalfSpeedBarrier({super.children}); + + @override + void updateSubtree(double dt) => super.updateSubtree(dt / 2); +} + +class _FrozenBarrier extends _DtRecorder implements CustomTraversal { + _FrozenBarrier({List? children}) { + if (children != null) { + addAll(children); + } + } + + bool frozen = true; + + @override + void updateSubtree(double dt) { + if (!frozen) { + super.updateSubtree(dt); + } + } +} diff --git a/packages/flame/test/components/core/update_paused_test.dart b/packages/flame/test/components/core/update_paused_test.dart new file mode 100644 index 00000000000..dca7f71c3e2 --- /dev/null +++ b/packages/flame/test/components/core/update_paused_test.dart @@ -0,0 +1,100 @@ +import 'dart:ui'; + +import 'package:canvas_test/canvas_test.dart'; +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('updatePaused', () { + testWithFlameGame('pauses the component and its subtree', (game) async { + final child = _Counter(); + final parent = _Counter(children: [child]); + final sibling = _Counter(); + await game.world.ensureAddAll([parent, sibling]); + + game.update(0); + expect(parent.updates, 1); + expect(child.updates, 1); + expect(sibling.updates, 1); + + parent.updatePaused = true; + game.update(0); + game.update(0); + expect(parent.updates, 1); + expect(child.updates, 1); + expect(sibling.updates, 3); + + parent.updatePaused = false; + game.update(0); + expect(parent.updates, 2); + expect(child.updates, 2); + expect(sibling.updates, 4); + }); + + testWithFlameGame('paused components still render', (game) async { + final component = _Counter(); + await game.world.ensureAdd(component); + component.updatePaused = true; + + game.update(0); + game.render(MockCanvas()); + expect(component.updates, 0); + expect(component.renders, 1); + }); + + testWithFlameGame('pause works inside a custom traversal subtree', ( + game, + ) async { + final pausable = _Counter(); + final running = _Counter(); + final barrier = _ScaledBarrier(children: [pausable, running]); + await game.world.ensureAdd(barrier); + + pausable.updatePaused = true; + game.update(0); + expect(pausable.updates, 0); + expect(running.updates, 1); + }); + + testWithFlameGame('lifecycle events still process while paused', ( + game, + ) async { + final parent = _Counter(); + await game.world.ensureAdd(parent); + parent.updatePaused = true; + + final child = _Counter(); + parent.add(child); + game.update(0); + await game.ready(); + + expect(child.isMounted, isTrue); + expect(child.updates, 0); + }); + }); +} + +class _Counter extends Component { + _Counter({super.children}); + + int updates = 0; + int renders = 0; + + @override + void update(double dt) { + updates++; + } + + @override + void render(Canvas canvas) { + renders++; + } +} + +class _ScaledBarrier extends Component implements CustomTraversal { + _ScaledBarrier({super.children}); + + @override + void updateSubtree(double dt) => super.updateSubtree(dt * 2); +} diff --git a/packages/flame/test/components/mixins/has_time_scale_self_test.dart b/packages/flame/test/components/mixins/has_time_scale_self_test.dart new file mode 100644 index 00000000000..5a322aa16c0 --- /dev/null +++ b/packages/flame/test/components/mixins/has_time_scale_self_test.dart @@ -0,0 +1,36 @@ +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('HasTimeScale self-scaling', () { + testWithFlameGame('scales the dt of the component it is mixed onto', ( + game, + ) async { + final component = _ScaledRecorder()..timeScale = 0.5; + await game.world.ensureAdd(component); + + game.update(1.0); + expect(component.recordedDts, [0.5]); + }); + + testWithFlameGame('scales its own dt when called via update directly', ( + game, + ) async { + final component = _ScaledRecorder()..timeScale = 0.5; + await game.world.ensureAdd(component); + + component.update(1.0); + expect(component.recordedDts, [1.0]); + }); + }); +} + +class _ScaledRecorder extends Component with HasTimeScale { + final List recordedDts = []; + + @override + void update(double dt) { + recordedDts.add(dt); + } +} diff --git a/packages/flame/test/components/priority_test.dart b/packages/flame/test/components/priority_test.dart index 0828eb16f9c..8e6ed5ad323 100644 --- a/packages/flame/test/components/priority_test.dart +++ b/packages/flame/test/components/priority_test.dart @@ -2,8 +2,6 @@ import 'dart:ui'; import 'package:flame/components.dart'; import 'package:flame_test/flame_test.dart'; -import 'package:ordered_set/mapping_ordered_set.dart'; -import 'package:ordered_set/ordered_set.dart'; import 'package:test/test.dart'; import '../custom_component.dart'; @@ -261,15 +259,13 @@ void main() { }); } -class _SpyComponentSet extends MappingOrderedSet { +class _SpyComponentList extends ComponentList { int callCount = 0; - _SpyComponentSet() : super((e) => e.priority); - @override - void rebalanceAll() { + void rebalance() { callCount++; - super.rebalanceAll(); + super.rebalance(); } } @@ -281,11 +277,11 @@ class _ParentWithReorderSpy extends Component { _ParentWithReorderSpy(int priority) : super(priority: priority); @override - OrderedSet createComponentSet() => _SpyComponentSet(); + ComponentList createComponentList() => _SpyComponentList(); void assertCalled(int n) { - final componentSet = children as _SpyComponentSet; - expect(componentSet.callCount, n); - componentSet.callCount = 0; + final componentList = children as _SpyComponentList; + expect(componentList.callCount, n); + componentList.callCount = 0; } } diff --git a/packages/flame/test/components/route_test.dart b/packages/flame/test/components/route_test.dart index 2098ade5b03..b1e92da650c 100644 --- a/packages/flame/test/components/route_test.dart +++ b/packages/flame/test/components/route_test.dart @@ -275,18 +275,23 @@ void main() { router.pushNamed('pause'); await game.ready(); expect(router.currentRoute.name, 'pause'); - expect(router.previousRoute!.timeScale, 0); + expect(router.previousRoute!.updatePaused, isTrue); + expect(router.previousRoute!.timeScale, 1); game.update(10); expect(timer.elapsedTime, 1); - router.previousRoute!.timeScale = 0.1; + // Resuming into slow motion: unpause and slow the route down. + router.previousRoute! + ..updatePaused = false + ..timeScale = 0.1; game.update(10); expect(timer.elapsedTime, 2); router.pop(); await game.ready(); expect(router.currentRoute.name, 'start'); + expect(router.currentRoute.updatePaused, isFalse); expect(router.currentRoute.timeScale, 1); game.update(10); diff --git a/packages/flame_3d/pubspec.yaml b/packages/flame_3d/pubspec.yaml index d94626f09bb..518c97e827e 100644 --- a/packages/flame_3d/pubspec.yaml +++ b/packages/flame_3d/pubspec.yaml @@ -20,7 +20,6 @@ dependencies: flutter_gpu: sdk: flutter meta: ^1.12.0 - ordered_set: ^8.0.0 vector_math: ^2.1.4 dev_dependencies: