From f040f71045ccf9e752e038d24f168f97b25e46b9 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 2 Sep 2026 00:33:18 +0300 Subject: [PATCH] Implement World: GameObject hierarchy, components, type-indexed queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real piece of M0 rather than scaffolding. GameWorld : IWorld owns GameObject creation/destruction, a type-indexed Dictionary> backing Query(), and hierarchy bookkeeping (roots list, parent/children, cycle rejection on SetParent). Two corrections to the design doc found while implementing: - WorldMatrix can't be cached on Transform as described — Transform is a plain struct with no reference to the hierarchy it would need to compose against. Moved to GameObject.WorldMatrix, computed from the parent chain on read; docs/kernel-contract.md and the §3 example updated (go.Transform.WorldMatrix -> go.WorldMatrix). - The concrete World class collided with its own containing namespace (Engine.Kernel.World.World), which makes the bare name ambiguous for every consumer. Renamed to GameWorld; IWorld and the World folder/ namespace are unaffected, and the doc never named the concrete class either way, so nothing there needed to change. Also fixed a real correctness bug caught while writing World.Destroy: a GameObject can carry more than one component of the same type, so removing one from the type index has to check whether any others of that type remain before dropping the GameObject from the index set — tested directly (Query_Still_Finds_A_GameObject_With_A_Duplicate_ Component_After_One_Removal). 12 tests in Engine.Kernel.Tests cover creation, hierarchy (including cycle rejection), component add/remove/query, destroy cascading through a subtree, and WorldMatrix composition (verified against a worked-through parent+child translation, not just asserted). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N --- docs/kernel-contract.md | 16 ++- src/Engine.Kernel/World/GameObject.cs | 106 ++++++++++++-- src/Engine.Kernel/World/GameWorld.cs | 118 ++++++++++++++++ src/Engine.Kernel/World/IWorld.cs | 3 + src/Engine.Kernel/World/Transform.cs | 17 ++- tests/Engine.Kernel.Tests/WorldTests.cs | 175 +++++++++++++++++++++++- 6 files changed, 412 insertions(+), 23 deletions(-) create mode 100644 src/Engine.Kernel/World/GameWorld.cs diff --git a/docs/kernel-contract.md b/docs/kernel-contract.md index 73039d9..8f8c85f 100644 --- a/docs/kernel-contract.md +++ b/docs/kernel-contract.md @@ -56,12 +56,16 @@ nothing. | 06 | **Time & Log** | Frame clock, fixed-step accumulator, logging interface. Kept minimal. | `GameObject.Transform` is the one field embedded directly rather than -modeled as a `Component` subclass — it's a plain struct (position, rotation, -scale, cached world matrix), because nearly every system in the engine +modeled as a `Component` subclass — it's a plain struct holding local +position, rotation, and scale, because nearly every system in the engine touches it every frame, and routing that through the same virtual-dispatch -path as every other component would tax the one thing everything depends on. -Everything else — `MeshRenderer`, `Rigidbody`, `AudioSource`, game-specific -components — is a plain class, heap-allocated, no special treatment. +path as every other component would tax the one thing everything depends +on. `GameObject.WorldMatrix` composes it with the parent chain on every +read rather than caching a value — a cache here would need invalidating on +every reparent and every ancestor's change, which is more bookkeeping than +a handful of matrix multiplies costs at indie scale. Everything else — +`MeshRenderer`, `Rigidbody`, `AudioSource`, game-specific components — is a +plain class, heap-allocated, no special treatment. ### Plugins — everything else, no exceptions @@ -219,7 +223,7 @@ public sealed class RenderPlugin : IPlugin { // type-indexed lookup, not a scan — see the World row in §2 foreach (var go in world.Query()) - f.Draw(go.GetComponent().Handle, go.Transform.WorldMatrix); + f.Draw(go.GetComponent().Handle, go.WorldMatrix); } } ``` diff --git a/src/Engine.Kernel/World/GameObject.cs b/src/Engine.Kernel/World/GameObject.cs index a19ac8e..818bd2f 100644 --- a/src/Engine.Kernel/World/GameObject.cs +++ b/src/Engine.Kernel/World/GameObject.cs @@ -1,28 +1,118 @@ +using System.Numerics; + namespace Engine.Kernel.World; /// /// The entity type: identity, hierarchy, and a list of components. See /// docs/kernel-contract.md §2. +/// +/// Construction is internal — the only way to get one is +/// , so can keep +/// its type index consistent instead of trusting callers to report changes. /// public sealed class GameObject { - public required string Name { get; set; } + private readonly List _components = []; + private readonly List _children = []; + + internal GameObject(string name) + { + Name = name; + } + + public string Name { get; set; } public Transform Transform; - public GameObject? Parent { get; internal set; } + public GameObject? Parent { get; private set; } - // TODO(M0): backing storage for children, tags, and the component list - // + type index that makes World.Query() O(matches), not O(all). + public IReadOnlyList Children => _children; - public IReadOnlyList Children => throw new NotImplementedException(); + public IReadOnlyList Components => _components; + + /// + /// Set by at creation and cleared on destroy. + /// Null means "not attached to a World" — a defensive state that + /// should never be observable from a plugin. + /// + internal GameWorld? Owner { get; set; } + + /// + /// Composed from the parent chain on every read, not cached — a cache + /// here would need invalidating on every reparent and every ancestor's + /// transform change, which is more bookkeeping than recomputing a + /// handful of matrix multiplies costs at indie scale. + /// + public Matrix4x4 WorldMatrix => + Parent is null ? Transform.LocalMatrix : Transform.LocalMatrix * Parent.WorldMatrix; + + /// + /// Reparents this GameObject. Throws if that would create a cycle — + /// checked by walking 's own ancestors for + /// this object, which is cheap next to the cost of silently corrupting + /// the hierarchy. + /// + public void SetParent(GameObject? parent) + { + if (ReferenceEquals(parent, this)) + throw new InvalidOperationException($"GameObject '{Name}' cannot be its own parent."); + + for (var ancestor = parent?.Parent; ancestor is not null; ancestor = ancestor.Parent) + { + if (ReferenceEquals(ancestor, this)) + throw new InvalidOperationException( + $"Setting '{parent!.Name}' as the parent of '{Name}' would create a cycle."); + } + + if (ReferenceEquals(Parent, parent)) + return; + + var oldParent = Parent; + oldParent?._children.Remove(this); + Parent = parent; + parent?._children.Add(this); + + Owner?.OnReparented(this, oldParent, parent); + } public T? GetComponent() where T : Component - => throw new NotImplementedException(); + { + foreach (var component in _components) + { + if (component is T match) + return match; + } + + return null; + } public T AddComponent() where T : Component, new() - => throw new NotImplementedException(); + { + var component = new T(); + _components.Add(component); + Owner?.IndexComponentAdded(this, component); + return component; + } public void RemoveComponent() where T : Component - => throw new NotImplementedException(); + { + for (var i = 0; i < _components.Count; i++) + { + if (_components[i] is not T match) + continue; + + _components.RemoveAt(i); + Owner?.IndexComponentRemoved(this, match); + return; + } + } + + /// Used only by GameWorld.Destroy, which handles index and + /// roots bookkeeping itself — see the note there on why this bypasses + /// SetParent's cycle check and reparent notification. + internal void DetachFromParent() + { + Parent?._children.Remove(this); + Parent = null; + } } diff --git a/src/Engine.Kernel/World/GameWorld.cs b/src/Engine.Kernel/World/GameWorld.cs new file mode 100644 index 0000000..e6635ae --- /dev/null +++ b/src/Engine.Kernel/World/GameWorld.cs @@ -0,0 +1,118 @@ +namespace Engine.Kernel.World; + +/// +/// Kernel-owned storage for every GameObject in a scene. See +/// docs/kernel-contract.md §2. +/// +/// Named GameWorld rather than World — a class with the same +/// simple name as its own containing namespace (Engine.Kernel.World) +/// makes the bare name ambiguous for every consumer, since C# resolves the +/// enclosing namespace before an imported type. +/// +public sealed class GameWorld : IWorld +{ + private readonly List _roots = []; + private readonly Dictionary> _index = []; + + /// Top-level GameObjects — everything with no parent. Walking + /// a scene starts here. + public IReadOnlyList Roots => _roots; + + public GameObject CreateGameObject(string name) + { + var go = new GameObject(name) + { + Owner = this, + Transform = Transform.Identity, + }; + + _roots.Add(go); + return go; + } + + public void Destroy(GameObject go) + { + // Children first: a GameObject can't outlive the world that + // indexes it. Snapshot to an array — Destroy(child) mutates + // go.Children out from under a live enumeration otherwise. + foreach (var child in go.Children.ToArray()) + Destroy(child); + + RemoveFromAllIndices(go); + + if (go.Parent is null) + _roots.Remove(go); + else + go.DetachFromParent(); + + go.Owner = null; + } + + /// Type-indexed lookup — O(matches), not O(all). See §2. + /// + /// Iterating this while structurally mutating the world (adding or + /// removing a GameObject or component) throws, by design: correctness + /// over silently returning a stale or partial result. A Scheduler is + /// expected to queue structural changes to a stage boundary rather than + /// let a running system trigger this — see the Scheduler row in §2. + /// + public IEnumerable Query() where T : Component => + _index.TryGetValue(typeof(T), out var set) ? set : []; + + internal void IndexComponentAdded(GameObject go, Component component) + { + var type = component.GetType(); + + if (!_index.TryGetValue(type, out var set)) + { + set = []; + _index[type] = set; + } + + set.Add(go); + } + + internal void IndexComponentRemoved(GameObject go, Component removed) + { + var type = removed.GetType(); + + if (!_index.TryGetValue(type, out var set)) + return; + + // A GameObject can carry more than one component of the same type + // (AddComponent() doesn't enforce uniqueness). Only drop it from + // the index once none are left — checked against go.Components, + // which by this point no longer includes the one just removed. + var stillHasOne = false; + foreach (var c in go.Components) + { + if (c.GetType() != type) + continue; + stillHasOne = true; + break; + } + + if (!stillHasOne) + set.Remove(go); + } + + internal void OnReparented(GameObject go, GameObject? oldParent, GameObject? newParent) + { + if (oldParent is null && newParent is not null) + _roots.Remove(go); + else if (oldParent is not null && newParent is null) + _roots.Add(go); + } + + /// Unconditional removal from every type bucket, used by + /// Destroy — cheaper to reason about than replaying per-component + /// removals through IndexComponentRemoved's "still has one left?" + /// check, which assumes the component list it inspects still reflects + /// reality. O(distinct component types ever indexed); revisit only if + /// that count grows large enough to matter. + private void RemoveFromAllIndices(GameObject go) + { + foreach (var set in _index.Values) + set.Remove(go); + } +} diff --git a/src/Engine.Kernel/World/IWorld.cs b/src/Engine.Kernel/World/IWorld.cs index 08b17c5..ce82282 100644 --- a/src/Engine.Kernel/World/IWorld.cs +++ b/src/Engine.Kernel/World/IWorld.cs @@ -7,6 +7,9 @@ namespace Engine.Kernel.World; /// public interface IWorld { + /// Top-level GameObjects — everything with no parent. + IReadOnlyList Roots { get; } + GameObject CreateGameObject(string name); void Destroy(GameObject go); diff --git a/src/Engine.Kernel/World/Transform.cs b/src/Engine.Kernel/World/Transform.cs index af8af1b..90e87bb 100644 --- a/src/Engine.Kernel/World/Transform.cs +++ b/src/Engine.Kernel/World/Transform.cs @@ -6,6 +6,10 @@ namespace Engine.Kernel.World; /// Embedded directly on rather than modeled as a /// subclass, because nearly every system touches it /// every frame — see the kernel scope table in docs/kernel-contract.md §2. +/// +/// Holds local position/rotation/scale only. World-space composition lives +/// on , not here — see that property +/// for why it isn't cached on this struct instead. /// public struct Transform { @@ -13,6 +17,15 @@ public struct Transform public Quaternion LocalRotation; public Vector3 LocalScale; - // TODO(M0): derive from the GameObject hierarchy once World can walk it. - public readonly Matrix4x4 WorldMatrix => throw new NotImplementedException(); + public static Transform Identity => new() + { + LocalPosition = Vector3.Zero, + LocalRotation = Quaternion.Identity, + LocalScale = Vector3.One, + }; + + public readonly Matrix4x4 LocalMatrix => + Matrix4x4.CreateScale(LocalScale) * + Matrix4x4.CreateFromQuaternion(LocalRotation) * + Matrix4x4.CreateTranslation(LocalPosition); } diff --git a/tests/Engine.Kernel.Tests/WorldTests.cs b/tests/Engine.Kernel.Tests/WorldTests.cs index f452e4f..2a7c898 100644 --- a/tests/Engine.Kernel.Tests/WorldTests.cs +++ b/tests/Engine.Kernel.Tests/WorldTests.cs @@ -1,14 +1,175 @@ +using System.Numerics; +using Engine.Kernel.World; + namespace Engine.Kernel.Tests; public class WorldTests { - // TODO(M0): once IWorld has a concrete implementation — - // - GameObject hierarchy (parent/children) behaves correctly - // - Query() returns exactly the GameObjects holding a T, and is - // O(matches) — see the World row in docs/kernel-contract.md §2 - // - AddComponent/RemoveComponent keep the type index consistent - [Fact(Skip = "World has no implementation yet — see M0 in docs/kernel-contract.md §8.")] - public void Placeholder() + [Fact] + public void CreateGameObject_Is_A_Root_With_Identity_Transform() { + var world = new GameWorld(); + + var go = world.CreateGameObject("Player"); + + Assert.Equal("Player", go.Name); + Assert.Null(go.Parent); + Assert.Contains(go, world.Roots); + Assert.Equal(Vector3.Zero, go.Transform.LocalPosition); + Assert.Equal(Quaternion.Identity, go.Transform.LocalRotation); + Assert.Equal(Vector3.One, go.Transform.LocalScale); + } + + [Fact] + public void SetParent_Moves_GameObject_Out_Of_Roots_And_Into_Children() + { + var world = new GameWorld(); + var parent = world.CreateGameObject("Parent"); + var child = world.CreateGameObject("Child"); + + child.SetParent(parent); + + Assert.Same(parent, child.Parent); + Assert.Contains(child, parent.Children); + Assert.DoesNotContain(child, world.Roots); + Assert.Contains(parent, world.Roots); + } + + [Fact] + public void SetParent_Null_Returns_GameObject_To_Roots() + { + var world = new GameWorld(); + var parent = world.CreateGameObject("Parent"); + var child = world.CreateGameObject("Child"); + child.SetParent(parent); + + child.SetParent(null); + + Assert.Null(child.Parent); + Assert.DoesNotContain(child, parent.Children); + Assert.Contains(child, world.Roots); + } + + [Fact] + public void SetParent_Rejects_Self_Parenting() + { + var world = new GameWorld(); + var go = world.CreateGameObject("Solo"); + + Assert.Throws(() => go.SetParent(go)); + } + + [Fact] + public void SetParent_Rejects_A_Cycle_Through_A_Descendant() + { + var world = new GameWorld(); + var grandparent = world.CreateGameObject("Grandparent"); + var parent = world.CreateGameObject("Parent"); + var child = world.CreateGameObject("Child"); + parent.SetParent(grandparent); + child.SetParent(parent); + + // grandparent is child's own descendant-of-a-descendant here — + // reparenting it under child would close the loop. + Assert.Throws(() => grandparent.SetParent(child)); + } + + [Fact] + public void AddComponent_Then_GetComponent_Roundtrips() + { + var world = new GameWorld(); + var go = world.CreateGameObject("Enemy"); + + var added = go.AddComponent(); + added.Value = 42; + + var fetched = go.GetComponent(); + + Assert.NotNull(fetched); + Assert.Same(added, fetched); + Assert.Equal(42, fetched!.Value); + } + + [Fact] + public void GetComponent_Returns_Null_When_Absent() + { + var world = new GameWorld(); + var go = world.CreateGameObject("Empty"); + + Assert.Null(go.GetComponent()); + } + + [Fact] + public void Query_Finds_Only_GameObjects_Carrying_The_Component() + { + var world = new GameWorld(); + var withHealth = world.CreateGameObject("A"); + withHealth.AddComponent(); + var without = world.CreateGameObject("B"); + + var matches = world.Query().ToList(); + + Assert.Contains(withHealth, matches); + Assert.DoesNotContain(without, matches); + } + + [Fact] + public void Query_Stops_Finding_A_GameObject_After_Its_Only_Component_Is_Removed() + { + var world = new GameWorld(); + var go = world.CreateGameObject("A"); + go.AddComponent(); + + go.RemoveComponent(); + + Assert.DoesNotContain(go, world.Query()); + } + + [Fact] + public void Query_Still_Finds_A_GameObject_With_A_Duplicate_Component_After_One_Removal() + { + var world = new GameWorld(); + var go = world.CreateGameObject("A"); + go.AddComponent(); + go.AddComponent(); + + go.RemoveComponent(); // removes one of the two + + Assert.Contains(go, world.Query()); + } + + [Fact] + public void Destroy_Removes_The_GameObject_And_Its_Whole_Subtree() + { + var world = new GameWorld(); + var parent = world.CreateGameObject("Parent"); + var child = world.CreateGameObject("Child"); + child.SetParent(parent); + child.AddComponent(); + + world.Destroy(parent); + + Assert.DoesNotContain(parent, world.Roots); + Assert.DoesNotContain(child, world.Query()); + } + + [Fact] + public void WorldMatrix_Composes_Local_Position_With_The_Parent_Chain() + { + var world = new GameWorld(); + var parent = world.CreateGameObject("Parent"); + parent.Transform.LocalPosition = new Vector3(1, 0, 0); + var child = world.CreateGameObject("Child"); + child.Transform.LocalPosition = new Vector3(0, 1, 0); + child.SetParent(parent); + + var worldPosition = child.WorldMatrix.Translation; + + Assert.Equal(new Vector3(1, 1, 0), worldPosition); + } + + private sealed class Health : Component + { + public int Value; } }