commit 127e957c97dfd676e0bfe8b76c76ca303a260ee3 Author: copygirl Date: Tue Dec 27 14:20:20 2022 +0100 Initial commit Split gaemstone.ECS into its own project For older history, see copygirl/gaemstone@0c6d63af21d086f71d1c46acda5ee4c1d0220914 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..41d9e68 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,27 @@ +root = true + +[*] +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.cs] +indent_style = tab +indent_size = 4 +# IDE0005: Using directive is unnecessary +dotnet_diagnostic.IDE0005.severity = suggestion +# IDE0047: Parentheses can be removed +dotnet_diagnostic.IDE0047.severity = none +# IDE0055: Fix formatting +dotnet_diagnostic.IDE0055.severity = none + +[src/flecs-cs/**] +# Suppress compiler and analyer warnings in dependencies. +dotnet_analyzer_diagnostic.severity = none +dotnet_diagnostic.IDE0005.severity = none + +[*.md] +# Allows placing double-space at end of lines to force linebreaks. +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afec7c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +**/obj/ +**/bin/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..accde2e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/flecs-cs"] + path = src/flecs-cs + url = https://github.com/flecs-hub/flecs-cs diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..80d9c9a --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 copygirl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..187ca24 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# gaemstone.ECS + +.. is a medium-level managed wrapper library around the [flecs-cs] bindings +for the amazing [Entity Component System (ECS)][ECS] framework [Flecs]. It is +used as part of the [gæmstone] game engine, but may be used in other projects +as well. To efficiently use this library, a thorough understanding of [Flecs] +is required. + +[ECS]: https://en.wikipedia.org/wiki/Entity_component_system +[Flecs]: https://github.com/SanderMertens/flecs +[flecs-cs]: https://github.com/flecs-hub/flecs-cs +[gæmstone]: https://git.mcft.net/copygirl/gaemstone + +## Features + +These classes have only recently been split from the main **gæmstone** project. It is currently unclear what functionality belongs where, and structural changes are still very likely. In its current state, feel free to use **gæmstone.ECS** as a reference for building similar projects, or be aware that things are in flux, and in general nowhere near stable. + +- Simple wrapper structs such as [Entity] and [Identifier]. +- Classes with convenience functions like [EntityRef] and [EntityType]. +- Define your own [Components] as both value or reference types. +- Query the ECS with [Iterators], [Filters], [Queries] and [Rules]. +- Create [Systems] for game logic and [Observers] to act on changes. +- [EntityPath] uses a unix-like path: `/Game/Players/copygirl` + +[Entity]: ./src/gaemstone.ECS/Entity.cs +[Identifier]: ./src/gaemstone.ECS/Identifier.cs +[EntityRef]: ./src/gaemstone.ECS/EntityRef.cs +[EntityType]: ./src/gaemstone.ECS/EntityType.cs +[Components]: ./src/gaemstone.ECS/Component.cs +[Iterators]: ./src/gaemstone.ECS/Iterator.cs +[Filters]: ./src/gaemstone.ECS/Filter.cs +[Queries]: ./src/gaemstone.ECS/Query.cs +[Rules]: ./src/gaemstone.ECS/Rule.cs +[Systems]: ./src/gaemstone.ECS/System.cs +[Observers]: ./src/gaemstone.ECS/Observer.cs +[EntityPath]: ./src/gaemstone.ECS/EntityPath.cs + +## Example + +```cs +var world = new World(); + +var position = world + .New("Position") // Create a new EntityBuilder, and set its name. + .Symbol("Position") // Set entity's symbol. (Used in query expression.) + .Build() // Actually create the entity in-world. + // Finally, create a component from this entity. + // The "Position" struct is defined at the bottom of this example. + .InitComponent(); + +world.New("One").Set(new Position( 0, 0)).Build(); +world.New("Two").Set(new Position(10, 20)).Build(); + +var onUpdate = world.LookupOrThrow("/flecs/pipeline/OnUpdate"); + +// Create a system that will move all entities with +// the "Position" component downwards by 2 every frame. +world.New("FallSystem").Build() + .InitSystem(onUpdate, new("Position"), iter => { + var positionSpan = iter.Field(1); + for (var i = 0; i < iter.Count; i++) { + ref var position = ref positionSpan[i]; + position = new(position.X, position.Y + 2); + } + }); + +// Create a system that will print out entities' positions. +world.New("PrintPositionSystem").Build() + .InitSystem(onUpdate, new("[in] Position"), iter => { + var positionSpan = iter.Field(1); + for (var i = 0; i < iter.Count; i++) { + var entity = iter.Entity(i); + var position = positionSpan[i]; + Console.WriteLine($"{entity.Name} is at {position}"); + } + }); + +// Infinite loop that runs the "game" at 30 FPS. +while (true) { + var delta = TimeSpan.FromSeconds(1.0f / 30); + // Progress returns false if quit was requested. + if (!world.Progress(delta)) break; + Thread.Sleep(delta); +} + +record struct Position(int X, int Y); +``` + +## Instructions + +```sh +# Clone the repository. Recurse into submodules to include flecs-cs and flecs. +git clone --recurse-submodules https://git.mcft.net/copygirl/gaemstone.ECS.git + +# If you want to add it to your own repository as a submodule: +git submodule add https://git.mcft.net/copygirl/gaemstone.ECS.git + +# To add a reference to this library to your .NET project: +dotnet add reference gaemstone.ECS/gaemstone.ECS.csproj + +# To generate flecs-cs' bindings: +./gaemstone.ECS/src/flecs-cs/library.sh +``` diff --git a/gaemstone.ECS.csproj b/gaemstone.ECS.csproj new file mode 100644 index 0000000..d9995da --- /dev/null +++ b/gaemstone.ECS.csproj @@ -0,0 +1,23 @@ + + + + net7.0 + true + disable + enable + + + + false + + + + + + + + + + + + diff --git a/src/flecs-cs b/src/flecs-cs new file mode 160000 index 0000000..a204798 --- /dev/null +++ b/src/flecs-cs @@ -0,0 +1 @@ +Subproject commit a2047983917aa462a8c2f34d5315aea48502f4d8 diff --git a/src/gaemstone.ECS/Component.cs b/src/gaemstone.ECS/Component.cs new file mode 100644 index 0000000..d6b4b3f --- /dev/null +++ b/src/gaemstone.ECS/Component.cs @@ -0,0 +1,30 @@ +using System; +using System.Runtime.CompilerServices; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public static class ComponentExtensions +{ + public unsafe static EntityRef InitComponent(this EntityRef entity, Type type) + => (EntityRef)typeof(ComponentExtensions) + .GetMethod(nameof(InitComponent), new[] { typeof(EntityRef) })! + .MakeGenericMethod(type).Invoke(null, new[]{ entity })!; + + public unsafe static EntityRef InitComponent(this EntityRef entity) + { + if (typeof(T).IsPrimitive) throw new ArgumentException( + "Must not be primitive"); + if (typeof(T).IsValueType && RuntimeHelpers.IsReferenceOrContainsReferences()) throw new ArgumentException( + "Struct component must satisfy the unmanaged constraint. " + + "Consider making it a class if you need to store references."); + + var size = Unsafe.SizeOf(); + var typeInfo = new ecs_type_info_t + { size = size, alignment = size }; + var componentDesc = new ecs_component_desc_t + { entity = entity, type = typeInfo }; + ecs_component_init(entity.World, &componentDesc); + return entity.CreateLookup(typeof(T)); + } +} diff --git a/src/gaemstone.ECS/Entity.cs b/src/gaemstone.ECS/Entity.cs new file mode 100644 index 0000000..345a1be --- /dev/null +++ b/src/gaemstone.ECS/Entity.cs @@ -0,0 +1,30 @@ +using System; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public readonly struct Entity + : IEquatable +{ + public static readonly Entity None = default; + + public readonly ecs_entity_t Value; + public uint Id => (uint)Value.Data; + + public bool IsSome => Value.Data != 0; + public bool IsNone => Value.Data == 0; + + public Entity(ecs_entity_t value) => Value = value; + + public bool Equals(Entity other) => Value.Data == other.Value.Data; + public override bool Equals(object? obj) => (obj is Entity other) && Equals(other); + public override int GetHashCode() => Value.Data.GetHashCode(); + public override string? ToString() => $"Entity(0x{Value.Data.Data:X})"; + + public static bool operator ==(Entity left, Entity right) => left.Equals(right); + public static bool operator !=(Entity left, Entity right) => !left.Equals(right); + + public static implicit operator ecs_entity_t(Entity e) => e.Value; + public static implicit operator Identifier(Entity e) => new(e.Value.Data); + public static implicit operator ecs_id_t(Entity e) => e.Value.Data; +} diff --git a/src/gaemstone.ECS/EntityBase.cs b/src/gaemstone.ECS/EntityBase.cs new file mode 100644 index 0000000..b4fb6f2 --- /dev/null +++ b/src/gaemstone.ECS/EntityBase.cs @@ -0,0 +1,61 @@ +namespace gaemstone.ECS; + +public abstract class EntityBase +{ + public abstract World World { get; } + + + public abstract TReturn Add(Identifier id); + public abstract TReturn Remove(Identifier id); + public abstract bool Has(Identifier id); + + public TReturn Add(string symbol) => Add(World.LookupSymbolOrThrow(symbol)); + public TReturn Add() => Add(World.LookupOrThrow(typeof(T))); + public TReturn Add(Entity relation, Entity target) => Add(Identifier.Pair(relation, target)); + public TReturn Add(Entity target) => Add(World.LookupOrThrow(), target); + public TReturn Add() => Add(World.LookupOrThrow(), World.LookupOrThrow()); + + public TReturn Remove(string symbol) => Remove(World.LookupSymbolOrThrow(symbol)); + public TReturn Remove() => Remove(World.LookupOrThrow(typeof(T))); + public TReturn Remove(Entity relation, Entity target) => Remove(Identifier.Pair(relation, target)); + public TReturn Remove(Entity target) => Remove(World.LookupOrThrow(), target); + public TReturn Remove() => Remove(World.LookupOrThrow(), World.LookupOrThrow()); + + public bool Has(string symbol) => Has(World.LookupSymbolOrThrow(symbol)); + public bool Has() => Has(World.LookupOrThrow(typeof(T))); + public bool Has(Entity relation, Entity target) => Has(Identifier.Pair(relation, target)); + public bool Has(Entity target) => Has(World.LookupOrThrow(), target); + public bool Has() => Has(World.LookupOrThrow(), World.LookupOrThrow()); + + + public abstract T Get(Identifier id); + public abstract T? GetOrNull(Identifier id) where T : unmanaged; + public abstract T? GetOrNull(Identifier id, T _ = null!) where T : class; + public abstract ref T GetMut(Identifier id) where T : unmanaged; + public abstract ref T GetRefOrNull(Identifier id) where T : unmanaged; + public abstract ref T GetRefOrThrow(Identifier id) where T : unmanaged; + public abstract void Modified(Identifier id); + + public T Get() => Get(World.LookupOrThrow()); + public T? GetOrNull() where T : unmanaged => GetOrNull(World.LookupOrThrow()); + public T? GetOrNull(T _ = null!) where T : class => GetOrNull(World.LookupOrThrow()); + public ref T GetMut() where T : unmanaged => ref GetMut(World.LookupOrThrow()); + public ref T GetRefOrNull() where T : unmanaged => ref GetRefOrNull(World.LookupOrThrow()); + public ref T GetRefOrThrow() where T : unmanaged => ref GetRefOrThrow(World.LookupOrThrow()); + public void Modified() => Modified(World.LookupOrThrow()); + + + public abstract TReturn Set(Identifier id, in T value) where T : unmanaged; + public abstract TReturn Set(Identifier id, T obj) where T : class; + + public TReturn Set(in T value) where T : unmanaged => Set(World.LookupOrThrow(), value); + public TReturn Set(T obj) where T : class => Set(World.LookupOrThrow(), obj); + + + public TReturn ChildOf(Entity parent) => Add(World.ChildOf, parent); + public TReturn ChildOf() => Add(World.ChildOf, World.LookupOrThrow()); + + public TReturn Disable() => Add(World.Disabled); + public TReturn Enable() => Remove(World.Disabled); + public bool IsDisabled => Has(World.Disabled); +} diff --git a/src/gaemstone.ECS/EntityBuilder.cs b/src/gaemstone.ECS/EntityBuilder.cs new file mode 100644 index 0000000..dd6e2cf --- /dev/null +++ b/src/gaemstone.ECS/EntityBuilder.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public class EntityBuilder + : EntityBase +{ + public override World World { get; } + + /// Set to modify existing entity (optional). + public Entity Id { get; set; } + + /// + /// Path of the entity. If no entity is provided, an entity with this path + /// will be looked up first. When an entity is provided, the path will be + /// verified with the existing entity. + /// + public EntityPath? Path { get; set; } + + /// + /// Optional entity symbol. A symbol is an unscoped identifier that can + /// be used to lookup an entity. The primary use case for this is to + /// associate the entity with a language identifier, such as a type or + /// function name, where these identifiers differ from the name they are + /// registered with in flecs. + /// + public EntityBuilder Symbol(string symbol) { _symbol = symbol; return this; } + private string? _symbol = null; + + /// + /// When set to true, a low id (typically reserved for components) + /// will be used to create the entity, if no id is specified. + /// + public bool UseLowId { get; set; } + + /// Ids to add to the new or existing entity. + private readonly HashSet _toAdd = new(); + private Entity _parent = Entity.None; + + /// String expression with components to add. + public string? Expression { get; } + + /// Actions to run once the entity has been created. + private readonly List> _toSet = new(); + + public EntityBuilder(World world, EntityPath? path = null) + { World = world; Path = path; } + + public override EntityBuilder Add(Identifier id) + { + // If adding a ChildOf relation, store the parent separately. + if (id.AsPair(World) is (EntityRef relation, EntityRef target) && + (relation == World.ChildOf)) { _parent = target; return this; } + + if (_toAdd.Count == 31) throw new NotSupportedException( + "Must not add more than 31 Ids at once with EntityBuilder"); + _toAdd.Add(id); + + return this; + } + public override EntityBuilder Remove(Identifier id) + => throw new NotSupportedException(); + public override bool Has(Identifier id) + => !id.IsWildcard ? _toAdd.Contains(id) + : throw new NotSupportedException(); // TODO: Support wildcard. + + public override T Get(Identifier id) => throw new NotSupportedException(); + public override T? GetOrNull(Identifier id) => throw new NotSupportedException(); + public override T? GetOrNull(Identifier id, T _ = null!) where T : class => throw new NotSupportedException(); + public override ref T GetMut(Identifier id) => throw new NotSupportedException(); + public override ref T GetRefOrNull(Identifier id) => throw new NotSupportedException(); + public override ref T GetRefOrThrow(Identifier id) => throw new NotSupportedException(); + public override void Modified(Identifier id) => throw new NotImplementedException(); + + public override EntityBuilder Set(Identifier id, in T value) + // "in" can't be used with lambdas, so we make a local copy. + { var copy = value; _toSet.Add(e => e.Set(id, copy)); return this; } + + public override EntityBuilder Set(Identifier id, T obj) + { _toSet.Add(e => e.Set(id, obj)); return this; } + + public unsafe EntityRef Build() + { + var parent = _parent; + + if (Path != null) { + if (parent.IsSome && Path.IsAbsolute) throw new InvalidOperationException( + "Entity already has parent set (via ChildOf), so path must not be absolute"); + // If path specifies more than just a name, ensure the parent entity exists. + if (Path.Count > 1) parent = EntityPath.EnsureEntityExists(World, parent, Path.Parent!); + } + + using var alloc = TempAllocator.Use(); + var desc = new ecs_entity_desc_t { + id = Id, + name = (Path != null) ? alloc.AllocateCString(Path.Name.AsSpan()) : default, + symbol = alloc.AllocateCString(_symbol), + add_expr = alloc.AllocateCString(Expression), + use_low_id = UseLowId, + sep = CStringExtensions.ETX, + }; + + var add = desc.add; var index = 0; + if (parent.IsSome) add[index++] = Identifier.Pair(World.ChildOf, parent); + foreach (var id in _toAdd) add[index++] = id; + + var entityId = ecs_entity_init(World, &desc); + var entity = new EntityRef(World, new(entityId)); + foreach (var action in _toSet) action(entity); + + return entity; + } +} diff --git a/src/gaemstone.ECS/EntityPath.cs b/src/gaemstone.ECS/EntityPath.cs new file mode 100644 index 0000000..0886a35 --- /dev/null +++ b/src/gaemstone.ECS/EntityPath.cs @@ -0,0 +1,233 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public class EntityPath +{ + private readonly byte[][] _parts; + + public bool IsAbsolute { get; } + public bool IsRelative => !IsAbsolute; + public int Count => _parts.Length; + + public UTF8View Name => this[^1]; + public EntityPath? Parent => (Count > 1) ? new(IsAbsolute, _parts[..^1]) : null; + + public UTF8View this[int index] + => (index >= 0 && index < Count) ? new(_parts[index].AsSpan()[..^1]) + : throw new ArgumentOutOfRangeException(nameof(index)); + + public EntityPath this[Range range] + => new(IsAbsolute && (range.GetOffsetAndLength(Count).Offset == 0), _parts[range]); + + internal EntityPath(bool absolute, params byte[][] parts) + { + if (parts.Length == 0) throw new ArgumentException( + "Must have at least one part", nameof(parts)); + IsAbsolute = absolute; + _parts = parts; + } + + public EntityPath(params string[] parts) + : this(false, parts) { } + public EntityPath(bool absolute, params string[] parts) + : this(absolute, parts.Select(part => { + if (GetNameValidationError(part) is string error) + throw new ArgumentException(error); + var byteCount = Encoding.UTF8.GetByteCount(part); + // Includes NUL character at the end of bytes. + var bytes = new byte[byteCount + 1]; + Encoding.UTF8.GetBytes(part, bytes); + return bytes; + }).ToArray()) { } + + public static bool TryParse(string str, [NotNullWhen(true)] out EntityPath? result) + { + result = null; + if (str.Length == 0) return false; + + var strSpan = str.AsSpan(); + var isAbsolute = (str[0] == '/'); + if (isAbsolute) strSpan = strSpan[1..]; + + var numSeparators = 0; + foreach (var chr in strSpan) if (chr == '/') numSeparators++; + + var index = 0; + var parts = new byte[numSeparators + 1][]; + foreach (var part in strSpan.Split('/')) { + if (GetNameValidationError(part) != null) return false; + var byteCount = Encoding.UTF8.GetByteCount(part); + // Includes NUL character at the end of bytes. + var bytes = new byte[byteCount + 1]; + Encoding.UTF8.GetBytes(part, bytes); + parts[index++] = bytes; + } + + result = new(isAbsolute, parts); + return true; + } + + public static EntityPath Parse(string str) + { + if (str.Length == 0) throw new ArgumentException( + "String must not be empty", nameof(str)); + var parts = str.Split('/'); + // If string starts with a slash, first part will be empty, so create an absolute path. + return (parts[0].Length == 0) ? new(true, parts[1..]) : new(parts); + } + + public static string? GetNameValidationError(ReadOnlySpan name) + { + if (name.Length == 0) return "Must not be empty"; + // NOTE: This is a hopefully straightforward way to also prevent "." + // and ".." to be part of paths which may access the file system. + if (name[0] == '.') return "Must not begin with a dot"; + foreach (var chr in name) if (char.IsControl(chr)) + return "Must not contain contol characters"; + return null; + } + + // private static readonly Rune[] _validRunes = { (Rune)'-', (Rune)'.', (Rune)'_' }; + // private static readonly UnicodeCategory[] _validCategories = { + // UnicodeCategory.LowercaseLetter, UnicodeCategory.UppercaseLetter, + // UnicodeCategory.OtherLetter, UnicodeCategory.DecimalDigitNumber }; + + // private static void ValidateRune(Rune rune) + // { + // if (!_validRunes.Contains(rune) && !_validCategories.Contains(Rune.GetUnicodeCategory(rune))) + // throw new ArgumentException($"Must not contain {Rune.GetUnicodeCategory(rune)} character"); + // } + + public string[] GetParts() + { + var result = new string[Count]; + for (var i = 0; i < Count; i++) result[i] = this[i]; + return result; + } + + public override string ToString() + { + var builder = new StringBuilder(); + if (IsAbsolute) builder.Append('/'); + foreach (var part in this) builder.Append(part).Append('/'); + return builder.ToString(0, builder.Length - 1); + } + + public static implicit operator EntityPath(string path) => Parse(path); + + public Enumerator GetEnumerator() => new(this); + public ref struct Enumerator + { + private readonly EntityPath _path; + private int index = -1; + public UTF8View Current => _path[index]; + internal Enumerator(EntityPath path) => _path = path; + public bool MoveNext() => (++index < _path.Count); + } + + + internal static unsafe Entity Lookup(World world, Entity parent, EntityPath path) + { + // If path is absolute, ignore parent and start at root. + if (path.IsAbsolute) parent = default; + // Otherwise, if no parent is specified, use the current scope. + else if (parent.IsNone) parent = new(ecs_get_scope(world)); + + foreach (var part in path) + fixed (byte* ptr = part.AsSpan()) { + // FIXME: This breaks when using large entity Ids. + parent = new(ecs_lookup_child(world, parent, ptr)); + if (parent.IsNone || !ecs_is_alive(world, parent)) + return Entity.None; + } + + return parent; + } + + /// Used by . + internal static unsafe Entity EnsureEntityExists( + World world, Entity parent, EntityPath path) + { + // If no parent is specified and path is relative, use the current scope. + if (parent.IsNone && path.IsRelative) parent = new(ecs_get_scope(world)); + + var skipLookup = parent.IsNone; + foreach (var part in path) + fixed (byte* ptr = part.AsSpan()) + if (skipLookup || (parent = new(ecs_lookup_child(world, parent, ptr))).IsNone) { + var desc = new ecs_entity_desc_t { name = ptr, sep = CStringExtensions.ETX }; + if (parent.IsSome) desc.add[0] = Identifier.Pair(world.ChildOf, parent); + parent = new(ecs_entity_init(world, &desc)); + skipLookup = true; + } + + return parent; + } +} + +public static class EntityPathExtensions +{ + public static unsafe EntityPath GetFullPath(this EntityRef entity) + { + var current = (Entity)entity; + var parts = new List(32); + + do { + var name = ecs_get_name(entity.World, current).FlecsToBytes(); + if (name != null) parts.Add(name); + else { + // If name is not set, use the numeric Id, instead. + var id = current.Id.ToString(); + var bytes = new byte[Encoding.UTF8.GetByteCount(id) + 1]; + Encoding.UTF8.GetBytes(id, bytes); + parts.Add(bytes); + } + } while ((current = new(ecs_get_target(entity.World, current, EcsChildOf, 0))).IsSome); + + parts.Reverse(); + return new(true, parts.ToArray()); + } +} + +public readonly ref struct UTF8View +{ + private readonly ReadOnlySpan _bytes; + public UTF8View(ReadOnlySpan bytes) + => _bytes = bytes; + + public int Length => _bytes.Length; + public byte this[int index] => _bytes[index]; + + public ReadOnlySpan AsSpan() => _bytes; + public override string ToString() => Encoding.UTF8.GetString(_bytes); + public static implicit operator string(UTF8View view) => view.ToString(); + + public Enumerator GetEnumerator() => new(_bytes); + public ref struct Enumerator + { + private readonly ReadOnlySpan _bytes; + private int index = 0; + private Rune _current = default; + public Rune Current => _current; + + internal Enumerator(ReadOnlySpan bytes) + => _bytes = bytes; + + public bool MoveNext() + { + if (index >= _bytes.Length) return false; + if (Rune.DecodeFromUtf8(_bytes[index..], out _current, out var consumed) != OperationStatus.Done) + throw new InvalidOperationException("Contains invalid UTF8"); + index += consumed; + return true; + } + } +} diff --git a/src/gaemstone.ECS/EntityRef.cs b/src/gaemstone.ECS/EntityRef.cs new file mode 100644 index 0000000..96ddff5 --- /dev/null +++ b/src/gaemstone.ECS/EntityRef.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public unsafe class EntityRef + : EntityBase + , IEquatable +{ + public override World World { get; } + public Entity Entity { get; } + public uint Id => Entity.Id; + + public bool IsAlive => ecs_is_alive(World, this); + public EntityType Type => new(World, ecs_get_type(World, this)); + + public string? Name { + get => ecs_get_name(World, this).FlecsToString()!; + set { using var alloc = TempAllocator.Use(); + ecs_set_name(World, this, alloc.AllocateCString(value)); } + } + public string? Symbol { + get => ecs_get_symbol(World, this).FlecsToString()!; + set { using var alloc = TempAllocator.Use(); + ecs_set_symbol(World, this, alloc.AllocateCString(value)); } + } + + + private EntityRef(World world, Entity entity, bool throwOnInvalid) + { + if (throwOnInvalid && !ecs_is_valid(world, entity)) + throw new InvalidOperationException($"The entity {entity} is not valid"); + World = world; + Entity = entity; + } + + public EntityRef(World world, Entity entity) + : this(world, entity, true) { } + + public static EntityRef? CreateOrNull(World world, Entity entity) + => ecs_is_valid(world, entity) ? new(world, entity) : null; + + + public void Delete() + => ecs_delete(World, this); + + public EntityBuilder NewChild(EntityPath? path = null) + => World.New(EnsureRelativePath(path)).ChildOf(this); + public EntityRef? Lookup(EntityPath path) + => World.Lookup(this, EnsureRelativePath(path)!); + public EntityRef LookupOrThrow(EntityPath path) + => World.LookupOrThrow(this, EnsureRelativePath(path)!); + + private static EntityPath? EnsureRelativePath(EntityPath? path) + { if (path?.IsAbsolute == true) throw new ArgumentException("path must not be absolute", nameof(path)); return path; } + + + public EntityRef? Parent + => GetTarget(World.ChildOf); + + public IEnumerable GetChildren() + { + foreach (var iter in Iterator.FromTerm(World, new(World.ChildOf, this))) + for (var i = 0; i < iter.Count; i++) + yield return iter.Entity(i); + } + + + public override EntityRef Add(Identifier id) { ecs_add_id(World, this, id); return this; } + public override EntityRef Remove(Identifier id) { ecs_remove_id(World, this, id); return this; } + public override bool Has(Identifier id) => ecs_has_id(World, this, id); + + public override T Get(Identifier id) + { + var ptr = ecs_get_id(World, this, id); + if (ptr == null) throw new Exception($"Component {typeof(T)} not found on {this}"); + return typeof(T).IsValueType ? Unsafe.Read(ptr) + : (T)((GCHandle)Unsafe.Read(ptr)).Target!; + } + + public override T? GetOrNull(Identifier id) + { + var ptr = ecs_get_id(World, this, id); + return (ptr != null) ? Unsafe.Read(ptr) : null; + } + + public override T? GetOrNull(Identifier id, T _ = null!) + where T : class + { + var ptr = ecs_get_id(World, this, id); + return (ptr != null) ? (T)((GCHandle)Unsafe.Read(ptr)).Target! : null; + } + + public override ref T GetRefOrNull(Identifier id) + { + var @ref = ecs_ref_init_id(World, this, id); + var ptr = ecs_ref_get_id(World, &@ref, id); + return ref (ptr != null) ? ref Unsafe.AsRef(ptr) : ref Unsafe.NullRef(); + } + + public override ref T GetRefOrThrow(Identifier id) + { + ref var ptr = ref GetRefOrNull(id); + if (Unsafe.IsNullRef(ref ptr)) throw new Exception( + $"Component {typeof(T)} not found on {this}"); + return ref ptr; + } + + public override ref T GetMut(Identifier id) + { + var ptr = ecs_get_mut_id(World, this, id); + // NOTE: Value is added if it doesn't exist on the entity. + return ref Unsafe.AsRef(ptr); + } + + public override void Modified(Identifier id) + => ecs_modified_id(World, this, id); + + public override EntityRef Set(Identifier id, in T value) + { + var size = (ulong)Unsafe.SizeOf(); + fixed (T* ptr = &value) + if (ecs_set_id(World, this, id, size, ptr).Data == 0) + throw new InvalidOperationException(); + return this; + } + + public override EntityRef Set(Identifier id, T obj) where T : class + { + var handle = (nint)GCHandle.Alloc(obj); + // FIXME: Previous handle needs to be freed. + if (ecs_set_id(World, this, id, (ulong)sizeof(nint), &handle).Data == 0) + throw new InvalidOperationException(); + // FIXME: Handle needs to be freed when component is removed! + return this; + } + + public EntityRef? GetTarget(Entity relation, int index = 0) + => CreateOrNull(World, new(ecs_get_target(World, this, relation, index))); + public EntityRef? GetTarget(string symbol, int index = 0) + => GetTarget(World.LookupSymbolOrThrow(symbol), index); + public EntityRef? GetTarget(int index = 0) + => GetTarget(World.LookupOrThrow(typeof(T)), index); + + public bool Equals(EntityRef? other) => (other is not null) && (World == other.World) && (Entity == other.Entity); + public override bool Equals(object? obj) => Equals(obj as EntityRef); + public override int GetHashCode() => HashCode.Combine(World, Entity); + public override string? ToString() => ecs_entity_str(World, this).FlecsToStringAndFree()!; + + public static bool operator ==(EntityRef? left, EntityRef? right) => ReferenceEquals(left, right) || (left?.Equals(right) ?? false); + public static bool operator !=(EntityRef? left, EntityRef? right) => !(left == right); + + public static implicit operator Entity(EntityRef? e) => e?.Entity ?? default; + public static implicit operator ecs_entity_t(EntityRef? e) => e?.Entity.Value ?? default; + + public static implicit operator Identifier(EntityRef? e) => new(e?.Entity.Value.Data ?? default); + public static implicit operator ecs_id_t(EntityRef? e) => e?.Entity.Value.Data ?? default; +} diff --git a/src/gaemstone.ECS/EntityType.cs b/src/gaemstone.ECS/EntityType.cs new file mode 100644 index 0000000..06e9417 --- /dev/null +++ b/src/gaemstone.ECS/EntityType.cs @@ -0,0 +1,25 @@ +using System.Collections; +using System.Collections.Generic; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public unsafe readonly struct EntityType + : IReadOnlyList +{ + public World World { get; } + public ecs_type_t* Handle { get; } + + public EntityType(World world, ecs_type_t* handle) + { World = world; Handle = handle; } + + public override string ToString() + => ecs_type_str(World, Handle).FlecsToStringAndFree()!; + + // IReadOnlyList implementation + public int Count => Handle->count; + public IdentifierRef this[int index] => new(World, new(Handle->array[index])); + public IEnumerator GetEnumerator() { for (var i = 0; i < Count; i++) yield return this[i]; } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/gaemstone.ECS/Filter.cs b/src/gaemstone.ECS/Filter.cs new file mode 100644 index 0000000..d3b38c1 --- /dev/null +++ b/src/gaemstone.ECS/Filter.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public unsafe sealed class Filter + : IDisposable +{ + public World World { get; } + public ecs_filter_t* Handle { get; } + + public Filter(World world, FilterDesc desc) + { + using var alloc = TempAllocator.Use(); + var flecsDesc = desc.ToFlecs(alloc); + World = world; + Handle = ecs_filter_init(world, &flecsDesc); + } + + public void Dispose() + => ecs_filter_fini(Handle); + + public Iterator Iter() + => new(World, IteratorType.Filter, ecs_filter_iter(World, this)); + + public override string ToString() + => ecs_filter_str(World, Handle).FlecsToStringAndFree()!; + + public static implicit operator ecs_filter_t*(Filter q) => q.Handle; +} + +public class FilterDesc +{ + public IReadOnlyList Terms { get; } + + public string? Expression { get; } + + /// + /// When true, terms returned by an iterator may either contain 1 or N + /// elements, where terms with N elements are owned, and terms with 1 + /// element are shared, for example from a parent or base entity. When + /// false, the iterator will at most return 1 element when the result + /// contains both owned and shared terms. + /// + public bool Instanced { get; set; } + + /// + /// Entity associated with query (optional). + /// + public Entity Entity { get; set; } + + public FilterDesc(params Term[] terms) + => Terms = terms; + public FilterDesc(string expression) : this() + => Expression = expression; + + public unsafe ecs_filter_desc_t ToFlecs(IAllocator allocator) + { + var desc = new ecs_filter_desc_t { + expr = allocator.AllocateCString(Expression), + instanced = Instanced, + entity = Entity, + }; + var span = desc.terms; + if (Terms.Count > span.Length) { + span = allocator.Allocate(Terms.Count); + desc.terms_buffer = (ecs_term_t*)Unsafe.AsPointer(ref span[0]); + desc.terms_buffer_count = Terms.Count; + } + for (var i = 0; i < Terms.Count; i++) + span[i] = Terms[i].ToFlecs(allocator); + return desc; + } +} diff --git a/src/gaemstone.ECS/Identifier.cs b/src/gaemstone.ECS/Identifier.cs new file mode 100644 index 0000000..672beba --- /dev/null +++ b/src/gaemstone.ECS/Identifier.cs @@ -0,0 +1,54 @@ +using System; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public readonly struct Identifier + : IEquatable +{ + public readonly ecs_id_t Value; + + public bool IsPair => ecs_id_is_pair(this); + public bool IsWildcard => ecs_id_is_wildcard(this); + + public IdentifierFlags Flags => (IdentifierFlags)(Value & ECS_ID_FLAGS_MASK); + + public Entity RelationUnsafe => new(new() { Data = (Value & ECS_COMPONENT_MASK) >> 32 }); + public Entity TargetUnsafe => new(new() { Data = Value & ECS_ENTITY_MASK }); + + public Identifier(ecs_id_t value) => Value = value; + + public static Identifier Combine(IdentifierFlags flags, Identifier id) + => new((ulong)flags | id.Value); + + public static Identifier Pair(Entity relation, Entity target) + => Combine(IdentifierFlags.Pair, new( + ((relation.Value.Data << 32) & ECS_COMPONENT_MASK) | + ( target.Value.Data & ECS_ENTITY_MASK ))); + + public (EntityRef Relation, EntityRef Target)? AsPair(World world) + => new IdentifierRef(world, this).AsPair(); + + public bool Equals(Identifier other) => Value.Data == other.Value.Data; + public override bool Equals(object? obj) => (obj is Identifier other) && Equals(other); + public override int GetHashCode() => Value.Data.GetHashCode(); + public override string? ToString() + => (Flags != default) ? $"Identifier(0x{Value.Data:X}, Flags={Flags})" + : $"Identifier(0x{Value.Data:X})"; + + public static bool operator ==(Identifier left, Identifier right) => left.Equals(right); + public static bool operator !=(Identifier left, Identifier right) => !left.Equals(right); + + public static implicit operator ecs_id_t(Identifier i) => i.Value; +} + +[Flags] +public enum IdentifierFlags : ulong +{ + Pair = 1ul << 63, + Override = 1ul << 62, + Toggle = 1ul << 61, + Or = 1ul << 60, + And = 1ul << 59, + Not = 1ul << 58, +} diff --git a/src/gaemstone.ECS/IdentifierRef.cs b/src/gaemstone.ECS/IdentifierRef.cs new file mode 100644 index 0000000..4c1459b --- /dev/null +++ b/src/gaemstone.ECS/IdentifierRef.cs @@ -0,0 +1,46 @@ +using System; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public unsafe class IdentifierRef + : IEquatable +{ + public World World { get; } + public Identifier Id { get; } + + public IdentifierFlags Flags => Id.Flags; + public bool IsPair => Id.IsPair; + public bool IsWildcard => Id.IsWildcard; + public bool IsValid => ecs_id_is_valid(World, this); + public bool IsInUse => ecs_id_in_use(World, this); + + public IdentifierRef(World world, Identifier id) + { World = world; Id = id; } + + public static IdentifierRef Combine(IdentifierFlags flags, IdentifierRef id) + => new(id.World, Identifier.Combine(flags, id)); + public static IdentifierRef Pair(EntityRef relation, Entity target) + => new(relation.World, Identifier.Pair(relation, target)); + public static IdentifierRef Pair(Entity relation, EntityRef target) + => new(target.World, Identifier.Pair(relation, target)); + + public EntityRef? AsEntity() + => (Flags == default) ? World.Lookup(new Entity(new() { Data = Id })) : null; + public (EntityRef Relation, EntityRef Target)? AsPair() + => IsPair && (World.Lookup(Id.RelationUnsafe) is EntityRef relation) && + (World.Lookup(Id.TargetUnsafe ) is EntityRef target ) + ? (relation, target) : null; + + public bool Equals(IdentifierRef? other) => (other is not null) && (World == other.World) && (Id == other.Id); + public override bool Equals(object? obj) => Equals(obj as IdentifierRef); + public override int GetHashCode() => HashCode.Combine(World, Id); + public override string? ToString() => ecs_id_str(World, this).FlecsToStringAndFree()!; + + public static bool operator ==(IdentifierRef? left, IdentifierRef? right) => ReferenceEquals(left, right) || (left?.Equals(right) ?? false); + public static bool operator !=(IdentifierRef? left, IdentifierRef? right) => !(left == right); + + public static implicit operator Identifier(IdentifierRef i) => i.Id; + public static implicit operator ecs_id_t(IdentifierRef i) => i.Id.Value; +} diff --git a/src/gaemstone.ECS/Iterator.cs b/src/gaemstone.ECS/Iterator.cs new file mode 100644 index 0000000..6108867 --- /dev/null +++ b/src/gaemstone.ECS/Iterator.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public unsafe class Iterator + : IEnumerable + , IDisposable +{ + public World World { get; } + public IteratorType? Type { get; } + public ecs_iter_t Value; + + public bool Completed { get; private set; } + public int Count => Value.count; + public TimeSpan DeltaTime => TimeSpan.FromSeconds(Value.delta_time); + public TimeSpan DeltaSystemTime => TimeSpan.FromSeconds(Value.delta_system_time); + + public Iterator(World world, IteratorType? type, ecs_iter_t value) + { World = world; Type = type; Value = value; } + + public static Iterator FromTerm(World world, Term term) + { + using var alloc = TempAllocator.Use(); + var flecsTerm = term.ToFlecs(alloc); + var flecsIter = ecs_term_iter(world, &flecsTerm); + return new(world, IteratorType.Term, flecsIter); + } + + public Iterator SetThis(Entity entity) + { + fixed (ecs_iter_t* ptr = &Value) + ecs_iter_set_var(ptr, 0, entity); + return this; + } + + public void Dispose() + { + // When an iterator is iterated until completion, + // ecs_iter_fini will be called automatically. + if (!Completed) + fixed (ecs_iter_t* ptr = &Value) + ecs_iter_fini(ptr); + } + + public bool Next() + { + fixed (ecs_iter_t* ptr = &Value) { + var result = Type switch { + IteratorType.Term => ecs_term_next(ptr), + IteratorType.Filter => ecs_filter_next(ptr), + IteratorType.Query => ecs_query_next(ptr), + IteratorType.Rule => ecs_rule_next(ptr), + _ => ecs_iter_next(ptr), + }; + Completed = !result; + return result; + } + } + + public EntityRef Entity(int index) + => new(World, new(Value.entities[index])); + + public IdentifierRef FieldId(int index) + { + fixed (ecs_iter_t* ptr = &Value) + return new(World, new(ecs_field_id(ptr, index))); + } + + public Span Field(int index) + where T : unmanaged + { + fixed (ecs_iter_t* ptr = &Value) { + var size = (ulong)Unsafe.SizeOf(); + var isSelf = ecs_field_is_self(ptr, index); + var pointer = ecs_field_w_size(ptr, size, index); + return new(pointer, isSelf ? Count : 1); + } + } + + public Span FieldOrEmpty(int index) + where T : unmanaged => FieldIsSet(index) ? Field(index) : default; + + public SpanToRef FieldRef(int index) + where T : class => new(Field(index)); + + public bool FieldIsSet(int index) + { + fixed (ecs_iter_t* ptr = &Value) + return ecs_field_is_set(ptr, index); + } + + public bool FieldIs(int index) + { + fixed (ecs_iter_t* ptr = &Value) { + var id = ecs_field_id(ptr, index); + var comp = World.LookupOrThrow(); + return id == comp.Entity.Value.Data; + } + } + + public override string ToString() + { + fixed (ecs_iter_t* ptr = &Value) + return ecs_iter_str(ptr).FlecsToStringAndFree()!; + } + + // IEnumerable implementation + public IEnumerator GetEnumerator() { while (Next()) yield return this; } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +public enum IteratorType +{ + Term, + Filter, + Query, + Rule, +} diff --git a/src/gaemstone.ECS/Observer.cs b/src/gaemstone.ECS/Observer.cs new file mode 100644 index 0000000..849ab42 --- /dev/null +++ b/src/gaemstone.ECS/Observer.cs @@ -0,0 +1,37 @@ +using System; +using System.Runtime.InteropServices; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public static class ObserverExtensions +{ + public static unsafe EntityRef InitObserver(this EntityRef entity, + Entity @event, FilterDesc filter, Action callback) + { + var world = entity.World; + using var alloc = TempAllocator.Use(); + var desc = new ecs_observer_desc_t { + entity = entity, + filter = filter.ToFlecs(alloc), + binding_ctx = (void*)CallbackContextHelper.Create((world, callback)), + binding_ctx_free = new() { Data = new() { Pointer = &FreeContext } }, + callback = new() { Data = new() { Pointer = &Callback } }, + }; + desc.events[0] = @event; + return new(world, new(ecs_observer_init(world, &desc))); + } + + [UnmanagedCallersOnly] + private static unsafe void Callback(ecs_iter_t* iter) + { + var (world, callback) = CallbackContextHelper + .Get<(World, Action)>((nint)iter->binding_ctx); + callback(new Iterator(world, null, *iter)); + } + + [UnmanagedCallersOnly] + private static unsafe void FreeContext(void* context) + => CallbackContextHelper.Free((nint)context); +} diff --git a/src/gaemstone.ECS/Query.cs b/src/gaemstone.ECS/Query.cs new file mode 100644 index 0000000..73de798 --- /dev/null +++ b/src/gaemstone.ECS/Query.cs @@ -0,0 +1,46 @@ +using System; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public unsafe sealed class Query + : IDisposable +{ + public World World { get; } + public ecs_query_t* Handle { get; } + + public Query(World world, QueryDesc desc) + { + using var alloc = TempAllocator.Use(); + var flecsDesc = desc.ToFlecs(alloc); + World = world; + Handle = ecs_query_init(world, &flecsDesc); + } + + public void Dispose() + => ecs_query_fini(this); + + public Iterator Iter() + => new(World, IteratorType.Query, ecs_query_iter(World, this)); + + public override string ToString() + => ecs_query_str(Handle).FlecsToStringAndFree()!; + + public static implicit operator ecs_query_t*(Query q) => q.Handle; +} + +public class QueryDesc : FilterDesc +{ + public QueryDesc(string expression) : base(expression) { } + public QueryDesc(params Term[] terms) : base(terms) { } + + public new unsafe ecs_query_desc_t ToFlecs(IAllocator allocator) + { + var desc = new ecs_query_desc_t { + filter = base.ToFlecs(allocator), + // TODO: Implement more Query features. + }; + return desc; + } +} diff --git a/src/gaemstone.ECS/Rule.cs b/src/gaemstone.ECS/Rule.cs new file mode 100644 index 0000000..1013723 --- /dev/null +++ b/src/gaemstone.ECS/Rule.cs @@ -0,0 +1,31 @@ +using System; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public unsafe sealed class Rule + : IDisposable +{ + public World World { get; } + public ecs_rule_t* Handle { get; } + + public Rule(World world, FilterDesc desc) + { + using var alloc = TempAllocator.Use(); + var flecsDesc = desc.ToFlecs(alloc); + World = world; + Handle = ecs_rule_init(world, &flecsDesc); + } + + public void Dispose() + => ecs_rule_fini(this); + + public Iterator Iter() + => new(World, IteratorType.Rule, ecs_rule_iter(World, this)); + + public override string ToString() + => ecs_rule_str(Handle).FlecsToStringAndFree()!; + + public static implicit operator ecs_rule_t*(Rule q) => q.Handle; +} diff --git a/src/gaemstone.ECS/System.cs b/src/gaemstone.ECS/System.cs new file mode 100644 index 0000000..4497ba1 --- /dev/null +++ b/src/gaemstone.ECS/System.cs @@ -0,0 +1,56 @@ +using System; +using System.Runtime.InteropServices; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public static class SystemExtensions +{ + public static unsafe EntityRef InitSystem(this EntityRef entity, + Entity phase, QueryDesc query, Action callback) + { + var world = entity.World; + entity.Add(world.DependsOn, phase); + using var alloc = TempAllocator.Use(); + var desc = new ecs_system_desc_t { + entity = entity, + query = query.ToFlecs(alloc), + binding_ctx = (void*)CallbackContextHelper.Create((world, callback)), + binding_ctx_free = new() { Data = new() { Pointer = &FreeContext } }, + callback = new() { Data = new() { Pointer = &Callback } }, + }; + return new(world, new(ecs_system_init(world, &desc))); + } + + [UnmanagedCallersOnly] + private static unsafe void Callback(ecs_iter_t* iter) + { + var (world, callback) = CallbackContextHelper + .Get<(World, Action)>((nint)iter->binding_ctx); + callback(new Iterator(world, null, *iter)); + } + + // [UnmanagedCallersOnly] + // private static unsafe void Run(ecs_iter_t* flecsIter) + // { + // var (world, callback) = CallbackContextHelper + // .Get<(World, Action)>((nint)flecsIter->binding_ctx); + + // // This is what flecs does, so I guess we'll do it too! + // var type = (&flecsIter->next == (delegate*)&ecs_query_next) + // ? IteratorType.Query : (IteratorType?)null; + // using var iter = new Iterator(world, type, *flecsIter); + + // If the method is marked with [Source], set the $This variable. + // if (Method.Get()?.Type is Type sourceType) + // iter.SetThis(World.LookupOrThrow(sourceType)); + + // if (flecsIter->field_count == 0) callback(iter); + // else while (iter.Next()) callback(iter); + // } + + [UnmanagedCallersOnly] + private static unsafe void FreeContext(void* context) + => CallbackContextHelper.Free((nint)context); +} diff --git a/src/gaemstone.ECS/Term.cs b/src/gaemstone.ECS/Term.cs new file mode 100644 index 0000000..feec33a --- /dev/null +++ b/src/gaemstone.ECS/Term.cs @@ -0,0 +1,116 @@ +using System; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public class Term +{ + public Identifier Id { get; set; } + public TermId? Source { get; set; } + public TermId? Relation { get; set; } + public TermId? Target { get; set; } + public TermInOutKind InOut { get; set; } + public TermOperKind Oper { get; set; } + public IdentifierFlags Flags { get; set; } + + public Term() { } + public Term(Identifier id) => Id = id; + public Term(TermId relation, TermId target) + { Relation = relation; Target = target; } + + public static implicit operator Term(EntityRef entity) => new(entity); + public static implicit operator Term(Entity entity) => new(entity); + public static implicit operator Term(IdentifierRef id) => new(id); + public static implicit operator Term(Identifier id) => new(id); + + public Term None { get { InOut = TermInOutKind.None; return this; } } + public Term In { get { InOut = TermInOutKind.In; return this; } } + public Term Out { get { InOut = TermInOutKind.Out; return this; } } + + public Term Or { get { Oper = TermOperKind.Or; return this; } } + public Term Not { get { Oper = TermOperKind.Not; return this; } } + public Term Optional { get { Oper = TermOperKind.Optional; return this; } } + + public ecs_term_t ToFlecs(IAllocator allocator) => new() { + id = Id, + src = Source?.ToFlecs(allocator) ?? default, + first = Relation?.ToFlecs(allocator) ?? default, + second = Target?.ToFlecs(allocator) ?? default, + inout = (ecs_inout_kind_t)InOut, + oper = (ecs_oper_kind_t)Oper, + id_flags = (ecs_id_t)(ulong)Flags, + }; +} + +public enum TermInOutKind +{ + Default = ecs_inout_kind_t.EcsInOutDefault, + None = ecs_inout_kind_t.EcsInOutNone, + InOut = ecs_inout_kind_t.EcsInOut, + In = ecs_inout_kind_t.EcsIn, + Out = ecs_inout_kind_t.EcsOut, +} + +public enum TermOperKind +{ + And = ecs_oper_kind_t.EcsAnd, + Or = ecs_oper_kind_t.EcsOr, + Not = ecs_oper_kind_t.EcsNot, + Optional = ecs_oper_kind_t.EcsOptional, + AndFrom = ecs_oper_kind_t.EcsAndFrom, + OrFrom = ecs_oper_kind_t.EcsOrFrom, + NotFrom = ecs_oper_kind_t.EcsNotFrom, +} + +public class TermId +{ + public static TermId This { get; } = new("$This"); + + public Entity Id { get; } + public string? Name { get; } + public Entity Traverse { get; set; } + public TermTraversalFlags Flags { get; set; } + + public TermId(Entity id) + => Id = id; + public TermId(string name) + { + if (name[0] == '$') { + Name = name[1..]; + Flags = TermTraversalFlags.IsVariable; + } else Name = name; + } + + public static implicit operator TermId(EntityRef entity) => new(entity); + public static implicit operator TermId(Entity entity) => new(entity); + public static implicit operator TermId(string name) => new(name); + + public ecs_term_id_t ToFlecs(IAllocator allocator) => new() { + id = Id, + name = allocator.AllocateCString(Name), + trav = Traverse, + flags = (ecs_flags32_t)(uint)Flags + }; +} + +[Flags] +public enum TermTraversalFlags : uint +{ + /// Match on self. + Self = EcsSelf, + /// Match by traversing upwards. + Up = EcsUp, + /// Match by traversing downwards (derived, cannot be set). + Down = EcsDown, + /// Sort results breadth first. + Cascade = EcsCascade, + /// Short for up(ChildOf). + Parent = EcsParent, + /// Term id is a variable. + IsVariable = EcsIsVariable, + /// Term id is an entity. + IsEntity = EcsIsEntity, + /// Prevent observer from triggering on term. + Filter = EcsFilter, +} diff --git a/src/gaemstone.ECS/World+Lookup.cs b/src/gaemstone.ECS/World+Lookup.cs new file mode 100644 index 0000000..d83733e --- /dev/null +++ b/src/gaemstone.ECS/World+Lookup.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using gaemstone.Utility; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public unsafe partial class World +{ + private readonly Dictionary _lookupByType = new(); + + public void AddLookupByType(Type type, Entity entity) + { + // If an existing lookup already exists with the same entity, don't throw an exception. + if (_lookupByType.TryGetValue(type, out var existing) && (existing == entity)) return; + _lookupByType.Add(type, entity); + } + public void RemoveLookupByType(Type type) + { if (!_lookupByType.Remove(type)) throw new InvalidOperationException( + $"Lookup for {type} does not exist"); } + + + private EntityRef? CreateOrNull(Entity entity) + => EntityRef.CreateOrNull(this, entity); + + public EntityRef? Lookup() + => Lookup(typeof(T)); + public EntityRef? Lookup(Type type) + => Lookup(_lookupByType.GetValueOrDefault(type)); + + public EntityRef? Lookup(Entity value) + => CreateOrNull(new(ecs_get_alive(this, value))); + + public EntityRef? Lookup(EntityPath path) + => Lookup(default, path); + public EntityRef? Lookup(Entity parent, EntityPath path) + => CreateOrNull(EntityPath.Lookup(this, parent, path)); + public EntityRef? LookupSymbol(string symbol) + { + using var alloc = TempAllocator.Use(); + return CreateOrNull(new(ecs_lookup_symbol(this, alloc.AllocateCString(symbol), false))); + } + + public EntityRef LookupOrThrow() => LookupOrThrow(typeof(T)); + public EntityRef LookupOrThrow(Type type) => Lookup(type) + ?? throw new EntityNotFoundException($"Entity of type {type} not found"); + public EntityRef LookupOrThrow(Entity entity) => Lookup(entity) + ?? throw new EntityNotFoundException($"Entity {entity} not alive"); + public EntityRef LookupOrThrow(EntityPath path) => Lookup(default, path) + ?? throw new EntityNotFoundException($"Entity '{path}' not found"); + public EntityRef LookupOrThrow(Entity parent, EntityPath path) => Lookup(parent, path) + ?? throw new EntityNotFoundException($"Child entity of {parent} '{path}' not found"); + public EntityRef LookupSymbolOrThrow(string symbol) => LookupSymbol(symbol) + ?? throw new EntityNotFoundException($"Entity with symbol '{symbol}' not found"); + + + public class EntityNotFoundException : Exception + { public EntityNotFoundException(string message) : base(message) { } } +} + +public static class LookupExtensions +{ + public static EntityRef CreateLookup(this EntityRef entity) + => entity.CreateLookup(typeof(T)); + public static EntityRef CreateLookup(this EntityRef entity, Type type) + { entity.World.AddLookupByType(type, entity); return entity; } +} diff --git a/src/gaemstone.ECS/World.cs b/src/gaemstone.ECS/World.cs new file mode 100644 index 0000000..db8655f --- /dev/null +++ b/src/gaemstone.ECS/World.cs @@ -0,0 +1,40 @@ +using System; +using static flecs_hub.flecs; + +namespace gaemstone.ECS; + +public unsafe partial class World +{ + public ecs_world_t* Handle { get; } + + // Flecs built-ins that are important to internals. + internal EntityRef ChildOf { get; } + internal EntityRef Disabled { get; } + internal EntityRef DependsOn { get; } + + public bool IsDeferred => ecs_is_deferred(this); + public bool IsQuitRequested => ecs_should_quit(this); + + public World(params string[] args) + { + Handle = ecs_init_w_args(args.Length, null); + + ChildOf = LookupOrThrow("/flecs/core/ChildOf"); + Disabled = LookupOrThrow("/flecs/core/Disabled"); + DependsOn = LookupOrThrow("/flecs/core/DependsOn"); + } + + public void Dispose() + => ecs_fini(this); + + public EntityBuilder New(EntityPath? path = null) + => new(this, path); + + public bool Progress(TimeSpan delta) + => ecs_progress(this, (float)delta.TotalSeconds); + + public void Quit() + => ecs_quit(this); + + public static implicit operator ecs_world_t*(World w) => w.Handle; +} diff --git a/src/gaemstone.Utility/Allocators.cs b/src/gaemstone.Utility/Allocators.cs new file mode 100644 index 0000000..e8342b5 --- /dev/null +++ b/src/gaemstone.Utility/Allocators.cs @@ -0,0 +1,170 @@ +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using static flecs_hub.flecs.Runtime; + +namespace gaemstone.Utility; + +public interface IAllocator +{ + nint Allocate(int byteCount); + void Free(nint pointer); +} + +public unsafe static class AllocatorExtensions +{ + public static Span Allocate(this IAllocator allocator, int count) where T : unmanaged + => new((void*)allocator.Allocate(sizeof(T) * count), count); + public static void Free(this IAllocator allocator, Span span) where T : unmanaged + => allocator.Free((nint)Unsafe.AsPointer(ref span[0])); + + public static Span AllocateCopy(this IAllocator allocator, ReadOnlySpan orig) where T : unmanaged + { var copy = allocator.Allocate(orig.Length); orig.CopyTo(copy); return copy; } + + public static ref T Allocate(this IAllocator allocator) where T : unmanaged + => ref Unsafe.AsRef((void*)allocator.Allocate(sizeof(T))); + public static void Free(this IAllocator allocator, ref T value) where T : unmanaged + => allocator.Free((nint)Unsafe.AsPointer(ref value)); + + public static CString AllocateCString(this IAllocator allocator, string? value) + { + if (value == null) return default; + var bytes = Encoding.UTF8.GetByteCount(value); + var span = allocator.Allocate(bytes + 1); + Encoding.UTF8.GetBytes(value, span); + span[^1] = 0; // In case the allocated span is not cleared. + return new((nint)Unsafe.AsPointer(ref span[0])); + } + + public static CString AllocateCString(this IAllocator allocator, ReadOnlySpan utf8) + { + var copy = allocator.Allocate(utf8.Length + 1); + utf8.CopyTo(copy); + copy[^1] = 0; // In case the allocated span is not cleared. + return new((nint)Unsafe.AsPointer(ref copy[0])); + } +} + +public static class TempAllocator +{ + public const int Capacity = 1024 * 1024; // 1 MB + private static readonly ThreadLocal _allocator + = new(() => new(Capacity)); + + public static ResetOnDispose Use() + { + var allocator = _allocator.Value!; + return new(allocator, allocator.Used); + } + + public sealed class ResetOnDispose + : IAllocator + , IDisposable + { + private readonly ArenaAllocator _allocator; + private readonly int _start; + + public ResetOnDispose(ArenaAllocator allocator, int start) + { _allocator = allocator; _start = start; } + // TODO: Print warning in finalizer if Dispose wasn't called manually. + + // IAllocator implementation + public nint Allocate(int byteCount) => _allocator.Allocate(byteCount); + public void Free(nint pointer) { /* Do nothing. */ } + + // IDisposable implementation + public void Dispose() => _allocator.Reset(_start); + } +} + +public class GlobalHeapAllocator + : IAllocator +{ + public static GlobalHeapAllocator Instance { get; } = new(); + + public nint Allocate(int byteCount) + => Marshal.AllocHGlobal(byteCount); + public void Free(nint pointer) + => Marshal.FreeHGlobal(pointer); +} + +public sealed class ArenaAllocator + : IAllocator + , IDisposable +{ + private nint _buffer; + public int Capacity { get; private set; } + public int Used { get; private set; } = 0; + + public ArenaAllocator(int capacity) + { + if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); + _buffer = Marshal.AllocHGlobal(capacity); + Capacity = capacity; + } + + public void Dispose() + { + Marshal.FreeHGlobal(_buffer); + _buffer = default; + Capacity = 0; + } + + public nint Allocate(int byteCount) + { + if (_buffer == default) throw new ObjectDisposedException(nameof(ArenaAllocator)); + if (Used + byteCount > Capacity) throw new InvalidOperationException( + $"Cannot allocate more than {Capacity} bytes with this {nameof(ArenaAllocator)}"); + var ptr = _buffer + Used; + Used += byteCount; + return ptr; + } + + public void Free(nint pointer) + { /* Do nothing. */ } + + public unsafe void Reset(int start = 0) + { + if (start > Used) throw new ArgumentOutOfRangeException(nameof(start)); + new Span((void*)(_buffer + start), Used - start).Clear(); + Used = start; + } +} + +public sealed class RingAllocator + : IAllocator + , IDisposable +{ + private nint _buffer; + private int _current = 0; + public int Capacity { get; private set; } + + public RingAllocator(int capacity) + { + if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); + _buffer = Marshal.AllocHGlobal(capacity); + Capacity = capacity; + } + + public void Dispose() + { + Marshal.FreeHGlobal(_buffer); + _buffer = default; + Capacity = 0; + } + + public nint Allocate(int byteCount) + { + if (_buffer == default) throw new ObjectDisposedException(nameof(RingAllocator)); + if (byteCount > Capacity) throw new ArgumentOutOfRangeException(nameof(byteCount)); + if (_current + byteCount > Capacity) _current = 0; + var ptr = _buffer + _current; + _current += byteCount; + return ptr; + } + + public void Free(nint pointer) + { /* IGNORE */ } +} diff --git a/src/gaemstone.Utility/CStringExtensions.cs b/src/gaemstone.Utility/CStringExtensions.cs new file mode 100644 index 0000000..0cc756b --- /dev/null +++ b/src/gaemstone.Utility/CStringExtensions.cs @@ -0,0 +1,34 @@ +using System; +using System.Runtime.InteropServices; +using static flecs_hub.flecs; +using static flecs_hub.flecs.Runtime; + +namespace gaemstone.Utility; + +public unsafe static class CStringExtensions +{ + public static CString Empty { get; } = (CString)""; + public static CString ETX { get; } = (CString)"\x3"; // TODO: Temporary, until flecs supports Empty. + + public static unsafe byte[]? FlecsToBytes(this CString str) + { + if (str.IsNull) return null; + var pointer = (byte*)(nint)str; + // Find length of the string by locating the NUL character. + var length = 0; while (true) if (pointer[length++] == 0) break; + // Create span over the region, NUL included, copy it into an array. + return new Span(pointer, length).ToArray(); + } + + public static byte[]? FlecsToBytesAndFree(this CString str) + { var result = str.FlecsToBytes(); str.FlecsFree(); return result; } + + public static string? FlecsToString(this CString str) + => Marshal.PtrToStringUTF8(str); + + public static string? FlecsToStringAndFree(this CString str) + { var result = str.FlecsToString(); str.FlecsFree(); return result; } + + public static void FlecsFree(this CString str) + { if (!str.IsNull) ecs_os_get_api().free_.Data.Pointer((void*)(nint)str); } +} diff --git a/src/gaemstone.Utility/CallbackContextHelper.cs b/src/gaemstone.Utility/CallbackContextHelper.cs new file mode 100644 index 0000000..d7c4939 --- /dev/null +++ b/src/gaemstone.Utility/CallbackContextHelper.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace gaemstone.Utility; + +public static class CallbackContextHelper +{ + private static readonly Dictionary _contexts = new(); + private static nint _counter = 0; + + public static nint Create(T context) where T : notnull + { + lock (_contexts) { + var id = _counter++; + _contexts.Add(id, context); + return id; + } + } + + public static T Get(nint id) + { + lock (_contexts) + return (T)_contexts[id]; + } + + public static void Free(nint id) + { + lock (_contexts) + _contexts.Remove(id); + } + +} diff --git a/src/gaemstone.Utility/SpanExtensions.cs b/src/gaemstone.Utility/SpanExtensions.cs new file mode 100644 index 0000000..3407363 --- /dev/null +++ b/src/gaemstone.Utility/SpanExtensions.cs @@ -0,0 +1,41 @@ +using System; + +namespace gaemstone.Utility; + +public static class SpanExtensions +{ + public static T? GetOrNull(this Span span, int index) + where T : struct => GetOrNull((ReadOnlySpan)span, index); + public static T? GetOrNull(this ReadOnlySpan span, int index) + where T : struct => (index >= 0 && index < span.Length) ? span[index] : null; + + + public static ReadOnlySpanSplitEnumerator Split(this ReadOnlySpan span, T splitOn) + where T : IEquatable => new(span, splitOn); + + public ref struct ReadOnlySpanSplitEnumerator + where T : IEquatable + { + private readonly ReadOnlySpan _span; + private readonly T _splitOn; + private int _index = -1; + private int _end = -1; + + public ReadOnlySpanSplitEnumerator(ReadOnlySpan span, T splitOn) + { _span = span; _splitOn = splitOn; } + + public ReadOnlySpanSplitEnumerator GetEnumerator() => this; + + public ReadOnlySpan Current + => (_end >= 0) && (_end <= _span.Length) + ? _span[_index.._end] : throw new InvalidOperationException(); + + public bool MoveNext() + { + if (_end == _span.Length) return false; + _end++; _index = _end; + while (_end < _span.Length && !_span[_end].Equals(_splitOn)) _end++; + return true; + } + } +} diff --git a/src/gaemstone.Utility/SpanToRef.cs b/src/gaemstone.Utility/SpanToRef.cs new file mode 100644 index 0000000..b9f169b --- /dev/null +++ b/src/gaemstone.Utility/SpanToRef.cs @@ -0,0 +1,19 @@ +using System; +using System.Runtime.InteropServices; + +namespace gaemstone.Utility; + +public readonly ref struct SpanToRef + where T : class +{ + private readonly Span _span; + + public int Length => _span.Length; + public T? this[int index] => (_span[index] != 0) + ? (T)((GCHandle)_span[index]).Target! : null; + + internal SpanToRef(Span span) => _span = span; + + public void Clear() => _span.Clear(); + public void CopyTo(SpanToRef dest) => _span.CopyTo(dest._span); +}