diff --git a/doc/flame/components/components.md b/doc/flame/components/components.md index 236fad43331..514356833ac 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 @@ -382,7 +414,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 17bf96f38a9..58021819914 100644 --- a/doc/flame/migration.md +++ b/doc/flame/migration.md @@ -476,3 +476,89 @@ if (game.isPaused) { game.isPaused = false; } ``` + + +### `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 5d3730ec038..fc9d633a969 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 b3f2b3edb59..685f97cbe8d 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/benchmark/README.md b/packages/flame/benchmark/README.md index 16c4521fa2b..2bb2c966f3e 100644 --- a/packages/flame/benchmark/README.md +++ b/packages/flame/benchmark/README.md @@ -45,7 +45,12 @@ the benchmark results are printed above it. changes across many parents, and the y-sort pattern where a whole container reorders every tick. - `type_query_benchmark.dart`: maintenance and read cost of the - `register()`/`query()` type-query caches under mixed-type churn. + `register()`/`query()` type-query caches. The churn suite varies how + many types are registered and how much of the container each cache matches; + the read suite compares a cached `query()` against the `whereType()` + scan that an unregistered type falls back to. Together they say what a cache + is worth, and what an accidental registration (the thing that + `Component.strictQueryMode` turns into an error) costs. - `update_components_benchmark.dart`: end-to-end update pass with game-like logic and inputs on a two-level tree. - `render_components_benchmark.dart`: render pass over a randomized tree onto diff --git a/packages/flame/benchmark/type_query_benchmark.dart b/packages/flame/benchmark/type_query_benchmark.dart index 1fd3f134f95..1a5a60c46af 100644 --- a/packages/flame/benchmark/type_query_benchmark.dart +++ b/packages/flame/benchmark/type_query_benchmark.dart @@ -8,54 +8,100 @@ import 'common.dart'; const _dt = 1.0 / 60; +/// Builds a population of [amount] components in which one in five is a +/// `_MarkedComponent`, one in fifty is a `_RareComponent`, and the remaining +/// four in five are `_PlainComponent`s. +List _mixedComponents(int amount) { + return List.generate(amount, (i) { + if (i % 50 == 0) { + return _RareComponent(); + } + return i % 5 == 0 ? _MarkedComponent() : _PlainComponent(); + }); +} + +/// Which query caches are registered on the container that is churned by +/// [TypeQueryChurnBenchmark]. +enum QueryRegistrations { + none('no registered queries, whereType scan'), + marked('1 registered query (matches 1 in 5)'), + markedAndRare('2 registered queries (second matches 1 in 50)'), + markedAndPlain('2 registered queries (second matches 4 in 5)'); + + const QueryRegistrations(this.label); + + final String label; + + /// Whether `_MarkedComponent` has a cache, and reads can go through + /// `query()` instead of a `whereType()` scan. + bool get isMarkedRegistered => this != none; + + void applyTo(ComponentList children) { + if (this != none) { + children.register<_MarkedComponent>(); + } + if (this == markedAndRare) { + children.register<_RareComponent>(); + } + if (this == markedAndPlain) { + children.register<_PlainComponent>(); + } + } +} + /// Measures the per-type query caches of the children container /// (`children.register()` / `children.query()`), which Flame uses /// internally for hitboxes (`GestureHitboxes`), post-processing /// (`CameraComponent`), and layout (`LinearLayoutComponent`). /// -/// Every add and remove has to update all registered caches, so this -/// benchmark churns a mixed-type population in a container with two -/// registered queries while reading one query per tick. A children-container -/// replacement must keep both the cache-maintenance and the query-read cost -/// at least this fast. +/// Every add and remove has to update all registered caches, so this benchmark +/// churns a mixed-type population while reading one query per tick. A +/// children-container replacement must keep both the cache-maintenance and the +/// query-read cost at least this fast. +/// +/// The [registrations] variants separate the two things that could drive the +/// maintenance cost: +/// - [QueryRegistrations.marked] against [QueryRegistrations.markedAndRare]: +/// one more cache, but one that almost never matches, so only the per-add +/// and per-remove type check is added; +/// - [QueryRegistrations.markedAndRare] against +/// [QueryRegistrations.markedAndPlain]: the same number of caches, but the +/// second one now holds most of the container, so the cost of maintaining a +/// cache entry itself dominates; +/// - [QueryRegistrations.none] against [QueryRegistrations.marked]: the whole +/// trade, cache maintenance on every structural change against a +/// `whereType` scan on every read. The no-cache variant reads through +/// `whereType()`, which falls back to a linear scan of the backing array +/// when no cache exists for the type. class TypeQueryChurnBenchmark extends AsyncBenchmarkBase { static const _amountStatic = 1000; static const _batchSize = 50; static const _liveBatches = 5; static const _amountTicks = 60; - static const _markedInterval = 5; + + final QueryRegistrations registrations; late final FlameGame _game; final Queue> _batches = Queue(); - TypeQueryChurnBenchmark() : super('Type-query churn (2 registered queries)'); + TypeQueryChurnBenchmark({ + this.registrations = QueryRegistrations.markedAndPlain, + }) : super('Type-query churn (${registrations.label})'); static Future main() async { - await TypeQueryChurnBenchmark().report(); - } - - List _newBatch() { - return List.generate( - _batchSize, - (i) => i % _markedInterval == 0 ? _MarkedComponent() : _PlainComponent(), - ); + for (final registrations in QueryRegistrations.values) { + await TypeQueryChurnBenchmark(registrations: registrations).report(); + } } @override Future setup() async { _game = FlameGame(); await mountGame(_game); - _game.world.children.register<_MarkedComponent>(); - _game.world.children.register<_PlainComponent>(); - _game.world.addAll( - List.generate( - _amountStatic, - (i) => - i % _markedInterval == 0 ? _MarkedComponent() : _PlainComponent(), - ), - ); + registrations.applyTo(_game.world.children); + _game.world.addAll(_mixedComponents(_amountStatic)); for (var i = 0; i < _liveBatches; i++) { - final batch = _newBatch(); + final batch = _mixedComponents(_batchSize); _batches.addLast(batch); _game.world.addAll(batch); } @@ -64,14 +110,19 @@ class TypeQueryChurnBenchmark extends AsyncBenchmarkBase { @override Future run() async { + final children = _game.world.children; + final isMarkedRegistered = registrations.isMarkedRegistered; var visited = 0; for (var i = 0; i < _amountTicks; i++) { _game.world.removeAll(_batches.removeFirst()); - final batch = _newBatch(); + final batch = _mixedComponents(_batchSize); _batches.addLast(batch); _game.world.addAll(batch); - for (final marked in _game.world.children.query<_MarkedComponent>()) { - visited += marked.marker; + final marked = isMarkedRegistered + ? children.query<_MarkedComponent>() + : children.whereType<_MarkedComponent>(); + for (final component in marked) { + visited += component.marker; } _game.update(_dt); } @@ -79,12 +130,86 @@ class TypeQueryChurnBenchmark extends AsyncBenchmarkBase { } } +/// Measures the read side of the query caches in isolation: [_amountReads] +/// repeated reads of every `_MarkedComponent` in a static container of +/// [amountChildren] children, one fifth of which match. +/// +/// The [cached] variant registers the type and reads through `query()`, +/// which returns a maintained list of exactly the matching children. The +/// uncached variant reads through `whereType()`, which, without a cache for +/// the type, scans the whole backing array. +/// +/// The gap between the two is what a cache buys on the read side, and it is +/// the number to weigh against the maintenance cost measured by +/// [TypeQueryChurnBenchmark]. It is measured at both a large container size +/// (where the scan has to skip many non-matching children) and at a typical +/// per-component size (where a query such as `GestureHitboxes.hitboxes` runs +/// over a handful of children). +class TypeQueryReadBenchmark extends AsyncBenchmarkBase { + static const _amountReads = 500; + + final int amountChildren; + final bool cached; + + late final FlameGame _game; + late final Component _parent; + + TypeQueryReadBenchmark({required this.amountChildren, required this.cached}) + : super( + 'Type-query read ($amountChildren children, ' + '${cached ? 'cached query' : 'whereType scan'})', + ); + + static Future main() async { + for (final amountChildren in [1000, 16]) { + for (final cached in [false, true]) { + await TypeQueryReadBenchmark( + amountChildren: amountChildren, + cached: cached, + ).report(); + } + } + } + + @override + Future setup() async { + _game = FlameGame(); + await mountGame(_game); + _parent = Component(); + _game.world.add(_parent); + await _game.ready(); + if (cached) { + _parent.children.register<_MarkedComponent>(); + } + _parent.addAll(_mixedComponents(amountChildren)); + await _game.ready(); + } + + @override + Future run() async { + final children = _parent.children; + var visited = 0; + for (var i = 0; i < _amountReads; i++) { + final marked = cached + ? children.query<_MarkedComponent>() + : children.whereType<_MarkedComponent>(); + for (final component in marked) { + visited += component.marker; + } + } + assert(visited > 0); + } +} + class _MarkedComponent extends Component { final int marker = 1; } class _PlainComponent extends Component {} +class _RareComponent extends Component {} + Future main() async { await TypeQueryChurnBenchmark.main(); + await TypeQueryReadBenchmark.main(); } diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index 836f1ae9c67..83e418107df 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/collisions/broadphase/sweep/sweep.dart b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart index f79cc6e33e0..42aabb4bb81 100644 --- a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart +++ b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flame/collisions.dart'; class Sweep> extends Broadphase { @@ -17,9 +18,17 @@ 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. The comparator is a static tear-off, so nothing is + // allocated per tick. + insertionSort(items, compare: _compareMinX); } + static int _compareMinX(Hitbox a, Hitbox b) => + a.aabb.min.x.compareTo(b.aabb.min.x); + @override Iterable> query() { _active.clear(); @@ -44,7 +53,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 7fca4bb0510..a601bfdaf8f 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); } @@ -283,9 +284,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; @@ -298,57 +299,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. @@ -525,7 +526,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 @@ -573,12 +574,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); + } } } } @@ -598,19 +712,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 ?? const []; + if (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]. @@ -622,15 +733,21 @@ class Component { void renderTree(Canvas canvas) { final context = renderContext; + List? renderContexts; if (context != null) { - _renderContexts.add(context); + renderContexts = _renderContexts ??= []; + 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); } @@ -640,9 +757,7 @@ class Component { renderDebugMode(canvas); } - if (context != null) { - _renderContexts.removeLast(); - } + renderContexts?.removeLast(); } //#endregion @@ -877,8 +992,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; @@ -1186,24 +1303,39 @@ class Component { 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 = []; + _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); + } _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(); @@ -1217,14 +1349,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 @@ -1255,8 +1389,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..9a8bbc8c5f2 --- /dev/null +++ b/packages/flame/lib/src/components/core/component_list.dart @@ -0,0 +1,633 @@ +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. The per-type [query] caches work the same way: a removal only marks +/// them, and the entries of the removed components are dropped in a single +/// pass before anything reads, reorders or adds to them again, so that a +/// removal stays O(1) no matter how many types are registered or how much of +/// the container each of them matches. +/// +/// 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; + + /// Whether any of [_queryCaches] may still hold entries for components that + /// have since been removed from this list. + /// + /// Removals only raise this flag instead of searching every matching cache + /// for the removed component, which would be O(n) per removal. The stale + /// entries are dropped by [_compactQueryCaches], in a single pass that + /// covers any number of removals at once, before anything can observe them. + bool _hasStaleQueryEntries = false; + + /// 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', + ); + // Must happen before [component] is linked to this list: a component that + // is removed and added back before the caches are compacted would + // otherwise be seen as a live entry and end up in a cache twice. + _compactQueryCaches(); + 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.hasStaleEntries = true; + _hasStaleQueryEntries = true; + } + } + } + if (_length == 0) { + _elements.clear(); + _tombstones = 0; + // Nothing is left to hold on to, so drop the stale cache entries right + // away instead of keeping the removed components alive until the next + // add or query. + _compactQueryCaches(); + } else if (_tombstones >= _tombstoneCompactionThreshold && + _tombstones * 2 >= _elements.length) { + _compact(); + _compactQueryCaches(); + } + 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() + ..hasStaleEntries = false; + } + } + _hasStaleQueryEntries = false; + } + + /// 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(); + // Not needed for correctness, since every read compacts as well, but it + // keeps [_QueryCache.resort] from ordering entries that are about to be + // dropped, by an element index that they no longer have. + _compactQueryCaches(); + 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++; + } + + /// Drops the entries of removed components from the query caches, if any + /// removal has left some behind. + @pragma('vm:prefer-inline') + @pragma('wasm:prefer-inline') + void _compactQueryCaches() { + if (!_hasStaleQueryEntries) { + return; + } + _hasStaleQueryEntries = false; + final caches = _queryCaches!; + for (var i = 0; i < caches.length; i++) { + caches[i].compact(this); + } + } + + /// 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(); + } + _compactQueryCaches(); + // 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) { + _compactQueryCaches(); + 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; + + /// Whether [data] may hold entries for components that have since been + /// removed from the list; see [ComponentList._hasStaleQueryEntries]. + bool hasStaleEntries = false; + + bool check(Component component) => component is C; + + /// Drops the entries that are no longer in [list], in a single pass that + /// preserves the order of the remaining ones. + void compact(ComponentList list) { + if (!hasStaleEntries) { + return; + } + hasStaleEntries = false; + final data = this.data; + var write = 0; + for (var read = 0; read < data.length; read++) { + final element = data[read]; + if (identical(element._containerList, list)) { + if (write != read) { + data[write] = element; + } + write++; + } + } + data.length = write; + } + + /// 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 9627a335075..25f74650f4a 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. /// 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_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/router/route.dart b/packages/flame/lib/src/components/router/route.dart index dbbd91d21d3..49fc9680a29 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. /// @@ -169,13 +173,6 @@ class Route extends PositionComponent } } - @override - void updateTree(double dt) { - if (timeScale > 0) { - super.updateTree(dt); - } - } - @override Iterable componentsAtLocation( T locationContext, 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/game/flame_game.dart b/packages/flame/lib/src/game/flame_game.dart index 32065890ec3..fe8894b3fd3 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 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 47287774f5a..a1fd51debc5 100644 --- a/packages/flame/test/components/component_test.dart +++ b/packages/flame/test/components/component_test.dart @@ -7,8 +7,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'; @@ -1742,23 +1740,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 { @@ -2343,3 +2352,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/component_list_query_test.dart b/packages/flame/test/components/core/component_list_query_test.dart new file mode 100644 index 00000000000..68469152db8 --- /dev/null +++ b/packages/flame/test/components/core/component_list_query_test.dart @@ -0,0 +1,215 @@ +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('ComponentList queries', () { + test('a removed component leaves the cache', () { + final list = ComponentList()..register<_Marked>(); + final marked = _Marked(1); + list + ..add(marked) + ..add(_Plain()); + + expect(list.query<_Marked>(), [marked]); + + list.remove(marked); + expect(list.query<_Marked>(), isEmpty); + }); + + test('removing and adding back does not duplicate the cache entry', () { + final list = ComponentList()..register<_Marked>(); + final marked = _Marked(1); + // A sibling keeps the list non-empty, so that the removal does not + // compact the cache on its own. + list + ..add(marked) + ..add(_Marked(2)); + + list + ..remove(marked) + ..add(marked); + + expect(list.query<_Marked>().map((c) => c.id), [2, 1]); + }); + + test('a component moved to another list leaves the first cache', () { + final source = ComponentList()..register<_Marked>(); + final target = ComponentList()..register<_Marked>(); + final marked = _Marked(1); + source + ..add(marked) + ..add(_Marked(2)); + target.add(_Marked(3)); + + source.remove(marked); + target.add(marked); + + expect(source.query<_Marked>().map((c) => c.id), [2]); + expect(target.query<_Marked>().map((c) => c.id), [3, 1]); + }); + + test('the cache keeps the list order across removals and additions', () { + final list = ComponentList()..register<_Marked>(); + final marked = List.generate(10, (i) => _Marked(i, priority: i)); + for (var i = 0; i < 10; i++) { + list + ..add(marked[i]) + ..add(_Plain(priority: i)); + } + + // Removals from the front, the middle and the end, all before anything + // reads the cache again. + list + ..remove(marked[0]) + ..remove(marked[4]) + ..remove(marked[5]) + ..remove(marked[9]); + expect(list.query<_Marked>().map((c) => c.id), [1, 2, 3, 6, 7, 8]); + + // A component that sorts into the middle lands in the right place. + final inserted = _Marked(99, priority: 4); + list.add(inserted); + expect(list.query<_Marked>().map((c) => c.id), [1, 2, 3, 99, 6, 7, 8]); + }); + + test('the cache is reordered after a rebalance that follows a removal', () { + final list = ComponentList()..register<_Marked>(); + final marked = List.generate(5, (i) => _Marked(i, priority: i)); + for (final component in marked) { + list.add(component); + } + + list.remove(marked[2]); + marked[0].priority = 10; + list.rebalance(); + + expect(list.query<_Marked>().map((c) => c.id), [1, 3, 4, 0]); + }); + + test('emptying the list empties the caches', () { + final list = ComponentList()..register<_Marked>(); + final marked = List.generate(3, _Marked.new); + for (final component in marked) { + list.add(component); + } + + for (final component in marked) { + list.remove(component); + } + + expect(list.query<_Marked>(), isEmpty); + expect(list, isEmpty); + }); + + test('clear empties the caches', () { + final list = ComponentList()..register<_Marked>(); + list + ..add(_Marked(1)) + ..add(_Plain()) + ..clear(); + + expect(list.query<_Marked>(), isEmpty); + }); + + test('removals past the tombstone compaction threshold', () { + final list = ComponentList()..register<_Marked>(); + // More than the threshold at which the backing array compacts itself. + final marked = List.generate(100, (i) => _Marked(i, priority: i)); + for (final component in marked) { + list.add(component); + } + + for (var i = 0; i < 100; i += 2) { + list.remove(marked[i]); + } + + expect( + list.query<_Marked>().map((c) => c.id), + [for (var i = 1; i < 100; i += 2) i], + ); + }); + + test('whereType sees removals as query does', () { + final list = ComponentList()..register<_Marked>(); + final marked = _Marked(1); + list.add(marked); + + list.remove(marked); + + expect(list.whereType<_Marked>(), isEmpty); + // Unregistered types scan the backing array instead of a cache. + expect(list.whereType<_Plain>(), isEmpty); + }); + + test('a cache of an unrelated type is unaffected by removals', () { + final list = ComponentList() + ..register<_Marked>() + ..register<_Plain>(); + final marked = _Marked(1); + final plain = _Plain(); + list + ..add(marked) + ..add(plain); + + list.remove(marked); + + expect(list.query<_Plain>(), [plain]); + expect(list.query<_Marked>(), isEmpty); + }); + + testWithFlameGame('queries follow the component lifecycle', (game) async { + game.world.children.register<_Marked>(); + final marked = List.generate(20, (i) => _Marked(i, priority: i)); + await game.world.ensureAddAll([ + ...marked, + ...List.generate(20, (i) => _Plain(priority: i)), + ]); + + expect(game.world.children.query<_Marked>(), marked); + + game.world.removeAll(marked.sublist(0, 10)); + game.update(0); + expect(game.world.children.query<_Marked>(), marked.sublist(10)); + + // Removals and additions within the same tick. + final added = _Marked(100, priority: 100); + game.world + ..removeAll(marked.sublist(10, 15)) + ..add(added); + game.update(0); + expect(game.world.children.query<_Marked>(), [ + ...marked.sublist(15), + added, + ]); + }); + + testWithFlameGame('hitbox queries follow removals', (game) async { + final component = _Hitboxes(); + final hitboxes = List.generate(3, (_) => RectangleHitbox()); + await game.world.ensureAdd(component); + await component.ensureAddAll(hitboxes); + + expect(component.hitboxes, hitboxes); + + hitboxes.first.removeFromParent(); + game.update(0); + expect(component.hitboxes, hitboxes.sublist(1)); + }); + }); +} + +class _Marked extends Component { + _Marked(this.id, {super.priority}); + + final int id; +} + +class _Plain extends Component { + _Plain({super.priority}); +} + +class _Hitboxes extends PositionComponent with GestureHitboxes { + _Hitboxes() : super(size: Vector2.all(10)); +} 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: