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; } }