diff --git a/src/Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj b/src/Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj index 19c8eb3..692cf07 100644 --- a/src/Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj +++ b/src/Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj @@ -16,10 +16,10 @@ - - - - + + + + diff --git a/src/Editor/Ghost.Editor.Core/SceneGraph/EntityNode.cs b/src/Editor/Ghost.Editor.Core/SceneGraph/EntityNode.cs index 941b2f3..c15075b 100644 --- a/src/Editor/Ghost.Editor.Core/SceneGraph/EntityNode.cs +++ b/src/Editor/Ghost.Editor.Core/SceneGraph/EntityNode.cs @@ -6,9 +6,16 @@ namespace Ghost.Editor.Core.SceneGraph; public sealed partial class EntityNode : SceneGraphNode { - private readonly Entity _entity; + public Entity Entity + { + get; + } - public Entity Entity => _entity; + public EntityNode(World world, Entity entity, string name) + : base(world, name) + { + Entity = entity; + } public override IconSource? CreateIcon() { @@ -27,19 +34,4 @@ public sealed partial class EntityNode : SceneGraphNode { throw new NotImplementedException(); } - - public override DataTemplate GetSceneHierarchyTemplate() - { - var template = @" - - - - - - - - "; - - return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template); - } } diff --git a/src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraph Implementation Plan.md b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraph Implementation Plan.md new file mode 100644 index 0000000..861f9f4 --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraph Implementation Plan.md @@ -0,0 +1,521 @@ +# SceneGraph Implementation Plan + +## Summary of Design Decisions + +| Decision | Choice | +|----------|--------| +| Hierarchy data model | Linked-list (`parent`/`firstChild`/`nextSibling`), efficient for ECS | +| Editor ↔ World relationship | Mirror — editor world holds ECS entities, SceneGraph reflects them as observable nodes | +| Transform representation | `LocalToWorld` matrix only (no separate PRS components) | +| Implementation order | Data structures → HierarchySystem → Mirror bridge → UI → Serialization | + +--- + +## Phase 1: Fix SceneGraph Data Structures + +**Goal:** Make `EntityNode`, `SceneNode`, and `SceneGraphNode` correct, complete, and constructible. + +### 1.1 Enhance `SceneGraphNode` + +**File:** `SceneGraphNode.cs` + +- Add a `World` property so every node knows which world it belongs to. +- Make the constructor accept `World` and `string name`. +- Keep `ObservableObject` for MVVM binding. +- Keep `Children` as `ObservableCollection`. + +```csharp +public abstract partial class SceneGraphNode : ObservableObject, IInspectable +{ + public World World { get; } + + [ObservableProperty] + public partial string Name { get; set; } + + public ObservableCollection Children { get; } = new(); + + protected SceneGraphNode(World world, string name) + { + World = world; + Name = name; + } + + // ... existing abstract members +} +``` + +### 1.2 Enhance `SceneNode` + +**File:** `SceneNode.cs` + +- Add a `Scene` field referencing the runtime `Scene` struct. +- Add a constructor: `SceneNode(World world, Scene scene, string name)`. +- Remove `XamlReader.Load()` template generation (Phase 4 will move to XAML resources). + +```csharp +public sealed partial class SceneNode : SceneGraphNode +{ + public Scene Scene { get; } + + public SceneNode(World world, Scene scene, string name) + : base(world, name) + { + Scene = scene; + } + + // icon, header, inspector, template +} +``` + +### 1.3 Enhance `EntityNode` + +**File:** `EntityNode.cs` + +- Add a constructor: `EntityNode(World world, Entity entity, string name)`. +- Remove `XamlReader.Load()` template. +- Entity reference is read-only after construction (immutable node identity). + +```csharp +public sealed partial class EntityNode : SceneGraphNode +{ + public Entity Entity { get; } + + public EntityNode(World world, Entity entity, string name) + : base(world, name) + { + Entity = entity; + } + + // icon, header, inspector, template +} +``` + +### 1.4 Add `SceneGraphBuilder` + +**New file:** `SceneGraphBuilder.cs` + +Used for the **initial full build** of the scene graph from an ECS `World`. After initial construction, incremental updates are handled by `SceneGraphSyncService` (Phase 3). + +**Algorithm:** + +1. Query all entities with `SceneID` component. +2. Group entities by `SceneID.scene.ID`. +3. For each scene group: + - Create a `SceneNode` (name comes from editor metadata, not from runtime). + - Walk the `Hierarchy` component linked-list to build the tree: + - Root entities are those with `Hierarchy.parent == Entity.Invalid`. + - For each root, create an `EntityNode` and recursively visit `firstChild` → `nextSibling`. + - Entity names come from a naming system (either a component like `EntityName`, or editor-side metadata stored on the node). +4. Return `ObservableCollection` (the list of root scenes). + +**Name resolution strategy:** + +Names are stored directly on `EntityNode.Name`. During initial build, all entities get default names. During incremental sync, existing nodes (and their user-assigned names) are preserved because nodes are matched by `Entity` identity rather than recreated. See Phase 1.5 and 3.2 for details. + +### 1.5 Entity Names (Editor-Only) + +**No runtime component.** Entity names have no purpose in the runtime ECS. Names live purely on the editor side, stored directly on `EntityNode.Name`. + +**Strategy:** Entity names persist naturally across syncs because `EntityNode` instances survive — the sync is incremental, not a full rebuild (see Phase 3.2). Names are read directly from `EntityNode.Name` for inspector display and serialization. + +- New entities get a default name (e.g., `"Entity"`). +- User-renamed entities keep their name because the node instance is preserved. +- Destroyed entities have their node removed (names disappear with them). +- No dictionary, no cross-world identity concerns, no runtime footprint. + +--- + +## Phase 2: HierarchySystem (Runtime) + +**Goal:** Maintain the `Hierarchy` component's linked-list invariants. + +**New file:** `Runtime/Ghost.Engine/Systems/HierarchySystem.cs` + +### 2.1 Operations + +The system exposes static helper methods (or instance methods on `EntityManager`) for hierarchy mutation. All mutations use `EntityCommandBuffer` internally to avoid structural changes mid-iteration. + +| Operation | Description | +|-----------|-------------| +| `SetParent(child, parent)` | Links `child` as a child of `parent`. Updates `parent`, `firstChild`, `nextSibling` on both sides. | +| `RemoveParent(child)` | Unlinks `child` from its current parent. It becomes a root entity. | +| `DestroyEntity(entity)` | Also unlinks from parent, reparents children to grandparent (or makes them roots). | + +### 2.2 `SetParent(Entity child, Entity parent)` + +**Preconditions:** +- `child.IsValid` and `parent.IsValid`. +- Both entities exist in the world. +- Both have the `Hierarchy` component. +- `child != parent` (no self-parenting). +- `parent` is not a descendant of `child` (no cycles). + +**Algorithm:** + +1. If `child` already has a parent, call `RemoveParent(child)` first. +2. Get `ref childHierarchy`, `ref parentHierarchy`. +3. Set `childHierarchy.parent = parent`. +4. Set `childHierarchy.nextSibling = parentHierarchy.firstChild`. +5. Set `parentHierarchy.firstChild = child`. + +### 2.3 `RemoveParent(Entity child)` + +**Preconditions:** `child` has the `Hierarchy` component and has a parent. + +**Algorithm:** + +1. Get `ref childHierarchy`. Let `parent_entity = childHierarchy.parent`. +2. Get `ref parentHierarchy`. +3. Walk the sibling chain of `parentHierarchy.firstChild` to find and unlink `child`: + - If `firstChild == child`: set `firstChild = childHierarchy.nextSibling`. + - Else: walk `prev → current → nextSibling`, set `prev.nextSibling = childHierarchy.nextSibling`. +4. Set `childHierarchy.parent = Entity.Invalid`. +5. Set `childHierarchy.nextSibling = Entity.Invalid`. + +### 2.4 Entity Destruction Cleanup + +When an entity is destroyed via `EntityManager.DestroyEntity()`: + +1. Get its `Hierarchy` component. +2. Call `RemoveParent(entity)`. +3. **Cascade destroy all children:** Walk `firstChild` → `nextSibling` and recursively destroy every descendant entity. Destroy children before destroying the parent to ensure correct cleanup order. +4. When saving/loading, this means saving an entity implies saving its entire subtree. Deleting an entity implies deleting its entire subtree. + +### 2.5 Validation + +Each method validates invariants in `DEBUG`/`GHOST_EDITOR` builds: +- No entity appears as its own ancestor (cycle detection via ancestor walk). +- `firstChild`/`parent`/`nextSibling` references are all valid entities or `Entity.Invalid`. +- No dangling references. + +### 2.6 `HierarchySystem` as an `ISystem` + +The system does NOT run every frame automatically. Parent/child mutations are explicit (called from editor commands or loading code). The system's `Update()` is a no-op. It exists as an `ISystem` so it can: +- Register its existence in the system graph. +- Be queried via `World.SystemManager`. +- Hold references to necessary queries. + +Alternatively: make it a static utility class that takes `World` as a parameter. **Recommendation:** static utility class (`HierarchyUtility`) to avoid entity archetype cost for a no-op system. The `Hierarchy` component itself drives the tree shape. + +--- + +## Phase 3: Mirror Bridge (Editor ↔ ECS sync) + +**Goal:** Keep the `SceneGraphNode` tree in the editor synchronized with the ECS `World`. + +### 3.1 `EditorWorldService` + +**New file:** `Editor/Ghost.Editor.Core/Services/EditorWorldService.cs` + +- Registers as a singleton via `[EditorInjection(ServiceLifetime.Singleton)]`. +- Creates the editor `World` on startup. +- Holds the `World` reference. +- Disposes the world on editor shutdown. + +```csharp +[EditorInjection(ServiceLifetime.Singleton)] +public class EditorWorldService : IDisposable +{ + public World EditorWorld { get; } + + public EditorWorldService() + { + EditorWorld = World.Create(entityCapacity: 1024); + } + + public void Dispose() + { + World.Destroy(EditorWorld.ID); + } +} +``` + +### 3.2 Scene Graph Sync Strategy + +**Incremental sync via polling.** Full rebuild is avoided — existing nodes are matched by `Entity` identity and preserved across syncs. This keeps names, selection state, and expanded/collapsed state intact. + +**Algorithm (runs on timer, e.g., every 100ms):** + +1. Check `EditorWorld.Version` — if unchanged, skip. +2. Query all entities with `SceneID` component from the editor world. +3. Group by `SceneID.scene.ID`. +4. **For each scene group:** + - Find or create the `SceneNode` in `RootNodes` (match by `Scene.ID`). + - Walk the `Hierarchy` linked-list of roots. + - **For each root entity:** find existing `EntityNode` in the tree by `Entity` identity. If found, keep the node (name, expansion, selection intact). If not found, create a new `EntityNode` with default name. + - **Recurse** into `firstChild` → `nextSibling`, matching existing nodes at each level. +5. **Remove stale nodes:** Any `EntityNode` not matched in step 4 is destroyed (entity no longer exists in world). Remove these nodes (and their subtrees) from the tree. +6. **Update hierarchy links:** If a matched entity's parent changed, move the `EntityNode` to its new parent's `Children` collection. + +**Key benefits of incremental sync:** +- `EntityNode.Name` persists naturally — node instances survive. +- No dictionary, no name map. +- Selection and tree expansion state survive across syncs. +- Only affected subtrees change — minimal UI churn. + +### 3.3 Incremental Sync Implementation + +**New file:** `Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs` + +```csharp +[EditorInjection(ServiceLifetime.Singleton)] +public class SceneGraphSyncService +{ + private readonly EditorWorldService _worldService; + private uint _lastSyncedVersion; + + public ObservableCollection RootNodes { get; } = new(); + + public void Tick() + { + var currentVersion = _worldService.EditorWorld.Version; + if (currentVersion == _lastSyncedVersion) + return; + + _lastSyncedVersion = currentVersion; + SyncScenesAndEntities(_worldService.EditorWorld); + } + + private void SyncScenesAndEntities(World world) + { + // 1. Query all entities with SceneID + // 2. Group by scene ID + // 3. For each scene: match/create SceneNode, walk Hierarchy linked-list, + // match/create EntityNode, remove stale nodes, update Children links + } +} +``` + +### 3.4 Selection → Inspector Wiring + +Already partially wired: +- `InspectorService.SetSelected(IInspectable, source)` exists. +- `SceneGraphNode : IInspectable` exists. +- Clicking a `TreeViewItem` sets selection → fires event → calls `SetSelected`. + +**To complete:** +- In `Hierarchy.xaml.cs`, handle `TreeView.ItemInvoked` or selection changed. +- Call `InspectorService.SetSelected(node, this)`. + +--- + +## Phase 4: TreeView UI + +**Goal:** Replace `ListView` placeholder with a functional `TreeView` bound to the scene graph. + +### 4.1 Replace `ListView` with `TreeView` + +**File:** `Hierarchy.xaml` + +Changes: +- Replace `` with ``. +- Bind `ItemsSource` to `RootNodes` from `SceneGraphSyncService`. +- Use a `DataTemplateSelector` that picks `SceneNode.GetSceneHierarchyTemplate()` or `EntityNode.GetSceneHierarchyTemplate()` based on node type. +- Move templates to XAML resources (static `DataTemplate` in `Page.Resources` or a `ResourceDictionary`) instead of `XamlReader.Load()`. + +**Template selection:** + +```xml + + + + + +``` + +### 4.2 Move Templates to XAML Resources + +Create a `ResourceDictionary` (or put in `App.xaml` / `Hierarchy.xaml.Resources`): + +```xml + + + + + + + + + + + + + + + + + +``` + +### 4.3 DataTemplateSelector + +**New file:** `Editor/Ghost.Editor/Views/Controls/SceneGraphTemplateSelector.cs` + +```csharp +public class SceneGraphTemplateSelector : DataTemplateSelector +{ + public DataTemplate SceneNodeTemplate { get; set; } + public DataTemplate EntityNodeTemplate { get; set; } + + protected override DataTemplate SelectTemplateCore(object item) + { + return item switch + { + SceneNode => SceneNodeTemplate, + EntityNode => EntityNodeTemplate, + _ => base.SelectTemplateCore(item) + }; + } +} +``` + +### 4.4 Remove `GetSceneHierarchyTemplate()` from Node Classes + +Once templates are in XAML resources (Phase 4.2), the `GetSceneHierarchyTemplate()` method on `SceneGraphNode` is no longer needed. Remove it from the abstract class and both subclasses. Do NOT remove it in Phase 1 — the method is still referenced until the XAML templates are ready. + +### 4.5 Context Menu Support + +Add `TreeViewItem.ContextFlyout` or right-click handling for: +- **Scene-level:** Create Entity, Rename Scene, Unload Scene, Save Scene. +- **Entity-level:** Create Child Entity, Delete Entity, Duplicate Entity, Rename Entity. + +Create entity commands go through `EntityCommandBuffer` on the editor world. + +### 4.6 Search/Filter + +Wire the search `TextBox` to filter the TreeView: +- On text change, iterate `RootNodes` recursively. +- If a node or any descendant matches, show it. Otherwise collapse/hide. +- WinUI `TreeView` doesn't have built-in filtering, so implement a filtered copy of the tree or use `Visibility` toggling. + +### 4.7 Drag & Drop Reparenting (Stretch Goal) + +- Allow dragging an `EntityNode` onto another `EntityNode` to reparent. +- Uses WinUI drag-and-drop APIs. +- Calls `HierarchyUtility.SetParent()` on the editor world. +- Scene graph refreshes via sync. + +--- + +## Phase 5: Serialization (JSON Editor / Binary Runtime) + +### 5.1 File Local ID Scheme + +When serializing a scene, entities are ordered (index in the list = file-local ID). All `Entity` references within components are serialized as file-local IDs, not global entity IDs. + +**Rationale:** Global entity IDs are unpredictable across loads. File-local IDs are deterministic (they are just the list index) and remapped on load. + +### 5.2 Serialization Format + +**JSON (Editor) — in `Ghost.Editor.Core`:** + +```json +{ + "name": "MainScene", + "entities": [ + { + "components": { + "Ghost.Engine.Components.Hierarchy": { + "parent": -1, + "firstChild": 1, + "nextSibling": -1 + }, + "Ghost.Engine.Components.LocalToWorld": { + "matrix": { "m00": 1.0, ... } + } + } + }, + { + "components": { ... } + } + ] +} +``` + +- Entity index in the array = file-local ID. +- `Entity` references (`parent`, `firstChild`, `nextSibling`) stored as `int` file-local IDs. `-1` = `Entity.Invalid`. +- Component types stored by their stable `FullName` string. +- Uses `System.Text.Json` with reflection (allowed in editor). + +**Binary (Runtime) — in `Ghost.Engine`:** + +- MemoryPack serialization of the same structure. +- Must be AOT-compatible (use source-generated formatters). +- Components are blittable, so MemoryPack handles them efficiently. + +### 5.3 Save Algorithm + +1. Get all entities with `SceneID == targetScene` via `SceneManager.GetSceneEntities()`. +2. Sort entities in a deterministic order (e.g., by hierarchy depth-first traversal for deterministic output). +3. Create a `fileLocalID → Entity` map (list index → entity). +4. Create a reverse `Entity → fileLocalID` map. +5. For each entity, serialize all components. +6. For any component field of type `Entity`, replace with the file-local ID (using the reverse map). +7. For `ManagedEntityRef` / script data, serialize via MemoryPack. +8. Write to file. + +### 5.4 Load Algorithm + +1. Deserialize JSON/binary → list of entity component data. +2. Allocate all entities in the target `World` (no components yet, or minimal archetype). +3. Build `fileLocalID → Entity` map (list index → new global entity). +4. For each entity: + - Add its components to the entity. + - For any `Entity`-typed field, look up the file-local ID in the map and replace with the new global entity. +5. Call `HierarchyUtility` to validate/repair hierarchy invariants if needed. +6. Return the count of loaded entities. + +### 5.5 References to Other Scenes + +Per the architecture plan, cross-scene references are not supported. If a component references an entity from another scene: +- On save: log a warning and serialize as `-1` (invalid). +- On load: the reference will be `Entity.Invalid`. + +### 5.6 File Naming + +Scene files use the `.gscene` extension (`g` = GhostEngine): +- `Assets/Scenes/{SceneName}.gscene.json` (editor JSON) +- `Assets/Scenes/{SceneName}.gscene` (runtime binary) + +**Scene name resolution:** +- The scene's name derives from the file name (minus extension). E.g., `MyScene.gscene` → name is `"MyScene"`. +- For unsaved/new scenes: default name is `"NewScene"`. +- `SceneNode.Name` is set from the file name on load, and used as the save target on save. + +--- + +## Component Checklist + +| Phase | Task | File(s) | +|-------|------|---------| +| **1.1** | Enhance `SceneGraphNode` — add `World`, constructor | `SceneGraphNode.cs` | +| **1.2** | Enhance `SceneNode` — add `Scene` field, constructor | `SceneNode.cs` | +| **1.3** | Enhance `EntityNode` — add constructor, make usable | `EntityNode.cs` | +| **1.4** | Add `SceneGraphBuilder` | New: `SceneGraphBuilder.cs` | +| **1.5** | Entity name strategy (editor-only, no component) | See Section 1.4 | +| **2** | Add `HierarchyUtility` static class | New: `Runtime/.../HierarchyUtility.cs` | +| **3.1** | Add `EditorWorldService` | New: `Editor/.../EditorWorldService.cs` | +| **3.2-3** | Add `SceneGraphSyncService` | New: `Editor/.../SceneGraphSyncService.cs` | +| **4.1** | Replace `ListView` with `TreeView` in XAML | `Hierarchy.xaml` | +| **4.2** | Move templates to XAML resources | `Hierarchy.xaml` | +| **4.3** | Add `SceneGraphTemplateSelector` | New: `Views/Controls/SceneGraphTemplateSelector.cs` | +| **4.4** | Remove `GetSceneHierarchyTemplate()` methods | `SceneGraphNode.cs`, `.cs`, `.cs` | +| **4.5** | Context menu (create/delete entity) | `Hierarchy.xaml.cs` | +| **4.6** | Search/filter | `Hierarchy.xaml.cs` | +| **4.7** | Drag-drop reparenting (stretch) | `Hierarchy.xaml.cs` | +| **5.1-5** | Scene save/load with ID remapping | New files in `Serialization/` | +| **5.5** | MemoryPack source-gen formatters | `Ghost.Engine` | + +--- + +## Resolved Questions + +| # | Question | Decision | +|---|----------|----------| +| 1 | Entity name storage | **Editor-only** — names live on `EntityNode.Name` directly. Persist across syncs via incremental matching by `Entity` identity (no dictionary needed). No runtime component. | +| 2 | Orphan behavior on parent destroy | **Cascade destroy** all children recursively. | +| 3 | Remove `GetSceneHierarchyTemplate()` timing | **Phase 4** — keep until XAML templates are in place. | +| 4 | Scene name persistence | **File name** — name = file name minus `.gscene` extension. Unsaved scenes default to `"NewScene"`. | diff --git a/src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphBuilder.cs b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphBuilder.cs new file mode 100644 index 0000000..a162696 --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphBuilder.cs @@ -0,0 +1,156 @@ +using Ghost.Engine.Components; +using Ghost.Engine.Core; +using Ghost.Entities; + +namespace Ghost.Editor.Core.SceneGraph; + +public static class SceneGraphBuilder +{ + public static List Build(World world) + { + var sceneNodes = new List(); + var sceneEntities = GroupEntitiesByScene(world); + + foreach (var (scene, entities) in sceneEntities) + { + var sceneName = GetDefaultSceneName(scene); + var sceneNode = new SceneNode(world, scene, sceneName); + BuildEntityTree(entities, sceneNode); + sceneNodes.Add(sceneNode); + } + + return sceneNodes; + } + + private static Dictionary> GroupEntitiesByScene(World world) + { + var sceneMap = new Dictionary>(); + var queryID = new QueryBuilder().WithAll().Build(world); + ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID); + + foreach (var chunk in query.GetChunkIterator()) + { + var entities = chunk.GetEntities(); + var sceneIDs = chunk.GetComponentData(); + + for (var i = 0; i < chunk.EntityCount; i++) + { + var s = sceneIDs[i].scene; + if (!s.IsValid) + { + continue; + } + + if (!sceneMap.TryGetValue(s, out var list)) + { + list = new List(); + sceneMap[s] = list; + } + + list.Add(entities[i]); + } + } + + return sceneMap; + } + + private static void BuildEntityTree(List entities, SceneGraphNode parentNode) + { + var entitySet = new HashSet(entities); + var childrenByParent = new Dictionary>(); + var roots = new List(); + + foreach (var entity in entities) + { + Hierarchy hierarchy = default; + var hasHierarchy = TryGetHierarchyComponent(parentNode.World, entity, ref hierarchy); + + if (hasHierarchy && hierarchy.parent.IsValid && entitySet.Contains(hierarchy.parent)) + { + if (!childrenByParent.TryGetValue(hierarchy.parent, out var list)) + { + list = new List(); + childrenByParent[hierarchy.parent] = list; + } + + list.Add(entity); + } + else + { + roots.Add(entity); + } + } + + foreach (var rootEntity in roots) + { + var entityNode = new EntityNode(parentNode.World, rootEntity, "Entity"); + parentNode.Children.Add(entityNode); + BuildSubtree(entityNode, childrenByParent); + } + } + + private static void BuildSubtree(EntityNode parentNode, Dictionary> childrenByParent) + { + if (!childrenByParent.TryGetValue(parentNode.Entity, out var childList)) + { + return; + } + + Hierarchy parentHierarchy = default; + if (!TryGetHierarchyComponent(parentNode.World, parentNode.Entity, ref parentHierarchy)) + { + foreach (var childEntity in childList) + { + var childNode = new EntityNode(parentNode.World, childEntity, "Entity"); + parentNode.Children.Add(childNode); + BuildSubtree(childNode, childrenByParent); + } + + return; + } + + var sibling = parentHierarchy.firstChild; + while (sibling.IsValid) + { + if (childList.Contains(sibling)) + { + var childNode = new EntityNode(parentNode.World, sibling, "Entity"); + parentNode.Children.Add(childNode); + BuildSubtree(childNode, childrenByParent); + } + + Hierarchy siblingHierarchy = default; + if (!TryGetHierarchyComponent(parentNode.World, sibling, ref siblingHierarchy)) + { + break; + } + + sibling = siblingHierarchy.nextSibling; + } + } + + private static unsafe bool TryGetHierarchyComponent(World world, Entity entity, ref Hierarchy hierarchy) + { + var location = world.EntityManager.GetEntityLocation(entity); + if (!location.IsSuccess) + { + return false; + } + + ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.Value.archetypeID); + var hierarchyID = ComponentTypeID.Value; + var pData = archetype.GetComponentData(location.Value.chunkIndex, location.Value.rowIndex, hierarchyID); + if (pData == null) + { + return false; + } + + hierarchy = *(Hierarchy*)pData; + return true; + } + + private static string GetDefaultSceneName(Scene scene) + { + return $"NewScene ({scene.ID})"; + } +} diff --git a/src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs index cda5cca..3670a2a 100644 --- a/src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs +++ b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using Ghost.Editor.Core.Contracts; +using Ghost.Entities; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using System.Collections.ObjectModel; @@ -14,14 +15,23 @@ public abstract partial class SceneGraphNode : ObservableObject, IInspectable get; set; } + public World World + { + get; + } + public ObservableCollection Children { get; } = new(); + protected SceneGraphNode(World world, string name) + { + World = world; + Name = name; + } + public abstract IconSource? CreateIcon(); public abstract UIElement? CreateHeader(); public abstract UIElement? CreateInspector(); - - public abstract DataTemplate GetSceneHierarchyTemplate(); } diff --git a/src/Editor/Ghost.Editor.Core/SceneGraph/SceneNode.cs b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneNode.cs index 7ce7af7..d1ff50b 100644 --- a/src/Editor/Ghost.Editor.Core/SceneGraph/SceneNode.cs +++ b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneNode.cs @@ -1,3 +1,5 @@ +using Ghost.Engine.Core; +using Ghost.Entities; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -5,6 +7,17 @@ namespace Ghost.Editor.Core.SceneGraph; public sealed partial class SceneNode : SceneGraphNode { + public Scene Scene + { + get; + } + + public SceneNode(World world, Scene scene, string name) + : base(world, name) + { + Scene = scene; + } + public override IconSource? CreateIcon() { return new FontIconSource @@ -13,7 +26,6 @@ public sealed partial class SceneNode : SceneGraphNode }; } - // TODO: Implement custom header and inspector UI for the SceneNode public override UIElement? CreateHeader() { return null; @@ -23,23 +35,4 @@ public sealed partial class SceneNode : SceneGraphNode { return null; } - - public override DataTemplate GetSceneHierarchyTemplate() - { - var template = @" - - - - - - - - "; - - return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template); - } } \ No newline at end of file diff --git a/src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs b/src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs new file mode 100644 index 0000000..71f86ec --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs @@ -0,0 +1,54 @@ +using Ghost.Editor.Core.SceneGraph; +using Ghost.Entities; +using Ghost.Engine.Core; +using System.Collections.ObjectModel; + +namespace Ghost.Editor.Core.Services; + +[EditorInjection(EditorInjectionAttribute.ServiceLifetime.Singleton, typeof(EditorWorldService))] +public class EditorWorldService : IDisposable +{ + private const int DEFAULT_ENTITY_CAPACITY = 1024; + + public World EditorWorld + { + get; + } + + public ObservableCollection RootNodes + { + get; + } = new(); + + public EditorWorldService() + { + EditorWorld = World.Create(entityCapacity: DEFAULT_ENTITY_CAPACITY); + CreateDefaultScene(); + RebuildSceneGraph(); + } + + public void CreateDefaultScene() + { + var scene = SceneManager.CreateScene(); + var entity = EditorWorld.EntityManager.CreateEntity(); + EditorWorld.EntityManager.AddComponent(entity, new Ghost.Engine.Components.SceneID + { + scene = scene + }); + } + + public void RebuildSceneGraph() + { + RootNodes.Clear(); + var sceneNodes = SceneGraphBuilder.Build(EditorWorld); + foreach (var node in sceneNodes) + { + RootNodes.Add(node); + } + } + + public void Dispose() + { + World.Destroy(EditorWorld.ID); + } +} diff --git a/src/Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs b/src/Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs new file mode 100644 index 0000000..0469afb --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs @@ -0,0 +1,224 @@ +using Ghost.Editor.Core.SceneGraph; +using Ghost.Engine; +using Ghost.Engine.Components; +using Ghost.Engine.Core; +using Ghost.Entities; + +namespace Ghost.Editor.Core.Services; + +[EditorInjection(EditorInjectionAttribute.ServiceLifetime.Singleton, typeof(SceneGraphSyncService))] +public class SceneGraphSyncService +{ + private readonly EditorWorldService _worldService; + private uint _lastSyncedVersion; + + public SceneGraphSyncService(EditorWorldService worldService) + { + _worldService = worldService; + } + + public bool Tick() + { + var currentVersion = _worldService.EditorWorld.Version; + if (currentVersion == _lastSyncedVersion) + { + return false; + } + + _lastSyncedVersion = currentVersion; + SyncScenesAndEntities(_worldService.EditorWorld); + return true; + } + + private void SyncScenesAndEntities(World world) + { + var sceneEntities = GroupEntitiesByScene(world); + + foreach (var (scene, entities) in sceneEntities) + { + var sceneNode = FindOrCreateSceneNode(world, scene, _worldService.RootNodes); + SyncEntityTree(sceneNode, entities); + RemoveStaleEntityNodes(sceneNode, entities); + } + + var activeScenes = new HashSet(sceneEntities.Keys); + RemoveStaleSceneNodes(_worldService.RootNodes, activeScenes); + } + + private static SceneNode FindOrCreateSceneNode(World world, Scene scene, System.Collections.ObjectModel.ObservableCollection rootNodes) + { + foreach (var existing in rootNodes) + { + if (existing.Scene == scene) + { + return existing; + } + } + + var sceneName = $"NewScene ({scene.ID})"; + var newSceneNode = new SceneNode(world, scene, sceneName); + rootNodes.Add(newSceneNode); + return newSceneNode; + } + + private void SyncEntityTree(SceneGraphNode parentNode, List entities) + { + var entitySet = new HashSet(entities); + var children = new Dictionary>(); + var roots = new List(); + + foreach (var entity in entities) + { + Hierarchy hierarchy = default; + var hasHierarchy = TryGetHierarchyComponent(parentNode.World, entity, ref hierarchy); + + if (hasHierarchy && hierarchy.parent.IsValid && entitySet.Contains(hierarchy.parent)) + { + if (!children.TryGetValue(hierarchy.parent, out var list)) + { + list = new List(); + children[hierarchy.parent] = list; + } + + list.Add(entity); + } + else + { + roots.Add(entity); + } + } + + SyncExistingNodes(parentNode, roots, children); + } + + private static void SyncExistingNodes(SceneGraphNode parentNode, List roots, Dictionary> children) + { + var existingNodeMap = new Dictionary(); + for (var i = parentNode.Children.Count - 1; i >= 0; i--) + { + if (parentNode.Children[i] is EntityNode entityNode) + { + if (!roots.Contains(entityNode.Entity) && !children.ContainsKey(entityNode.Entity)) + { + parentNode.Children.RemoveAt(i); + } + else + { + existingNodeMap[entityNode.Entity] = entityNode; + } + } + } + + for (var i = 0; i < roots.Count; i++) + { + var entity = roots[i]; + if (existingNodeMap.TryGetValue(entity, out var existingNode)) + { + existingNodeMap.Remove(entity); + + if (i >= parentNode.Children.Count || parentNode.Children[i] != existingNode) + { + parentNode.Children.Remove(existingNode); + parentNode.Children.Insert(i, existingNode); + } + } + else + { + var newNode = new EntityNode(parentNode.World, entity, "Entity"); + parentNode.Children.Insert(i, newNode); + existingNode = newNode; + } + + if (children.TryGetValue(entity, out var childList)) + { + SyncExistingNodes(existingNode, childList, children); + } + else + { + for (var j = existingNode.Children.Count - 1; j >= 0; j--) + { + if (existingNode.Children[j] is EntityNode) + { + existingNode.Children.RemoveAt(j); + } + } + } + } + } + + private static void RemoveStaleEntityNodes(SceneGraphNode parentNode, List entities) + { + var entitySet = new HashSet(entities); + + for (var i = parentNode.Children.Count - 1; i >= 0; i--) + { + if (parentNode.Children[i] is EntityNode entityNode && !entitySet.Contains(entityNode.Entity)) + { + parentNode.Children.RemoveAt(i); + } + } + } + + private static void RemoveStaleSceneNodes(System.Collections.ObjectModel.ObservableCollection rootNodes, HashSet activeScenes) + { + for (var i = rootNodes.Count - 1; i >= 0; i--) + { + if (!activeScenes.Contains(rootNodes[i].Scene)) + { + rootNodes.RemoveAt(i); + } + } + } + + private static Dictionary> GroupEntitiesByScene(World world) + { + var sceneMap = new Dictionary>(); + var queryID = new QueryBuilder().WithAll().Build(world); + ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID); + + foreach (var chunk in query.GetChunkIterator()) + { + var entities = chunk.GetEntities(); + var sceneIDs = chunk.GetComponentData(); + + for (var i = 0; i < chunk.EntityCount; i++) + { + var s = sceneIDs[i].scene; + if (!s.IsValid) + { + continue; + } + + if (!sceneMap.TryGetValue(s, out var list)) + { + list = new List(); + sceneMap[s] = list; + } + + list.Add(entities[i]); + } + } + + return sceneMap; + } + + private static unsafe bool TryGetHierarchyComponent(World world, Entity entity, ref Hierarchy hierarchy) + { + var location = world.EntityManager.GetEntityLocation(entity); + if (!location.IsSuccess) + { + return false; + } + + ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.Value.archetypeID); + var hierarchyID = ComponentTypeID.Value; + var pData = archetype.GetComponentData(location.Value.chunkIndex, location.Value.rowIndex, hierarchyID); + if (pData == null) + { + return false; + } + + hierarchy = *(Hierarchy*)pData; + return true; + } +} diff --git a/src/Editor/Ghost.Editor/Ghost.Editor.csproj b/src/Editor/Ghost.Editor/Ghost.Editor.csproj index f345500..be2a31b 100644 --- a/src/Editor/Ghost.Editor/Ghost.Editor.csproj +++ b/src/Editor/Ghost.Editor/Ghost.Editor.csproj @@ -38,10 +38,10 @@ - + - - + + diff --git a/src/Editor/Ghost.Editor/Views/Controls/Hierarchy.xaml b/src/Editor/Ghost.Editor/Views/Controls/Hierarchy.xaml index 56ed149..7b2ce3e 100644 --- a/src/Editor/Ghost.Editor/Views/Controls/Hierarchy.xaml +++ b/src/Editor/Ghost.Editor/Views/Controls/Hierarchy.xaml @@ -5,9 +5,40 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:Ghost.Editor.Views.Controls" + xmlns:sg="using:Ghost.Editor.Core.SceneGraph" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> + + + + + + + + + + + + + + + + + + + + + + @@ -50,19 +81,17 @@ Margin="0,0,4,0" FontSize="{StaticResource ToolbarFontIconFontSize}" Glyph="" /> - + - - - - - - - - + diff --git a/src/Editor/Ghost.Editor/Views/Controls/Hierarchy.xaml.cs b/src/Editor/Ghost.Editor/Views/Controls/Hierarchy.xaml.cs index b190fb5..4ecc32a 100644 --- a/src/Editor/Ghost.Editor/Views/Controls/Hierarchy.xaml.cs +++ b/src/Editor/Ghost.Editor/Views/Controls/Hierarchy.xaml.cs @@ -1,14 +1,62 @@ +using Ghost.Editor.Core.Contracts; +using Ghost.Editor.Core.Services; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml.Controls; -// To learn more about WinUI, the WinUI project structure, -// and more about our project templates, see: http://aka.ms/winui-project-info. - namespace Ghost.Editor.Views.Controls; public sealed partial class Hierarchy : UserControl { + private readonly SceneGraphSyncService _syncService; + private readonly IInspectorService _inspectorService; + private readonly EditorWorldService _worldService; + private DispatcherQueueTimer? _syncTimer; + public Hierarchy() { InitializeComponent(); + + _syncService = App.GetService(); + _inspectorService = App.GetService(); + _worldService = App.GetService(); + + SceneTreeView.ItemsSource = _worldService.RootNodes; + + SceneTreeView.ItemInvoked += OnTreeViewItemInvoked; + SceneTreeView.SelectionChanged += OnTreeViewSelectionChanged; + + _syncTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); + _syncTimer.Interval = TimeSpan.FromMilliseconds(100); + _syncTimer.Tick += OnSyncTick; + _syncTimer.Start(); + + Unloaded += OnUnloaded; + } + + private void OnSyncTick(DispatcherQueueTimer sender, object args) + { + if (_syncService.Tick()) + { + } + } + + private void OnTreeViewItemInvoked(TreeView sender, TreeViewItemInvokedEventArgs args) + { + if (args.InvokedItem is IInspectable inspectable) + { + _inspectorService.SetSelected(inspectable, this); + } + } + + private void OnTreeViewSelectionChanged(object sender, TreeViewSelectionChangedEventArgs args) + { + } + + private void OnUnloaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + _syncTimer?.Stop(); + SceneTreeView.ItemInvoked -= OnTreeViewItemInvoked; + SceneTreeView.SelectionChanged -= OnTreeViewSelectionChanged; + Unloaded -= OnUnloaded; } } diff --git a/src/Editor/Ghost.Editor/Views/Controls/SceneGraphTemplateSelector.cs b/src/Editor/Ghost.Editor/Views/Controls/SceneGraphTemplateSelector.cs new file mode 100644 index 0000000..6950663 --- /dev/null +++ b/src/Editor/Ghost.Editor/Views/Controls/SceneGraphTemplateSelector.cs @@ -0,0 +1,35 @@ +using Ghost.Editor.Core.SceneGraph; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Ghost.Editor.Views.Controls; + +public partial class SceneGraphTemplateSelector : DataTemplateSelector +{ + public DataTemplate? SceneNodeTemplate + { + get; set; + } + + public DataTemplate? EntityNodeTemplate + { + get; set; + } + + protected override DataTemplate SelectTemplateCore(object item) + { + var result = item switch + { + SceneNode => SceneNodeTemplate, + EntityNode => EntityNodeTemplate, + _ => base.SelectTemplateCore(item) + }; + + return result!; + } + + protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) + { + return SelectTemplateCore(item); + } +} diff --git a/src/Runtime/Ghost.Core/Ghost.Core.csproj b/src/Runtime/Ghost.Core/Ghost.Core.csproj index 41ec652..ac8de7b 100644 --- a/src/Runtime/Ghost.Core/Ghost.Core.csproj +++ b/src/Runtime/Ghost.Core/Ghost.Core.csproj @@ -11,6 +11,10 @@ True + + $(DefineConstants);MHP_ENABLE_STACKTRACE + + diff --git a/src/Runtime/Ghost.Engine/HierarchyUtility.cs b/src/Runtime/Ghost.Engine/HierarchyUtility.cs new file mode 100644 index 0000000..f35d435 --- /dev/null +++ b/src/Runtime/Ghost.Engine/HierarchyUtility.cs @@ -0,0 +1,220 @@ +using Ghost.Core; +using Ghost.Entities; +using System.Runtime.CompilerServices; + +namespace Ghost.Engine; + +public static class HierarchyUtility +{ + public static Error SetParent(World world, Entity child, Entity parent) + { + if (!child.IsValid) + { + return Error.InvalidArgument; + } + + if (!parent.IsValid) + { + return Error.InvalidArgument; + } + + if (child == parent) + { + return Error.InvalidArgument; + } + + if (!world.EntityManager.HasComponent(child)) + { + return Error.NotFound; + } + + if (!world.EntityManager.HasComponent(parent)) + { + return Error.NotFound; + } + + if (IsAncestor(world, parent, child)) + { + return Error.InvalidArgument; + } + + ref var childHierarchy = ref world.EntityManager.GetComponent(child); + if (Unsafe.IsNullRef(ref childHierarchy)) + { + return Error.NotFound; + } + + if (childHierarchy.parent.IsValid) + { + RemoveParent(world, child); + } + + ref var parentHierarchy = ref world.EntityManager.GetComponent(parent); + if (Unsafe.IsNullRef(ref parentHierarchy)) + { + return Error.NotFound; + } + + childHierarchy.parent = parent; + childHierarchy.nextSibling = parentHierarchy.firstChild; + parentHierarchy.firstChild = child; + + return Error.None; + } + + public static Error RemoveParent(World world, Entity child) + { + if (!child.IsValid) + { + return Error.InvalidArgument; + } + + if (!world.EntityManager.HasComponent(child)) + { + return Error.NotFound; + } + + ref var childHierarchy = ref world.EntityManager.GetComponent(child); + if (Unsafe.IsNullRef(ref childHierarchy)) + { + return Error.NotFound; + } + + var parent = childHierarchy.parent; + if (!parent.IsValid) + { + return Error.None; + } + + ref var parentHierarchy = ref world.EntityManager.GetComponent(parent); + if (Unsafe.IsNullRef(ref parentHierarchy)) + { + childHierarchy.parent = Entity.Invalid; + childHierarchy.nextSibling = Entity.Invalid; + return Error.None; + } + + var prev = Entity.Invalid; + var current = parentHierarchy.firstChild; + + while (current.IsValid) + { + ref var currentHierarchy = ref world.EntityManager.GetComponent(current); + if (Unsafe.IsNullRef(ref currentHierarchy)) + { + break; + } + + if (current == child) + { + if (prev.IsValid) + { + ref var prevHierarchy = ref world.EntityManager.GetComponent(prev); + prevHierarchy.nextSibling = childHierarchy.nextSibling; + } + else + { + parentHierarchy.firstChild = childHierarchy.nextSibling; + } + + break; + } + + prev = current; + current = currentHierarchy.nextSibling; + } + + childHierarchy.parent = Entity.Invalid; + childHierarchy.nextSibling = Entity.Invalid; + + return Error.None; + } + + public static void DestroyEntityWithChildren(World world, Entity entity) + { + if (!entity.IsValid) + { + return; + } + + if (world.EntityManager.HasComponent(entity)) + { + ref var hierarchy = ref world.EntityManager.GetComponent(entity); + if (!Unsafe.IsNullRef(ref hierarchy)) + { + var child = hierarchy.firstChild; + while (child.IsValid) + { + ref var childHierarchy = ref world.EntityManager.GetComponent(child); + var next = childHierarchy.nextSibling; + DestroyEntityWithChildren(world, child); + child = next; + } + + RemoveParent(world, entity); + } + } + + world.EntityManager.DestroyEntity(entity); + } + + public static bool IsAncestor(World world, Entity entity, Entity potentialAncestor) + { + if (!entity.IsValid || !potentialAncestor.IsValid) + { + return false; + } + + if (!world.EntityManager.HasComponent(entity)) + { + return false; + } + + ref var hierarchy = ref world.EntityManager.GetComponent(entity); + if (Unsafe.IsNullRef(ref hierarchy)) + { + return false; + } + + var current = hierarchy.parent; + while (current.IsValid) + { + if (current == potentialAncestor) + { + return true; + } + + if (!world.EntityManager.HasComponent(current)) + { + break; + } + + ref var currentHierarchy = ref world.EntityManager.GetComponent(current); + if (Unsafe.IsNullRef(ref currentHierarchy)) + { + break; + } + + current = currentHierarchy.parent; + } + + return false; + } + + public static Error RemoveEntity(World world, Entity entity) + { + if (!entity.IsValid) + { + return Error.InvalidArgument; + } + + if (world.EntityManager.HasComponent(entity)) + { + RemoveParent(world, entity); + } + + world.EntityManager.DestroyEntity(entity); + + return Error.None; + } +} diff --git a/src/Runtime/Ghost.Entities/Archetype.cs b/src/Runtime/Ghost.Entities/Archetype.cs index b5511a0..ccd9c67 100644 --- a/src/Runtime/Ghost.Entities/Archetype.cs +++ b/src/Runtime/Ghost.Entities/Archetype.cs @@ -125,11 +125,15 @@ internal unsafe struct Chunk : IDisposable public Chunk(int bufferSize, int capacity, int componentCount, uint globalVersion) { _data = new UnsafeArray(bufferSize, AllocationHandle.Persistent, AllocationOption.Clear); - _versions = new UnsafeArray(componentCount, AllocationHandle.Persistent); _capacity = capacity; _count = 0; - _versions.AsSpan().Fill(globalVersion); + if (componentCount > 0) + { + _versions = new UnsafeArray(componentCount, AllocationHandle.Persistent); + _versions.AsSpan().Fill(globalVersion); + } + _structuralVersion = globalVersion; } diff --git a/src/Runtime/Ghost.Entities/System.cs b/src/Runtime/Ghost.Entities/System.cs index c46740c..c592d25 100644 --- a/src/Runtime/Ghost.Entities/System.cs +++ b/src/Runtime/Ghost.Entities/System.cs @@ -332,6 +332,11 @@ public abstract class SystemGroup : ISystem public void Initialize(ref readonly SystemAPI systemAPI) { + if (_systems.Count == 0) + { + return; + } + ThrowIfNotSorted(); foreach (var system in _sortedSystems!) @@ -342,6 +347,11 @@ public abstract class SystemGroup : ISystem public void Update(ref readonly SystemAPI systemAPI) { + if (_systems.Count == 0) + { + return; + } + ThrowIfNotSorted(); foreach (var system in _sortedSystems!) @@ -352,6 +362,11 @@ public abstract class SystemGroup : ISystem public void Cleanup(ref readonly SystemAPI systemAPI) { + if (_systems.Count == 0) + { + return; + } + ThrowIfNotSorted(); foreach (var system in _sortedSystems!) @@ -399,6 +414,11 @@ public sealed class SystemManager : IDisposable internal void InitializeAll(TimeData timeData) { + if (_systems.Count == 0) + { + return; + } + var systemAPI = new SystemAPI { Time = timeData, @@ -413,6 +433,11 @@ public sealed class SystemManager : IDisposable internal void UpdateAll(TimeData timeData) { + if (_systems.Count == 0) + { + return; + } + var systemAPI = new SystemAPI { Time = timeData, @@ -427,6 +452,11 @@ public sealed class SystemManager : IDisposable internal void CleanupAll(TimeData timeData) { + if (_systems.Count == 0) + { + return; + } + var systemAPI = new SystemAPI { Time = timeData, diff --git a/src/Test/Ghost.UnitTest/Ghost.UnitTest.csproj b/src/Test/Ghost.UnitTest/Ghost.UnitTest.csproj index decb949..a5314dd 100644 --- a/src/Test/Ghost.UnitTest/Ghost.UnitTest.csproj +++ b/src/Test/Ghost.UnitTest/Ghost.UnitTest.csproj @@ -12,8 +12,7 @@ - - + diff --git a/src/Test/Ghost.UnitTest/HierarchyUtilityTests.cs b/src/Test/Ghost.UnitTest/HierarchyUtilityTests.cs new file mode 100644 index 0000000..e1de3ff --- /dev/null +++ b/src/Test/Ghost.UnitTest/HierarchyUtilityTests.cs @@ -0,0 +1,286 @@ +using Ghost.Core; +using Ghost.Engine; +using Ghost.Engine.Components; +using Ghost.Entities; +using Misaki.HighPerformance.LowLevel.Buffer; + +namespace Ghost.UnitTest; + +[TestClass] +[DoNotParallelize] +public class HierarchyUtilityTests +{ + private World _world = null!; + + [TestInitialize] + public void Setup() + { + AllocationManager.Initialize(); + _world = World.Create(entityCapacity: 64); + } + + [TestCleanup] + public void Cleanup() + { + World.Destroy(_world.ID); + AllocationManager.Dispose(); + } + + private Entity CreateHierarchyEntity() + { + var entity = _world.EntityManager.CreateEntity(); + _world.EntityManager.AddComponent(entity, new Hierarchy + { + parent = Entity.Invalid, + firstChild = Entity.Invalid, + nextSibling = Entity.Invalid + }); + return entity; + } + + [TestMethod] + public void SetParent_ChildBecomesChildOfParent() + { + var parent = CreateHierarchyEntity(); + var child = CreateHierarchyEntity(); + + var result = HierarchyUtility.SetParent(_world, child, parent); + + Assert.AreEqual(Error.None, result); + + ref var childHierarchy = ref _world.EntityManager.GetComponent(child); + Assert.AreEqual(parent, childHierarchy.parent); + + ref var parentHierarchy = ref _world.EntityManager.GetComponent(parent); + Assert.AreEqual(child, parentHierarchy.firstChild); + } + + [TestMethod] + public void SetParent_SecondChildBecomesSibling() + { + var parent = CreateHierarchyEntity(); + var child1 = CreateHierarchyEntity(); + var child2 = CreateHierarchyEntity(); + + HierarchyUtility.SetParent(_world, child1, parent); + HierarchyUtility.SetParent(_world, child2, parent); + + ref var parentHierarchy = ref _world.EntityManager.GetComponent(parent); + Assert.AreEqual(child2, parentHierarchy.firstChild); + + ref var child2Hierarchy = ref _world.EntityManager.GetComponent(child2); + Assert.AreEqual(child1, child2Hierarchy.nextSibling); + + ref var child1Hierarchy = ref _world.EntityManager.GetComponent(child1); + Assert.AreEqual(Entity.Invalid, child1Hierarchy.nextSibling); + } + + [TestMethod] + public void SetParent_ReparentFromOneParentToAnother() + { + var parent1 = CreateHierarchyEntity(); + var parent2 = CreateHierarchyEntity(); + var child = CreateHierarchyEntity(); + + HierarchyUtility.SetParent(_world, child, parent1); + HierarchyUtility.SetParent(_world, child, parent2); + + ref var childHierarchy = ref _world.EntityManager.GetComponent(child); + Assert.AreEqual(parent2, childHierarchy.parent); + + ref var parent1Hierarchy = ref _world.EntityManager.GetComponent(parent1); + Assert.AreEqual(Entity.Invalid, parent1Hierarchy.firstChild); + + ref var parent2Hierarchy = ref _world.EntityManager.GetComponent(parent2); + Assert.AreEqual(child, parent2Hierarchy.firstChild); + } + + [TestMethod] + public void SetParent_SelfParentingIsRejected() + { + var entity = CreateHierarchyEntity(); + + var result = HierarchyUtility.SetParent(_world, entity, entity); + + Assert.AreEqual(Error.InvalidArgument, result); + } + + [TestMethod] + public void SetParent_CycleIsRejected() + { + var parent = CreateHierarchyEntity(); + var child = CreateHierarchyEntity(); + + HierarchyUtility.SetParent(_world, child, parent); + + var result = HierarchyUtility.SetParent(_world, parent, child); + + Assert.AreEqual(Error.InvalidArgument, result); + Assert.AreEqual(parent, _world.EntityManager.GetComponent(child).parent); + } + + [TestMethod] + public void SetParent_GrandchildCycleIsRejected() + { + var grandparent = CreateHierarchyEntity(); + var parent = CreateHierarchyEntity(); + var child = CreateHierarchyEntity(); + + HierarchyUtility.SetParent(_world, parent, grandparent); + HierarchyUtility.SetParent(_world, child, parent); + + var result = HierarchyUtility.SetParent(_world, grandparent, child); + + Assert.AreEqual(Error.InvalidArgument, result); + } + + [TestMethod] + public void SetParent_EntityWithoutHierarchyComponentReturnsNotFound() + { + var parent = CreateHierarchyEntity(); + var child = _world.EntityManager.CreateEntity(); + + var result = HierarchyUtility.SetParent(_world, child, parent); + + Assert.AreEqual(Error.NotFound, result); + } + + [TestMethod] + public void RemoveParent_UnlinksChildFromParent() + { + var parent = CreateHierarchyEntity(); + var child = CreateHierarchyEntity(); + + HierarchyUtility.SetParent(_world, child, parent); + var result = HierarchyUtility.RemoveParent(_world, child); + + Assert.AreEqual(Error.None, result); + + ref var childHierarchy = ref _world.EntityManager.GetComponent(child); + Assert.AreEqual(Entity.Invalid, childHierarchy.parent); + Assert.AreEqual(Entity.Invalid, childHierarchy.nextSibling); + + ref var parentHierarchy = ref _world.EntityManager.GetComponent(parent); + Assert.AreEqual(Entity.Invalid, parentHierarchy.firstChild); + } + + [TestMethod] + public void RemoveParent_MiddleChildMaintainsSiblingChain() + { + var parent = CreateHierarchyEntity(); + var child1 = CreateHierarchyEntity(); + var child2 = CreateHierarchyEntity(); + var child3 = CreateHierarchyEntity(); + + HierarchyUtility.SetParent(_world, child1, parent); + HierarchyUtility.SetParent(_world, child2, parent); + HierarchyUtility.SetParent(_world, child3, parent); + + HierarchyUtility.RemoveParent(_world, child2); + + ref var child3Hierarchy = ref _world.EntityManager.GetComponent(child3); + Assert.AreEqual(child1, child3Hierarchy.nextSibling); + + ref var child2Hierarchy = ref _world.EntityManager.GetComponent(child2); + Assert.AreEqual(Entity.Invalid, child2Hierarchy.parent); + Assert.AreEqual(Entity.Invalid, child2Hierarchy.nextSibling); + } + + [TestMethod] + public void RemoveParent_WhenNoParentReturnsNone() + { + var entity = CreateHierarchyEntity(); + + var result = HierarchyUtility.RemoveParent(_world, entity); + + Assert.AreEqual(Error.None, result); + } + + [TestMethod] + public void IsAncestor_ReturnsTrueForDirectParent() + { + var parent = CreateHierarchyEntity(); + var child = CreateHierarchyEntity(); + + HierarchyUtility.SetParent(_world, child, parent); + + Assert.IsTrue(HierarchyUtility.IsAncestor(_world, child, parent)); + } + + [TestMethod] + public void IsAncestor_ReturnsTrueForGrandparent() + { + var grandparent = CreateHierarchyEntity(); + var parent = CreateHierarchyEntity(); + var child = CreateHierarchyEntity(); + + HierarchyUtility.SetParent(_world, parent, grandparent); + HierarchyUtility.SetParent(_world, child, parent); + + Assert.IsTrue(HierarchyUtility.IsAncestor(_world, child, grandparent)); + } + + [TestMethod] + public void IsAncestor_ReturnsFalseForUnrelatedEntity() + { + var entity1 = CreateHierarchyEntity(); + var entity2 = CreateHierarchyEntity(); + + Assert.IsFalse(HierarchyUtility.IsAncestor(_world, entity1, entity2)); + } + + [TestMethod] + public void IsAncestor_ReturnsFalseForChild() + { + var parent = CreateHierarchyEntity(); + var child = CreateHierarchyEntity(); + + HierarchyUtility.SetParent(_world, child, parent); + + Assert.IsFalse(HierarchyUtility.IsAncestor(_world, parent, child)); + } + + [TestMethod] + public void DestroyEntityWithChildren_CascadeDestroysAllChildren() + { + var parent = CreateHierarchyEntity(); + var child1 = CreateHierarchyEntity(); + var child2 = CreateHierarchyEntity(); + var grandchild = CreateHierarchyEntity(); + + HierarchyUtility.SetParent(_world, child1, parent); + HierarchyUtility.SetParent(_world, child2, parent); + HierarchyUtility.SetParent(_world, grandchild, child1); + + HierarchyUtility.DestroyEntityWithChildren(_world, parent); + + Assert.IsFalse(_world.EntityManager.Exists(parent)); + Assert.IsFalse(_world.EntityManager.Exists(child1)); + Assert.IsFalse(_world.EntityManager.Exists(child2)); + Assert.IsFalse(_world.EntityManager.Exists(grandchild)); + } + + [TestMethod] + public void DestroyEntityWithChildren_DoesNotAffectUnrelatedEntities() + { + var parent = CreateHierarchyEntity(); + var child = CreateHierarchyEntity(); + var unrelated = CreateHierarchyEntity(); + + HierarchyUtility.SetParent(_world, child, parent); + HierarchyUtility.DestroyEntityWithChildren(_world, parent); + + Assert.IsTrue(_world.EntityManager.Exists(unrelated)); + } + + [TestMethod] + public void RemoveEntity_DestroysSingleEntity() + { + var entity = CreateHierarchyEntity(); + + var result = HierarchyUtility.RemoveEntity(_world, entity); + + Assert.AreEqual(Error.None, result); + Assert.IsFalse(_world.EntityManager.Exists(entity)); + } +} diff --git a/src/Test/Ghost.UnitTest/SceneGraphBuilderTests.cs b/src/Test/Ghost.UnitTest/SceneGraphBuilderTests.cs new file mode 100644 index 0000000..54c7865 --- /dev/null +++ b/src/Test/Ghost.UnitTest/SceneGraphBuilderTests.cs @@ -0,0 +1,174 @@ +using Ghost.Editor.Core.SceneGraph; +using Ghost.Engine; +using Ghost.Engine.Components; +using Ghost.Engine.Core; +using Ghost.Entities; +using Misaki.HighPerformance.LowLevel.Buffer; + +namespace Ghost.UnitTest; + +[TestClass] +[DoNotParallelize] +public class SceneGraphBuilderTests +{ + private World _world = null!; + + [TestInitialize] + public void Setup() + { + AllocationManager.Initialize(); + _world = World.Create(entityCapacity: 64); + } + + [TestCleanup] + public void Cleanup() + { + World.Destroy(_world.ID); + AllocationManager.Dispose(); + } + + private Entity CreateEntityWithScene(Scene scene) + { + var entity = _world.EntityManager.CreateEntity(); + _world.EntityManager.AddComponent(entity, new SceneID { scene = scene }); + return entity; + } + + private Entity CreateEntityWithSceneAndHierarchy(Scene scene, Entity parent) + { + var entity = _world.EntityManager.CreateEntity(); + _world.EntityManager.AddComponent(entity, new SceneID { scene = scene }); + _world.EntityManager.AddComponent(entity, new Hierarchy + { + parent = Entity.Invalid, + firstChild = Entity.Invalid, + nextSibling = Entity.Invalid + }); + + if (parent.IsValid) + { + HierarchyUtility.SetParent(_world, entity, parent); + } + + return entity; + } + + [TestMethod] + public void Build_EmptyWorldReturnsEmptyList() + { + var nodes = SceneGraphBuilder.Build(_world); + + Assert.AreEqual(0, nodes.Count); + } + + [TestMethod] + public void Build_OneSceneOneEntity_CreatesSceneNodeWithEntityChild() + { + var scene = SceneManager.CreateScene(); + CreateEntityWithScene(scene); + + var nodes = SceneGraphBuilder.Build(_world); + + Assert.AreEqual(1, nodes.Count); + + var sceneNode = nodes[0]; + Assert.AreEqual(scene, sceneNode.Scene); + Assert.AreEqual(1, sceneNode.Children.Count); + Assert.IsInstanceOfType(sceneNode.Children[0]); + } + + [TestMethod] + public void Build_MultipleScenes_CreatesMultipleSceneNodes() + { + var scene1 = SceneManager.CreateScene(); + var scene2 = SceneManager.CreateScene(); + CreateEntityWithScene(scene1); + CreateEntityWithScene(scene2); + + var nodes = SceneGraphBuilder.Build(_world); + + Assert.AreEqual(2, nodes.Count); + } + + [TestMethod] + public void Build_HierarchicalEntities_CreatesNestedEntityNodes() + { + var scene = SceneManager.CreateScene(); + var rootEntity = CreateEntityWithSceneAndHierarchy(scene, Entity.Invalid); + var childEntity = CreateEntityWithSceneAndHierarchy(scene, rootEntity); + + var nodes = SceneGraphBuilder.Build(_world); + + Assert.AreEqual(1, nodes.Count); + var sceneNode = nodes[0]; + Assert.AreEqual(1, sceneNode.Children.Count); + + var rootNode = (EntityNode)sceneNode.Children[0]; + Assert.AreEqual(rootEntity, rootNode.Entity); + Assert.AreEqual(1, rootNode.Children.Count); + + var childNode = (EntityNode)rootNode.Children[0]; + Assert.AreEqual(childEntity, childNode.Entity); + } + + [TestMethod] + public void Build_EntitiesWithoutHierarchy_AreFlatChildren() + { + var scene = SceneManager.CreateScene(); + CreateEntityWithScene(scene); + CreateEntityWithScene(scene); + + var nodes = SceneGraphBuilder.Build(_world); + + Assert.AreEqual(1, nodes.Count); + var sceneNode = nodes[0]; + Assert.AreEqual(2, sceneNode.Children.Count); + } + + [TestMethod] + public void Build_SiblingOrder_PreservesChildOrder() + { + var scene = SceneManager.CreateScene(); + var parent = CreateEntityWithSceneAndHierarchy(scene, Entity.Invalid); + var child1 = CreateEntityWithSceneAndHierarchy(scene, parent); + var child2 = CreateEntityWithSceneAndHierarchy(scene, parent); + + var nodes = SceneGraphBuilder.Build(_world); + + var rootNode = (EntityNode)nodes[0].Children[0]; + Assert.AreEqual(2, rootNode.Children.Count); + Assert.AreEqual(child2, ((EntityNode)rootNode.Children[0]).Entity); + Assert.AreEqual(child1, ((EntityNode)rootNode.Children[1]).Entity); + } + + [TestMethod] + public void Build_DeepHierarchy_BuildsFullTree() + { + var scene = SceneManager.CreateScene(); + var level1 = CreateEntityWithSceneAndHierarchy(scene, Entity.Invalid); + var level2 = CreateEntityWithSceneAndHierarchy(scene, level1); + var level3 = CreateEntityWithSceneAndHierarchy(scene, level2); + + var nodes = SceneGraphBuilder.Build(_world); + + var n1 = (EntityNode)nodes[0].Children[0]; + Assert.AreEqual(1, n1.Children.Count); + + var n2 = (EntityNode)n1.Children[0]; + Assert.AreEqual(1, n2.Children.Count); + + var n3 = (EntityNode)n2.Children[0]; + Assert.AreEqual(level3, n3.Entity); + } + + [TestMethod] + public void Build_InvalidSceneEntitiesAreExcluded() + { + var entity = _world.EntityManager.CreateEntity(); + _world.EntityManager.AddComponent(entity, new SceneID { scene = Scene.Invalid }); + + var nodes = SceneGraphBuilder.Build(_world); + + Assert.AreEqual(0, nodes.Count); + } +}