From c0f537cd6f91cb6615c1e9390a7b7fd5b3093d21 Mon Sep 17 00:00:00 2001 From: rrbox <87851278+rrbox@users.noreply.github.com> Date: Sat, 27 Sep 2025 11:53:13 +0900 Subject: [PATCH 1/9] Add FeatureFlags --- Sources/ECS/Commons/FeatureFlags.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 Sources/ECS/Commons/FeatureFlags.swift diff --git a/Sources/ECS/Commons/FeatureFlags.swift b/Sources/ECS/Commons/FeatureFlags.swift new file mode 100644 index 0000000..1651bdb --- /dev/null +++ b/Sources/ECS/Commons/FeatureFlags.swift @@ -0,0 +1,15 @@ +// +// FeatureFlags.swift +// ECS_Swift +// +// Created by rrbox on 2025/09/27. +// + +struct FeatureFlags: OptionSet { + let rawValue: UInt8 + static let enabled: FeatureFlags = [] + + func isEnabled(_ flags: FeatureFlags) -> Bool { + Self.enabled.contains(flags) + } +} From 68a5b7ba6e41e67fdfe73e33b57babb4aa4fa0f0 Mon Sep 17 00:00:00 2001 From: rrbox <87851278+rrbox@users.noreply.github.com> Date: Sat, 27 Sep 2025 17:24:17 +0900 Subject: [PATCH 2/9] Make isEnabled method static in FeatureFlags struct --- Sources/ECS/Commons/FeatureFlags.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ECS/Commons/FeatureFlags.swift b/Sources/ECS/Commons/FeatureFlags.swift index 1651bdb..977521b 100644 --- a/Sources/ECS/Commons/FeatureFlags.swift +++ b/Sources/ECS/Commons/FeatureFlags.swift @@ -9,7 +9,7 @@ struct FeatureFlags: OptionSet { let rawValue: UInt8 static let enabled: FeatureFlags = [] - func isEnabled(_ flags: FeatureFlags) -> Bool { + static func isEnabled(_ flags: FeatureFlags) -> Bool { Self.enabled.contains(flags) } } From 4962d47acb71f92b8c3ed0ca6ee8d991078e44c0 Mon Sep 17 00:00:00 2001 From: rrbox <87851278+rrbox@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:58:00 +0900 Subject: [PATCH 3/9] Add Hierarchy resouce --- Sources/PlugIns/Graphic2D/Hierarchy.swift | 72 +++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 Sources/PlugIns/Graphic2D/Hierarchy.swift diff --git a/Sources/PlugIns/Graphic2D/Hierarchy.swift b/Sources/PlugIns/Graphic2D/Hierarchy.swift new file mode 100644 index 0000000..7e90e83 --- /dev/null +++ b/Sources/PlugIns/Graphic2D/Hierarchy.swift @@ -0,0 +1,72 @@ +// +// Hierarchy.swift +// ECS_Swift +// +// Created by rrbox on 2025/09/30. +// + +import ECS + +public final class Hierarchy: ResourceProtocol { + private(set) var childrenMap = [Entity: Set]() + private(set) var parentMap = [Entity: Entity]() + + // MARK: - public + + public func children(of parentEntity: Entity) -> Set? { + childrenMap[parentEntity] + } + + public func parent(of childEntity: Entity) -> Entity? { + parentMap[childEntity] + } + + public func hasParentSlot(_ parent: Entity) -> Bool { + return childrenMap.keys.contains(parent) + } + + public func childrenIsEmpty(for parent: Entity) -> Bool { + childrenMap[parent]?.isEmpty ?? true + } + + // MARK: - internal + + func insertChild(_ childEntity: Entity, forParent parentEntity: Entity) { + insertChildToSlot(childEntity: childEntity, parentEntity: parentEntity) + setParentToSlot(parentEntity: parentEntity, childEntity: childEntity) + } + + /// 指定した entity を hierarchy グラフから完全に削除します + func removeRecursively(entity: Entity) { + childrenMap[entity]?.forEach { removeRecursively(entity: $0) } + childrenMap.removeValue(forKey: entity) + guard let parent = parentMap.removeValue(forKey: entity) else { return } + childrenMap[parent]?.remove(entity) + } + + func removeAllChildren(fromEntity entity: Entity) { + guard let children = childrenMap[entity] else { return } + children.forEach { child in + parentMap.removeValue(forKey: child) + } + childrenMap.removeValue(forKey: entity) + } + + func removeFromParent(_ child: Entity) { + guard let parent = parentMap.removeValue(forKey: child) else { return } + childrenMap[parent]?.remove(child) + if childrenMap[parent]?.count == 0 { + childrenMap.removeValue(forKey: parent) + } + } + + // MARK: - private + + private func insertChildToSlot(childEntity: Entity, parentEntity: Entity) { + childrenMap[parentEntity, default: []].insert(childEntity) + } + + private func setParentToSlot(parentEntity: Entity, childEntity: Entity) { + parentMap[childEntity] = parentEntity + } +} From 889c00644acba3c0f448cdc2443f228af741cc01 Mon Sep 17 00:00:00 2001 From: rrbox <87851278+rrbox@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:58:42 +0900 Subject: [PATCH 4/9] Update GraphicPlugInTests --- .../GraphicPlugInTests.swift | 70 ++++++++++++++----- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/Tests/GraphicPlugInTests/GraphicPlugInTests.swift b/Tests/GraphicPlugInTests/GraphicPlugInTests.swift index 0eb8f5a..3a508d5 100644 --- a/Tests/GraphicPlugInTests/GraphicPlugInTests.swift +++ b/Tests/GraphicPlugInTests/GraphicPlugInTests.swift @@ -93,6 +93,7 @@ final class GraphicPlugInTests: XCTestCase { parents: Query3>, currentTime: Resource, commands: Commands, + hierarchy: Resource, nodes: Resource ) in switch currentTime.resource.value { @@ -101,12 +102,16 @@ final class GraphicPlugInTests: XCTestCase { XCTAssertEqual(parents.components.data.count, 0) XCTAssertEqual(parentNode.children.count, 0) XCTAssertEqual(children.components.data.count, 0) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 0) + XCTAssertEqual(hierarchy.resource.parentMap.count, 0) XCTAssertEqual(nodes.resource.store.count, 2) flags[0] += 1 case 1: - XCTAssertEqual(parents.components.data.count, 2) + XCTAssertEqual(parents.components.data.count, 1) XCTAssertEqual(parentNode.children.count, 1) XCTAssertEqual(children.components.data.count, 1) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 1) + XCTAssertEqual(hierarchy.resource.parentMap.count, 1) XCTAssertEqual(nodes.resource.store.count, 2) flags[1] += 1 default: return @@ -147,11 +152,14 @@ final class GraphicPlugInTests: XCTestCase { .addSystem(.update) { ( children: Query, parents: Query2, + hierarchy: Resource, nodes: Resource ) in flags[0] += 1 - XCTAssertEqual(parents.components.data.count, 2) + XCTAssertEqual(parents.components.data.count, 0) XCTAssertEqual(children.components.data.count, 0) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 0) + XCTAssertEqual(hierarchy.resource.parentMap.count, 0) XCTAssertEqual(nodes.resource.store.count, 2) } @@ -182,14 +190,21 @@ final class GraphicPlugInTests: XCTestCase { .setGraphic(nodes.resource.create(node: parentNode)) .addChild(child) } - .addSystem(.update) { (children: Query, parents: Query2, currentTime: Resource) in + .addSystem(.update) { ( + children: Query, + parents: Query2, + hierarchy: Resource, + currentTime: Resource + ) in // add child 関数が機能しているのかをチェック - XCTAssertEqual(parents.components.data.count, 2) switch currentTime.resource.value { case -1: fatalError() // ここは通過しない. case 0: flags[0] += 1 + XCTAssertEqual(parents.components.data.count, 1) XCTAssertEqual(parentNode.children.count, 1) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 1) + XCTAssertEqual(hierarchy.resource.parentMap.count, 1) XCTAssertEqual(children.components.data.count, 1) default: return } @@ -200,10 +215,10 @@ final class GraphicPlugInTests: XCTestCase { parents: Query, commands: Commands, currentTime: Resource, + hierarchy: Resource, nodes: Resource ) in // remove from parent 関数の効果をチェック - XCTAssertEqual(parents.components.data.count, 2) switch currentTime.resource.value { case 1: flags[1] += 1 @@ -213,8 +228,11 @@ final class GraphicPlugInTests: XCTestCase { } case 2: flags[1] += 1 + XCTAssertEqual(parents.components.data.count, 0) XCTAssertEqual(children.query.components.data.count, 0) XCTAssertEqual(parentNode.children.count, 0) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 0) + XCTAssertEqual(hierarchy.resource.parentMap.count, 0) XCTAssertEqual(nodes.resource.store.count, 1) default: break } @@ -257,35 +275,42 @@ final class GraphicPlugInTests: XCTestCase { currentTime: Resource, commands: Commands, children: Query, - parents: Query2, + parents: Filtered, With>, totalEntities: Query, + hierarchy: Resource, nodes: Resource ) in switch currentTime.resource.value { case -1: fatalError() // ここは通過しません. case 0: flags[0] += 1 - XCTAssertEqual(parents.components.data.count, 3) + XCTAssertEqual(parents.query.components.data.count, 2) XCTAssertEqual(children.components.data.count, 2) XCTAssertEqual(totalEntities.components.data.count, 3) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 2) + XCTAssertEqual(hierarchy.resource.parentMap.count, 2) XCTAssertEqual(nodes.resource.store.count, 3) case 1: flags[1] += 1 - parents.update { entity, parent in - if parent.children.count == 1 { + parents.update { entity in + if hierarchy.resource.children(of: entity)?.count == 1 { commands.despawn(entity: entity) } } - XCTAssertEqual(parents.components.data.count, 3) + XCTAssertEqual(parents.query.components.data.count, 2) XCTAssertEqual(children.components.data.count, 2) XCTAssertEqual(totalEntities.components.data.count, 3) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 2) + XCTAssertEqual(hierarchy.resource.parentMap.count, 2) XCTAssertEqual(nodes.resource.store.count, 3) case 2: flags[2] += 1 - XCTAssertEqual(parents.components.data.count, 0) + XCTAssertEqual(parents.query.components.data.count, 0) XCTAssertEqual(children.components.data.count, 0) XCTAssertEqual(totalEntities.components.data.count, 0) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 0) + XCTAssertEqual(hierarchy.resource.parentMap.count, 0) XCTAssertEqual(nodes.resource.store.count, 0) default: fatalError() @@ -318,27 +343,40 @@ final class GraphicPlugInTests: XCTestCase { .setGraphic(nodes.resource.create(node: parentNode)) .addChild(child) } - .addSystem(.update) { (currentTime: Resource, commands: Commands, children: Filtered, With>, parents: Query2, totalEntities: Query) in + .addSystem(.update) { ( + currentTime: Resource, + commands: Commands, + children: Filtered, With>, + parents: Query2, + hierarchy: Resource, + totalEntities: Query + ) in switch currentTime.resource.value { case -1: fatalError() // ここは通過しません. case 0: XCTAssertStepOrder(currentStep: 0, steps: &flags) - XCTAssertEqual(parents.components.data.count, 2) + XCTAssertEqual(parents.components.data.count, 1) XCTAssertEqual(children.query.components.data.count, 1) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 1) + XCTAssertEqual(hierarchy.resource.parentMap.count, 1) XCTAssertEqual(totalEntities.components.data.count, 2) case 1: XCTAssertStepOrder(currentStep: 1, steps: &flags) children.update { entity in commands.despawn(entity: entity) } - - XCTAssertEqual(parents.components.data.count, 2) + + XCTAssertEqual(parents.components.data.count, 1) XCTAssertEqual(children.query.components.data.count, 1) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 1) + XCTAssertEqual(hierarchy.resource.parentMap.count, 1) XCTAssertEqual(totalEntities.components.data.count, 2) case 2: XCTAssertStepOrder(currentStep: 2, steps: &flags) - XCTAssertEqual(parents.components.data.count, 1) + XCTAssertEqual(parents.components.data.count, 0) XCTAssertEqual(children.query.components.data.count, 0) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 0) + XCTAssertEqual(hierarchy.resource.parentMap.count, 0) XCTAssertEqual(totalEntities.components.data.count, 1) default: fatalError() // ここは通過しません. From bfce02ad1d5c408edc36448c1b6ca5b70abad543 Mon Sep 17 00:00:00 2001 From: rrbox <87851278+rrbox@users.noreply.github.com> Date: Sun, 26 Oct 2025 22:04:22 +0900 Subject: [PATCH 5/9] Update SetGraphic command - Remove component addition in SetGraphic command --- Sources/PlugIns/Graphic2D/Commands/SetGraphic.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/PlugIns/Graphic2D/Commands/SetGraphic.swift b/Sources/PlugIns/Graphic2D/Commands/SetGraphic.swift index 9c7d628..e0211ce 100644 --- a/Sources/PlugIns/Graphic2D/Commands/SetGraphic.swift +++ b/Sources/PlugIns/Graphic2D/Commands/SetGraphic.swift @@ -27,8 +27,6 @@ final class SetGraphic: EntityCommand { override func runCommand(forRecord record: EntityRecordRef, inWorld world: World) { self.setEntityInfoForNode(entity) - - record.addComponent(Parent(_children: [])) } } From 80f6a1e7ef73419861216407a72219381412e98f Mon Sep 17 00:00:00 2001 From: rrbox <87851278+rrbox@users.noreply.github.com> Date: Sun, 26 Oct 2025 22:05:50 +0900 Subject: [PATCH 6/9] Child and Parent structs simplified by removing unnecessary properties --- .../Commands/EntityCommands+Graphic.swift | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/Sources/PlugIns/Graphic2D/Commands/EntityCommands+Graphic.swift b/Sources/PlugIns/Graphic2D/Commands/EntityCommands+Graphic.swift index 88063d1..a6012a5 100644 --- a/Sources/PlugIns/Graphic2D/Commands/EntityCommands+Graphic.swift +++ b/Sources/PlugIns/Graphic2D/Commands/EntityCommands+Graphic.swift @@ -8,19 +8,9 @@ import SpriteKit import ECS -public struct Child: Component { - var _parent: Entity - public var parent: Entity { - self._parent - } -} +public struct Child: Component {} -public struct Parent: Component { - var _children: Set - public var children: Set { - self._children - } -} +public struct Parent: Component {} struct _RemoveFromParentTransaction: Component { From e916934c6222a82090a544de3cbab90944734891 Mon Sep 17 00:00:00 2001 From: rrbox <87851278+rrbox@users.noreply.github.com> Date: Sun, 26 Oct 2025 22:08:10 +0900 Subject: [PATCH 7/9] Refactor entity hierarchy management in graphic plugin: streamline child-parent relationships and enhance transaction handling --- .../Commands/EntityCommands+Graphic.swift | 113 +++++++++++++----- Sources/PlugIns/Graphic2D/PlugInExport.swift | 71 ++++++----- 2 files changed, 127 insertions(+), 57 deletions(-) diff --git a/Sources/PlugIns/Graphic2D/Commands/EntityCommands+Graphic.swift b/Sources/PlugIns/Graphic2D/Commands/EntityCommands+Graphic.swift index a6012a5..33984fd 100644 --- a/Sources/PlugIns/Graphic2D/Commands/EntityCommands+Graphic.swift +++ b/Sources/PlugIns/Graphic2D/Commands/EntityCommands+Graphic.swift @@ -12,9 +12,11 @@ public struct Child: Component {} public struct Parent: Component {} -struct _RemoveFromParentTransaction: Component { +struct _RemoveFromParentTransaction: Component {} -} +struct _RemoveAllChildrenTransaction: Component {} + +struct _DespawnAllChildrenTransaction: Component {} final class AddChild: EntityCommand { let child: Entity @@ -26,22 +28,20 @@ final class AddChild: EntityCommand { override func runCommand(forRecord record: EntityRecordRef, inWorld world: World) { let childRecord = world.entityRecord(forEntity: self.child)! childRecord.addComponent(_AddChildNodeTransaction(parentEntity: self.entity)) + world.worldStorage.chunkStorageRef.pushUpdated(entityRecord: childRecord) } } final class RemoveAllChildren: EntityCommand { override func runCommand(forRecord record: EntityRecordRef, inWorld world: World) { - let node = record.component(ofType: Graphic.self)!.nodeRef - node.removeAllChildren() - record.componentRef(ofType: Parent.self)?.value._children = [] - - for child in record.componentRef(ofType: Parent.self)!.value.children { - let childRecord = world.entityRecord(forEntity: child)! - childRecord.removeComponent(ofType: Child.self) - world.worldStorage.chunkStorageRef.pushUpdated(entityRecord: childRecord) - } + record.addComponent(_RemoveAllChildrenTransaction()) + } +} +final class DespawnAllChildren: EntityCommand { + override func runCommand(forRecord record: EntityRecordRef, inWorld world: World) { + record.addComponent(_DespawnAllChildrenTransaction()) } } @@ -96,43 +96,96 @@ public extension EntityCommands { func removeChildIfDespawned( despawnEvent: EventReader, - query: Query, - parentQuery: Query + hierarchy: Resource, + commands: Commands ) { + let hierarchy = hierarchy.resource for event in despawnEvent.events { let entity = event.despawnedEntity - guard let parent = query.components(forEntity: entity)?.parent else { continue } - parentQuery.update(parent) { p in - p._children.remove(entity) - } + let parent = hierarchy.parent(of: entity) + hierarchy.removeFromParent(entity) + guard let parent, hierarchy.childrenIsEmpty(for: parent) else { continue } + commands.entity(parent) + .removeComponent(ofType: Parent.self) } } -// これが実行される時点ですでに parentEntity から despawn した parent が消えている. func despawnChildRecursive( despawnedEntity: Entity, - children: Query2, + hierarchy: Resource, commands: Commands ) { - children.update { entity, child in - if child.parent == despawnedEntity { - despawnChildRecursive(despawnedEntity: entity, children: children, commands: commands) - commands.despawn(entity: entity) - } + guard let children = hierarchy.resource.children(of: despawnedEntity) else { return } + for child in children { + despawnChildRecursive( + despawnedEntity: child, + hierarchy: hierarchy, + commands: commands + ) + commands.despawn(entity: child) } } -// これが実行される時点ですでに parentEntity から despawn した parent が消えている. func despawnChildIfParentDespawned( despawnedEntityEvent: EventReader, - children: Query2, + hierarchy: Resource, commands: Commands ) { - // despawn した entity と自分の親が一致する子を despawn する. for event in despawnedEntityEvent.events { let despawnedEntity = event.despawnedEntity - despawnChildRecursive(despawnedEntity: despawnedEntity, - children: children, - commands: commands) + despawnChildRecursive( + despawnedEntity: despawnedEntity, + hierarchy: hierarchy, + commands: commands + ) + hierarchy.resource.removeRecursively(entity: despawnedEntity) + } +} + +// TODO: child を despawn するかどうか検討する +// - despawn する場合: post update で状態を反映させる方法を検討する +// - despawn しない場合: child から Child component を外す +func removeAllChildren( + targetNodes: Filtered>, And, With<_RemoveAllChildrenTransaction>>>, + hierarchy: Resource, + commands: Commands +) { + targetNodes.update { entity, node in + node.nodeRef.removeAllChildren() + commands + .entity(entity) + .removeComponent(ofType: Parent.self) + .removeComponent(ofType: _RemoveAllChildrenTransaction.self) + let children = hierarchy.resource.children(of: entity) + children?.forEach { child in + commands + .entity(child) + .removeComponent(ofType: Child.self) + } + hierarchy.resource.removeAllChildren(fromEntity: entity) + } +} + +@MainActor +func despawnAllChildren( + targetNodes: Filtered>, And, With<_DespawnAllChildrenTransaction>>>, + hierarchy: Resource, + nodes: Resource, + commands: Commands +) { + targetNodes.update { entity, node in + node.nodeRef.removeAllChildren() + commands + .entity(entity) + .removeComponent(ofType: Parent.self) + .removeComponent(ofType: _DespawnAllChildrenTransaction.self) + + let children = hierarchy.resource.children(of: entity) + children?.forEach { child in + commands.despawn(entity: child) + // 防衛的に Nodes 経由で entity と SKNode の紐付けを削除する + nodes.resource.removeNode(forEntity: child) + } + hierarchy.resource.removeAllChildren(fromEntity: entity) } } diff --git a/Sources/PlugIns/Graphic2D/PlugInExport.swift b/Sources/PlugIns/Graphic2D/PlugInExport.swift index 16ca6c1..5fa0e7a 100644 --- a/Sources/PlugIns/Graphic2D/PlugInExport.swift +++ b/Sources/PlugIns/Graphic2D/PlugInExport.swift @@ -13,25 +13,31 @@ import ECS /// - query: entity heirarchy に入っていない entity の query. /// - graphics: 親 entity の SKNode を検索するための query. /// - scene: 親 entity が指定されていない場合に配置先となる scene. -/// - commands: `_AddChildNodeTransaction` を削除するための commands. +/// - commands: `_AddChildNodeTransaction` を削除するための commands.¥ func _addChildNodeSystem( query: Filtered>, WithOut>, - graphics: Query2, Parent>, + graphics: Query>, scene: Resource, + hierarchy: Resource, commands: Commands ) { - query.update { childEntity, parent, graphic in - if let parentEntity = parent.parentEntity { - graphics.update(parentEntity) { parentNode, children in + query.update { childEntity, transaction, graphic in + let childEntity = childEntity + let graphic = graphic + if let parentEntity = transaction.parentEntity { + graphics.update(parentEntity) { parentNode in parentNode.nodeRef.addChild(graphic.nodeRef) - children._children.insert(childEntity) + if !hierarchy.resource.hasParentSlot(parentEntity) { + commands.entity(parentEntity) + .addComponent(Parent()) + } + hierarchy.resource.insertChild(childEntity, forParent: parentEntity) commands.entity(childEntity) - .addComponent(Child(_parent: parentEntity)) + .addComponent(Child()) } } else { scene.resource.scene.addChild(graphic.nodeRef) } - commands .entity(childEntity) .removeComponent(ofType: _AddChildNodeTransaction.self) @@ -45,17 +51,24 @@ func _addChildNodeSystem( /// - commands: `_AddChildNodeTransaction` を削除するための commands. func _addChildNodeSystem( query: Filtered>, With>, - graphics: Query2, Parent>, + graphics: Query>, + hierarchy: Resource, commands: Commands ) { - query.update { childEntity, parent, graphic in - if let parentEntity = parent.parentEntity { + query.update { childEntity, transaction, graphic in + let childEntity = childEntity + let graphic = graphic + if let parentEntity = transaction.parentEntity { graphic.nodeRef.removeFromParent() - graphics.update(parentEntity) { parentNode, children in + graphics.update(parentEntity) { parentNode in parentNode.nodeRef.addChild(graphic.nodeRef) - children._children.insert(childEntity) + if !hierarchy.resource.hasParentSlot(parentEntity) { + commands.entity(parentEntity) + .addComponent(Parent()) + } + hierarchy.resource.insertChild(childEntity, forParent: parentEntity) commands.entity(childEntity) - .addComponent(Child(_parent: parentEntity)) + .addComponent(Child()) } } else { fatalError("parent entity not found") @@ -69,20 +82,23 @@ func _addChildNodeSystem( @MainActor func _removeFromParentSystem( - query: Filtered, Child>, With<_RemoveFromParentTransaction>>, - parents: Query, + query: Filtered>, And, With<_RemoveFromParentTransaction>>>, nodes: Resource, + hierarchy: Resource, commands: Commands ) { - query.update { childEntity, childNode, child in + query.update { childEntity, childNode in childNode.nodeRef.removeFromParent() nodes.resource.removeNode(forEntity: childEntity) commands.entity(childEntity) .removeComponent(ofType: Child.self) .removeComponent(ofType: _RemoveFromParentTransaction.self) - parents.update(child.parent) { parent in - parent._children.remove(childEntity) + guard let parent = hierarchy.resource.parent(of: childEntity) else { return } + hierarchy.resource.removeFromParent(childEntity) + if hierarchy.resource.childrenIsEmpty(for: parent) { + commands.entity(parent) + .removeComponent(ofType: Parent.self) } } } @@ -100,17 +116,18 @@ func _removeNodeIfDespawned(despawn: EventReader, nodes: Resou public func graphicPlugIn(world: World) { world .addResource(Nodes()) - .addSystem(.postStartUp, _addChildNodeSystem(query:graphics:scene:commands:)) - .addSystem(.postStartUp, _addChildNodeSystem(query:graphics:commands:)) - .addSystem(.postStartUp, _removeFromParentSystem(query:parents:nodes:commands:)) - .addSystem(.postUpdate, _addChildNodeSystem(query:graphics:scene:commands:)) - .addSystem(.postUpdate, _addChildNodeSystem(query:graphics:commands:)) - .addSystem(.postUpdate, _removeFromParentSystem(query:parents:nodes:commands:)) + .addResource(Hierarchy()) + .addSystem(.postStartUp, _addChildNodeSystem(query:graphics:hierarchy:commands:)) + .addSystem(.postStartUp, _addChildNodeSystem(query:graphics:scene:hierarchy:commands:)) + .addSystem(.postStartUp, _removeFromParentSystem(query:nodes:hierarchy:commands:)) + .addSystem(.postUpdate, _addChildNodeSystem(query:graphics:hierarchy:commands:)) + .addSystem(.postUpdate, _addChildNodeSystem(query:graphics:scene:hierarchy:commands:)) + .addSystem(.postUpdate, _removeFromParentSystem(query:nodes:hierarchy:commands:)) .buildWillDespawnResponder { responder in responder - .addSystem(.update, removeChildIfDespawned(despawnEvent:query:parentQuery:)) - .addSystem(.update, despawnChildIfParentDespawned(despawnedEntityEvent:children:commands:)) + .addSystem(.update, removeChildIfDespawned(despawnEvent:hierarchy:commands:)) + .addSystem(.update, despawnChildIfParentDespawned(despawnedEntityEvent:hierarchy:commands:)) .addSystem(.update, _removeNodeIfDespawned(despawn:nodes:)) } } From 386b476627ba2b2248164b5633fc90bbf6d2cf6f Mon Sep 17 00:00:00 2001 From: rrbox <87851278+rrbox@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:45:38 +0900 Subject: [PATCH 8/9] Add tests for despawning entities and children in GraphicPlugInTests --- .../GraphicPlugInTests.swift | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/Tests/GraphicPlugInTests/GraphicPlugInTests.swift b/Tests/GraphicPlugInTests/GraphicPlugInTests.swift index 3a508d5..f8dfb95 100644 --- a/Tests/GraphicPlugInTests/GraphicPlugInTests.swift +++ b/Tests/GraphicPlugInTests/GraphicPlugInTests.swift @@ -70,6 +70,65 @@ final class GraphicPlugInTests: XCTestCase { XCTAssertEqual(flags, [1]) } + // entity hierarchy から取り外す処理のテスト + func testDespawn() { + let scene = SKScene() + let node = SKNode() + var flags = [0, 0, 0] + let world = World() + .addResource(SceneResource(scene)) + .addPlugIn(graphicPlugIn(world:)) + .addSystem(.startUp) { (commands: Commands, nodes: Resource) in + commands.spawn() + .setGraphic(nodes.resource.create(node: node)) + } + .addSystem(.update) { ( + currentTime: Resource, + entities: Query + ) in + // add child 関数が機能しているのかをチェック + switch currentTime.resource.value { + case -1: fatalError() // ここは通過しない. + case 0: + flags[0] += 1 + XCTAssertEqual(scene.children.count, 1) + XCTAssertEqual(entities.components.data.count, 1) + default: return + } + } + .addSystem(.update) { ( + entities: Query, + parents: Query, + commands: Commands, + currentTime: Resource, + hierarchy: Resource, + nodes: Resource + ) in + // remove from parent 関数の効果をチェック + switch currentTime.resource.value { + case 1: + flags[1] += 1 + entities.update { entity in + commands.despawn(entity: entity) + } + case 2: + flags[2] += 1 + XCTAssertEqual(scene.children.count, 0) + XCTAssertEqual(entities.components.data.count, 0) + XCTAssertEqual(nodes.resource.store.count, 0) + default: break + } + } + + world.setUpWorld() + world.update(currentTime: -1) + world.update(currentTime: 0) + world.update(currentTime: 1) // この一番最後で _remove from parent tarnsaction 追加 + world.update(currentTime: 2) // remove from parent system 実行, 一番最後に component に変更反映 | ここで結果が出る + + XCTAssertEqual(flags, [1, 1, 1]) + } + func testAddChildOnUpdate() { let scene = SKScene() let parentNode = SKNode() @@ -172,6 +231,80 @@ final class GraphicPlugInTests: XCTestCase { } + // entity hierarchy から取り外す処理のテスト + func testDespawnChild() { + let scene = SKScene() + let parentNode = SKNode() + var flags = [0, 0] + let world = World() + .addResource(SceneResource(scene)) + .addPlugIn(graphicPlugIn(world:)) + .addSystem(.startUp) { (commands: Commands, nodes: Resource) in + let childNode = SKNode() + + let child = commands.spawn() + .setGraphic(nodes.resource.create(node: childNode)) + .id() + commands.spawn() + .setGraphic(nodes.resource.create(node: parentNode)) + .addChild(child) + } + .addSystem(.update) { ( + children: Query, + parents: Query2, + hierarchy: Resource, + currentTime: Resource + ) in + // add child 関数が機能しているのかをチェック + switch currentTime.resource.value { + case -1: fatalError() // ここは通過しない. + case 0: + flags[0] += 1 + XCTAssertEqual(parents.components.data.count, 1) + XCTAssertEqual(parentNode.children.count, 1) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 1) + XCTAssertEqual(hierarchy.resource.parentMap.count, 1) + XCTAssertEqual(children.components.data.count, 1) + default: return + } + } + .addSystem(.update) { ( + children: Filtered, + With>, + parents: Query, + commands: Commands, + currentTime: Resource, + hierarchy: Resource, + nodes: Resource + ) in + // remove from parent 関数の効果をチェック + switch currentTime.resource.value { + case 1: + flags[1] += 1 + children.update { entity in + commands.despawn(entity: entity) + } + case 2: + flags[1] += 1 + XCTAssertEqual(parents.components.data.count, 0) + XCTAssertEqual(children.query.components.data.count, 0) + XCTAssertEqual(parentNode.children.count, 0) + XCTAssertEqual(hierarchy.resource.childrenMap.count, 0) + XCTAssertEqual(hierarchy.resource.parentMap.count, 0) + XCTAssertEqual(nodes.resource.store.count, 1) + default: break + } + } + + world.setUpWorld() + world.update(currentTime: -1) + world.update(currentTime: 0) + world.update(currentTime: 1) // この一番最後で _remove from parent tarnsaction 追加 + world.update(currentTime: 2) // remove from parent system 実行, 一番最後に component に変更反映 | ここで結果が出る + + XCTAssertEqual(flags, [1, 2]) + } + // entity hierarchy から取り外す処理のテスト func testRemoveFromParent() { let scene = SKScene() From 1025498a65244b81df523c81fd9b0b6d559830f0 Mon Sep 17 00:00:00 2001 From: rrbox <87851278+rrbox@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:46:33 +0900 Subject: [PATCH 9/9] Refactor Nodes and PlugInExport: organize public/internal methods and enhance node removal logic --- Sources/PlugIns/Graphic2D/Nodes.swift | 6 +++++- Sources/PlugIns/Graphic2D/PlugInExport.swift | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Sources/PlugIns/Graphic2D/Nodes.swift b/Sources/PlugIns/Graphic2D/Nodes.swift index 701cddd..b6851a9 100644 --- a/Sources/PlugIns/Graphic2D/Nodes.swift +++ b/Sources/PlugIns/Graphic2D/Nodes.swift @@ -22,6 +22,8 @@ public final class Nodes: ResourceProtocol { var store = [Entity: SKNode]() + // MARK: - public + /// node hierarchy に存在しない SKNode を entity に紐付けます. public func create(node: Node) -> NodeCreate { return .init( @@ -70,11 +72,13 @@ public final class Nodes: ResourceProtocol { ) } + // MARK: - internal + func regiester(entity: Entity, node: Node) { store[entity] = node } - func removeNode(forEntity entity: Entity) { + @discardableResult func removeNode(forEntity entity: Entity) -> SKNode? { store.removeValue(forKey: entity) } } diff --git a/Sources/PlugIns/Graphic2D/PlugInExport.swift b/Sources/PlugIns/Graphic2D/PlugInExport.swift index 5fa0e7a..7ac3958 100644 --- a/Sources/PlugIns/Graphic2D/PlugInExport.swift +++ b/Sources/PlugIns/Graphic2D/PlugInExport.swift @@ -104,10 +104,15 @@ func _removeFromParentSystem( } @MainActor -func _removeNodeIfDespawned(despawn: EventReader, nodes: Resource) { +func _removeNodeIfDespawned( + despawn: EventReader, + nodes: Resource +) { for event in despawn.events { let despawnedEntity = event.despawnedEntity - nodes.resource.removeNode(forEntity: despawnedEntity) + nodes.resource + .removeNode(forEntity: despawnedEntity)? + .removeFromParent() } }