diff --git a/packages/flame/benchmark/README.md b/packages/flame/benchmark/README.md index 17105e12b66..ddb419e8a48 100644 --- a/packages/flame/benchmark/README.md +++ b/packages/flame/benchmark/README.md @@ -49,7 +49,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/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 6b54c683ac1..f0203aad627 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -14,6 +14,7 @@ import 'package:meta/meta.dart'; part 'component_list.dart'; part 'component_tree_root.dart'; part 'custom_traversal.dart'; +part 'query_cache.dart'; /// [Component]s are the basic building blocks for a [FlameGame]. /// diff --git a/packages/flame/lib/src/components/core/component_list.dart b/packages/flame/lib/src/components/core/component_list.dart index 615f4ee3875..69f124f165a 100644 --- a/packages/flame/lib/src/components/core/component_list.dart +++ b/packages/flame/lib/src/components/core/component_list.dart @@ -63,7 +63,7 @@ class ComponentList extends Iterable { static const int _tombstoneCompactionThreshold = 16; /// The per-type query caches, created by [register]. - Map>? _queries; + _QueryCacheStore? _queries; /// A monotonically increasing counter, bumped on every membership or order /// change of any [ComponentList] (adds, removes, clears, reorders). The @@ -156,6 +156,10 @@ class ComponentList extends Iterable { 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. + _queries?.compact(this); final elements = _elements; if (_length == 0 && elements.isNotEmpty) { // The list contains only tombstones; reset it. @@ -175,14 +179,7 @@ class ComponentList extends Iterable { component._containerList = this; _length++; _structureVersion++; - final caches = _queries?.values; - if (caches != null) { - for (final cache in caches) { - if (cache.check(component)) { - cache.insertSorted(component); - } - } - } + _queries?.onAdd(component); return true; } @@ -220,20 +217,18 @@ class ComponentList extends Iterable { _length--; _tombstones++; _structureVersion++; - final caches = _queries?.values; - if (caches != null) { - for (final cache in caches) { - if (cache.check(component)) { - cache.data.remove(component); - } - } - } + _queries?.onRemove(component); 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. + _queries?.compact(this); } else if (_tombstones >= _tombstoneCompactionThreshold && _tombstones * 2 >= _elements.length) { _compact(); + _queries?.compact(this); } return true; } @@ -255,12 +250,7 @@ class ComponentList extends Iterable { _tombstones = 0; _shiftCount++; _structureVersion++; - final caches = _queries?.values; - if (caches != null) { - for (final cache in caches) { - cache.data.clear(); - } - } + _queries?.clear(); } /// Restores the priority ordering after one or more elements have changed @@ -273,6 +263,10 @@ class ComponentList extends Iterable { void _rebalance() { // Removes all tombstones, which makes the `element!` accesses safe. _compact(); + // Not needed for correctness, since every read compacts as well, but it + // keeps [_QueryCacheStore.resort] from ordering entries that are about to + // be dropped, by an element index that they no longer have. + _queries?.compact(this); final elements = _elements; var isSorted = true; for (var i = 1; i < elements.length; i++) { @@ -298,12 +292,7 @@ class ComponentList extends Iterable { for (var i = 0; i < elements.length; i++) { elements[i]!._containerIndex = i; } - final caches = _queries?.values; - if (caches != null) { - for (final cache in caches) { - cache.resort(); - } - } + _queries?.resort(); } /// Rewrites the backing list without its tombstones, restoring exact @@ -331,23 +320,13 @@ class ComponentList extends Iterable { /// Whether type [C] has been registered as a queryable type. bool isRegistered() { - return _queries?.containsKey(C) ?? false; + return _queries?.isRegistered() ?? false; } /// Registers [C] as a queryable type, so that [query] can answer in /// constant time. Does nothing if the type is already registered. void register() { - final queries = _queries ??= {}; - if (queries.containsKey(C)) { - return; - } - final data = []; - for (final element in _elements) { - if (element is C) { - data.add(element); - } - } - queries[C] = _QueryCache(data); + (_queries ??= _QueryCacheStore()).register(this); } /// All elements of type [C], in priority order, in constant time. @@ -355,33 +334,27 @@ class ComponentList extends Iterable { /// 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 type $C. This list is in strict mode, ' - 'which requires register<$C>() to be called before the first ' - 'query<$C>(), so that the query cache is built at a controlled ' - 'moment (typically in onLoad) instead of in the middle of a frame. ' - 'To register types lazily instead, create the list with ' - 'strictMode: false.', - ); - } - 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; + final cached = _queries?.find(this); + if (cached != null) { + return cached; + } + if (strictMode) { + throw StateError( + 'Cannot query unregistered type $C. This list is in strict mode, ' + 'which requires register<$C>() to be called before the first ' + 'query<$C>(), so that the query cache is built at a controlled ' + 'moment (typically in onLoad) instead of in the middle of a frame. ' + 'To register types lazily instead, create the list with ' + 'strictMode: false.', + ); + } + register(); + return _queries!.find(this)!; } @override Iterable whereType() { - final cache = _queries?[C]; - if (cache != null) { - return cache.data as Iterable; - } - return super.whereType(); + return _queries?.find(this) ?? super.whereType(); } } @@ -493,35 +466,3 @@ class _ReversedComponentListIterator implements Iterator { 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 list. -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 list (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; - } - final insertionIndex = _partitionPoint( - list, - (element) => element._containerIndex < index, - ); - list.insert(insertionIndex, component as C); - } - - /// Re-sorts the cache after the main list 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/query_cache.dart b/packages/flame/lib/src/components/core/query_cache.dart new file mode 100644 index 00000000000..2269e281937 --- /dev/null +++ b/packages/flame/lib/src/components/core/query_cache.dart @@ -0,0 +1,165 @@ +part of 'component.dart'; + +/// The per-type query caches of a [ComponentList], created lazily on the +/// first [ComponentList.register] call. +/// +/// Each cache holds the subset of the list's elements that are of the +/// registered type, in the same order as the list itself, so that +/// [ComponentList.query] can answer in constant time. The list notifies the +/// store of every structural change ([onAdd], [onRemove], [clear], [resort]) +/// to keep the caches up to date. +/// +/// Removals are handled lazily, mirroring the tombstones of the backing +/// array: [onRemove] only marks the caches that match the removed component, +/// and the stale entries are dropped by [compact], in a single pass that +/// covers any number of removals at once, before anything can observe them. +/// Searching every matching cache on each removal instead would cost O(n) in +/// the size of the cache, while the removal from the backing array itself is +/// O(1). +class _QueryCacheStore { + final Map> _caches = {}; + + /// Whether any of the caches may still hold entries for components that + /// have since been removed from the list. + bool _hasStaleEntries = false; + + /// Whether type [C] has been registered as a queryable type. + bool isRegistered() => _caches.containsKey(C); + + /// Builds a cache for type [C] from the current contents of [list]. Does + /// nothing if the type is already registered. + void register(ComponentList list) { + if (_caches.containsKey(C)) { + return; + } + final data = []; + for (final element in list._elements) { + if (element is C) { + data.add(element); + } + } + _caches[C] = _QueryCache(data); + } + + /// The cached elements of type [C], freshly compacted, or `null` if the + /// type is not registered. + Iterable? find(ComponentList list) { + final cache = _caches[C]; + if (cache == null) { + return null; + } + compact(list); + // 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; + } + + /// Inserts [component] into every cache whose type matches it. + void onAdd(Component component) { + for (final cache in _caches.values) { + if (cache.check(component)) { + cache.insertSorted(component); + } + } + } + + /// Marks every cache whose type matches [component] as holding a stale + /// entry; the entry itself is dropped by the next [compact]. + void onRemove(Component component) { + for (final cache in _caches.values) { + if (cache.check(component)) { + cache.hasStaleEntries = true; + _hasStaleEntries = true; + } + } + } + + /// Empties every cache, dropping any stale entries with the live ones. + void clear() { + for (final cache in _caches.values) { + cache + ..data.clear() + ..hasStaleEntries = false; + } + _hasStaleEntries = false; + } + + /// Restores the order of every cache after the list has been re-sorted (at + /// which point every element's index is up to date again). + void resort() { + for (final cache in _caches.values) { + cache.resort(); + } + } + + /// Drops the entries of removed components from the caches, if any removal + /// has left some behind. + @pragma('vm:prefer-inline') + @pragma('wasm:prefer-inline') + void compact(ComponentList list) { + if (!_hasStaleEntries) { + return; + } + _hasStaleEntries = false; + for (final cache in _caches.values) { + cache.compact(list); + } + } +} + +/// 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 list. +class _QueryCache { + _QueryCache(this.data); + + final List data; + + /// Whether [data] may hold entries for components that have since been + /// removed from the list; see [_QueryCacheStore._hasStaleEntries]. + 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 list (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; + } + final insertionIndex = _partitionPoint( + list, + (element) => element._containerIndex < index, + ); + list.insert(insertionIndex, component as C); + } + + /// Re-sorts the cache after the main list 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/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..e7dc0f24be0 --- /dev/null +++ b/packages/flame/test/components/core/component_list_query_test.dart @@ -0,0 +1,234 @@ +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 parent = Component(); + final list = parent.children..register<_Marked>(); + final marked = _Marked(1); + parent + ..add(marked) + ..add(_Plain()); + + expect(list.query<_Marked>(), [marked]); + + parent.remove(marked); + expect(list.query<_Marked>(), isEmpty); + }); + + test('removing and adding back does not duplicate the cache entry', () { + final parent = Component(); + final list = parent.children..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. + parent + ..add(marked) + ..add(_Marked(2)); + + parent + ..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 sourceParent = Component(); + final targetParent = Component(); + final source = sourceParent.children..register<_Marked>(); + final target = targetParent.children..register<_Marked>(); + final marked = _Marked(1); + sourceParent + ..add(marked) + ..add(_Marked(2)); + targetParent.add(_Marked(3)); + + sourceParent.remove(marked); + targetParent.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 parent = Component(); + final list = parent.children..register<_Marked>(); + final marked = List.generate(10, (i) => _Marked(i, priority: i)); + for (var i = 0; i < 10; i++) { + parent + ..add(marked[i]) + ..add(_Plain(priority: i)); + } + + // Removals from the front, the middle and the end, all before anything + // reads the cache again. + parent + ..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); + parent.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 parent = Component(); + final list = parent.children..register<_Marked>(); + final marked = List.generate(5, (i) => _Marked(i, priority: i)); + for (final component in marked) { + parent.add(component); + } + + parent.remove(marked[2]); + marked[0].priority = 10; + parent.rebalanceChildren(); + + expect(list.query<_Marked>().map((c) => c.id), [1, 3, 4, 0]); + }); + + test('emptying the list empties the caches', () { + final parent = Component(); + final list = parent.children..register<_Marked>(); + final marked = List.generate(3, _Marked.new); + for (final component in marked) { + parent.add(component); + } + + for (final component in marked) { + parent.remove(component); + } + + expect(list.query<_Marked>(), isEmpty); + expect(list, isEmpty); + }); + + testWithFlameGame( + 'mounting re-adds children without duplicating cache entries', + (game) async { + final parent = Component(); + final list = parent.children..register<_Marked>(); + final kept = _Marked(1); + final removed = _Marked(2); + parent + ..add(kept) + ..add(removed) + ..add(_Plain()) + ..remove(removed); + + await game.world.ensureAdd(parent); + + expect(list.query<_Marked>().map((c) => c.id), [1]); + }, + ); + + test('removals past the tombstone compaction threshold', () { + final parent = Component(); + final list = parent.children..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) { + parent.add(component); + } + + for (var i = 0; i < 100; i += 2) { + parent.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 parent = Component(); + final list = parent.children..register<_Marked>(); + final marked = _Marked(1); + parent.add(marked); + + parent.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 parent = Component(); + final list = parent.children + ..register<_Marked>() + ..register<_Plain>(); + final marked = _Marked(1); + final plain = _Plain(); + parent + ..add(marked) + ..add(plain); + + parent.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)); +}