Implement World: GameObject hierarchy, components, type-indexed queries

First real piece of M0 rather than scaffolding. GameWorld : IWorld owns
GameObject creation/destruction, a type-indexed Dictionary<Type,
HashSet<GameObject>> backing Query<T>(), 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
Emil
2026-09-02 00:33:18 +03:00
co-authored by Claude Sonnet 5
parent 1459657408
commit f040f71045
6 changed files with 412 additions and 23 deletions
+98 -8
View File
@@ -1,28 +1,118 @@
using System.Numerics;
namespace Engine.Kernel.World;
/// <summary>
/// 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
/// <see cref="IWorld.CreateGameObject"/>, so <see cref="World"/> can keep
/// its type index consistent instead of trusting callers to report changes.
/// </summary>
public sealed class GameObject
{
public required string Name { get; set; }
private readonly List<Component> _components = [];
private readonly List<GameObject> _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<T>() O(matches), not O(all).
public IReadOnlyList<GameObject> Children => _children;
public IReadOnlyList<GameObject> Children => throw new NotImplementedException();
public IReadOnlyList<Component> Components => _components;
/// <summary>
/// Set by <see cref="GameWorld"/> at creation and cleared on destroy.
/// Null means "not attached to a World" — a defensive state that
/// should never be observable from a plugin.
/// </summary>
internal GameWorld? Owner { get; set; }
/// <summary>
/// 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.
/// </summary>
public Matrix4x4 WorldMatrix =>
Parent is null ? Transform.LocalMatrix : Transform.LocalMatrix * Parent.WorldMatrix;
/// <summary>
/// Reparents this GameObject. Throws if that would create a cycle —
/// checked by walking <paramref name="parent"/>'s own ancestors for
/// this object, which is cheap next to the cost of silently corrupting
/// the hierarchy.
/// </summary>
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<T>() where T : Component
=> throw new NotImplementedException();
{
foreach (var component in _components)
{
if (component is T match)
return match;
}
return null;
}
public T AddComponent<T>() where T : Component, new()
=> throw new NotImplementedException();
{
var component = new T();
_components.Add(component);
Owner?.IndexComponentAdded(this, component);
return component;
}
public void RemoveComponent<T>() 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;
}
}
/// <summary>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.</summary>
internal void DetachFromParent()
{
Parent?._children.Remove(this);
Parent = null;
}
}
+118
View File
@@ -0,0 +1,118 @@
namespace Engine.Kernel.World;
/// <summary>
/// Kernel-owned storage for every GameObject in a scene. See
/// docs/kernel-contract.md §2.
///
/// Named <c>GameWorld</c> rather than <c>World</c> — a class with the same
/// simple name as its own containing namespace (<c>Engine.Kernel.World</c>)
/// makes the bare name ambiguous for every consumer, since C# resolves the
/// enclosing namespace before an imported type.
/// </summary>
public sealed class GameWorld : IWorld
{
private readonly List<GameObject> _roots = [];
private readonly Dictionary<Type, HashSet<GameObject>> _index = [];
/// <summary>Top-level GameObjects — everything with no parent. Walking
/// a scene starts here.</summary>
public IReadOnlyList<GameObject> 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;
}
/// <summary>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.
/// </summary>
public IEnumerable<GameObject> Query<T>() 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<T>() 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);
}
/// <summary>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.</summary>
private void RemoveFromAllIndices(GameObject go)
{
foreach (var set in _index.Values)
set.Remove(go);
}
}
+3
View File
@@ -7,6 +7,9 @@ namespace Engine.Kernel.World;
/// </summary>
public interface IWorld
{
/// <summary>Top-level GameObjects — everything with no parent.</summary>
IReadOnlyList<GameObject> Roots { get; }
GameObject CreateGameObject(string name);
void Destroy(GameObject go);
+15 -2
View File
@@ -6,6 +6,10 @@ namespace Engine.Kernel.World;
/// Embedded directly on <see cref="GameObject"/> rather than modeled as a
/// <see cref="Component"/> 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 <see cref="GameObject.WorldMatrix"/>, not here — see that property
/// for why it isn't cached on this struct instead.
/// </summary>
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);
}