From f29954079750f6e1b562e9fa8f96ebd6b1a1fe8e Mon Sep 17 00:00:00 2001 From: mvvasilev Date: Fri, 27 Jun 2025 09:45:55 +0300 Subject: [PATCH] Finish ECS ( I hope ) --- go.mod | 4 + go.sum | 9 + internal/ecs/ecs.go | 58 ++++--- internal/ecs/ecs_test.go | 155 ++++++++++++++++++ internal/ecs/error.go | 6 - .../ecs/log/lastmud-2025-06-27_09-01-07.log | 0 .../ecs/log/lastmud-2025-06-27_09-01-17.log | 0 .../ecs/log/lastmud-2025-06-27_09-05-00.log | 0 .../ecs/log/lastmud-2025-06-27_09-05-02.log | 0 .../ecs/log/lastmud-2025-06-27_09-05-04.log | 0 .../ecs/log/lastmud-2025-06-27_09-10-43.log | 0 .../ecs/log/lastmud-2025-06-27_09-34-39.log | 0 internal/game/game.go | 2 + 13 files changed, 204 insertions(+), 30 deletions(-) create mode 100644 internal/ecs/ecs_test.go create mode 100644 internal/ecs/log/lastmud-2025-06-27_09-01-07.log create mode 100644 internal/ecs/log/lastmud-2025-06-27_09-01-17.log create mode 100644 internal/ecs/log/lastmud-2025-06-27_09-05-00.log create mode 100644 internal/ecs/log/lastmud-2025-06-27_09-05-02.log create mode 100644 internal/ecs/log/lastmud-2025-06-27_09-05-04.log create mode 100644 internal/ecs/log/lastmud-2025-06-27_09-10-43.log create mode 100644 internal/ecs/log/lastmud-2025-06-27_09-34-39.log diff --git a/go.mod b/go.mod index c8d68f6..c8a93a6 100644 --- a/go.mod +++ b/go.mod @@ -8,5 +8,9 @@ require ( ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect golang.org/x/sys v0.33.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 26e9566..8c4aeaf 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,17 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/ecs/ecs.go b/internal/ecs/ecs.go index 63816e7..b9ba344 100644 --- a/internal/ecs/ecs.go +++ b/internal/ecs/ecs.go @@ -37,27 +37,27 @@ type Component interface { Type() ComponentType } -type ComponentStorage[T Component] struct { +type ComponentStorage struct { forType ComponentType - storage map[Entity]T + storage map[Entity]Component } -func CreateComponentStorage[T Component](forType ComponentType) *ComponentStorage[T] { - return &ComponentStorage[T]{ +func CreateComponentStorage(forType ComponentType) *ComponentStorage { + return &ComponentStorage{ forType: forType, - storage: map[Entity]T{}, + storage: map[Entity]Component{}, } } -func (cs *ComponentStorage[T]) ComponentType() ComponentType { +func (cs *ComponentStorage) ComponentType() ComponentType { return cs.forType } -func (cs *ComponentStorage[T]) Entities() iter.Seq[Entity] { +func (cs *ComponentStorage) Entities() iter.Seq[Entity] { return maps.Keys(cs.storage) } -func (cs *ComponentStorage[T]) Query(query func(comp T) bool) iter.Seq[Entity] { +func queryComponents(cs *ComponentStorage, query func(comp Component) bool) iter.Seq[Entity] { return func(yield func(Entity) bool) { for k, v := range cs.storage { if !query(v) { @@ -71,20 +71,20 @@ func (cs *ComponentStorage[T]) Query(query func(comp T) bool) iter.Seq[Entity] { } } -func (cs *ComponentStorage[T]) Set(e Entity, component T) { +func (cs *ComponentStorage) Set(e Entity, component Component) { cs.storage[e] = component } -func (cs *ComponentStorage[T]) Get(e Entity) (component T, ok bool) { +func (cs *ComponentStorage) Get(e Entity) (component Component, ok bool) { component, ok = cs.storage[e] return } -func (cs *ComponentStorage[T]) Delete(e Entity) { +func (cs *ComponentStorage) Delete(e Entity) { delete(cs.storage, e) } -func (cs *ComponentStorage[T]) All() map[Entity]T { +func (cs *ComponentStorage) All() map[Entity]Component { return cs.storage } @@ -116,14 +116,14 @@ func (s *System) Execute(world *World, delta time.Duration) { type World struct { systems []*System - componentsByType map[ComponentType]any + componentsByType map[ComponentType]*ComponentStorage resources map[Resource]any } func CreateWorld() (world *World) { world = &World{ systems: []*System{}, - componentsByType: map[ComponentType]any{}, + componentsByType: map[ComponentType]*ComponentStorage{}, resources: map[Resource]any{}, } @@ -138,9 +138,7 @@ func (w *World) Tick(delta time.Duration) { func DeleteEntity(world *World, entity Entity) { for _, s := range world.componentsByType { - storage := s.(*ComponentStorage[Component]) - - storage.Delete(entity) + s.Delete(entity) } } @@ -180,13 +178,13 @@ func registerComponent[T Component](world *World, compType ComponentType) { return } - world.componentsByType[compType] = CreateComponentStorage[T](compType) + world.componentsByType[compType] = CreateComponentStorage(compType) } func SetComponent[T Component](world *World, entity Entity, component T) { registerComponent[T](world, component.Type()) - compStorage := world.componentsByType[component.Type()].(*ComponentStorage[T]) + compStorage := world.componentsByType[component.Type()] compStorage.Set(entity, component) } @@ -194,7 +192,10 @@ func SetComponent[T Component](world *World, entity Entity, component T) { func GetComponent[T Component](world *World, entity Entity) (component T, exists bool) { storage := GetComponentStorage[T](world) - return storage.Get(entity) + val, exists := storage.Get(entity) + casted, castSuccess := val.(T) + + return casted, (exists && castSuccess) } func DeleteComponent[T Component](world *World, entity Entity) { @@ -203,14 +204,14 @@ func DeleteComponent[T Component](world *World, entity Entity) { storage.Delete(entity) } -func GetComponentStorage[T Component](world *World) (compStorage *ComponentStorage[T]) { +func GetComponentStorage[T Component](world *World) (compStorage *ComponentStorage) { var zero T compType := zero.Type() registerComponent[T](world, compType) - return world.componentsByType[compType].(*ComponentStorage[T]) + return world.componentsByType[compType] } func IterateEntitiesWithComponent[T Component](world *World) iter.Seq[Entity] { @@ -222,7 +223,16 @@ func IterateEntitiesWithComponent[T Component](world *World) iter.Seq[Entity] { func QueryEntitiesWithComponent[T Component](world *World, query func(comp T) bool) iter.Seq[Entity] { storage := GetComponentStorage[T](world) - return storage.Query(query) + return queryComponents(storage, func(comp Component) bool { + val, ok := comp.(T) + + // Cast unsuccessful, assume failure + if !ok { + return false + } + + return query(val) + }) } func FindEntitiesWithComponents(world *World, componentTypes ...ComponentType) (entities []Entity) { @@ -236,7 +246,7 @@ func FindEntitiesWithComponents(world *World, componentTypes ...ComponentType) ( return } - storage, ok := world.componentsByType[compType].(*ComponentStorage[Component]) + storage, ok := world.componentsByType[compType] // If we can't find the storage for this component, then it hasn't been used yet. // Therefore, no entity could have all components requested. Return empty. diff --git a/internal/ecs/ecs_test.go b/internal/ecs/ecs_test.go new file mode 100644 index 0000000..7115309 --- /dev/null +++ b/internal/ecs/ecs_test.go @@ -0,0 +1,155 @@ +package ecs + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +type TestComponent struct { + Value string +} + +func (tc TestComponent) Type() ComponentType { + return 1 +} + +func TestEntityCreation(t *testing.T) { + u := uuid.New() + entity := CreateEntity(u) + assert.Equal(t, u, entity.AsUUID()) + + newEntity := NewEntity() + assert.NotEqual(t, uuid.Nil, newEntity.AsUUID()) + + nilEntity := NilEntity() + assert.Equal(t, uuid.Nil, nilEntity.AsUUID()) +} + +func TestComponentStorage(t *testing.T) { + cs := CreateComponentStorage(1) + entity := NewEntity() + comp := TestComponent{"hello"} + cs.Set(entity, comp) + + ret, ok := cs.Get(entity) + assert.True(t, ok) + assert.Equal(t, comp, ret) + + cs.Delete(entity) + _, ok = cs.Get(entity) + assert.False(t, ok) +} + +func TestSystemExecution(t *testing.T) { + called := false + s := CreateSystem("test", 0, func(world *World, delta time.Duration) error { + called = true + return nil + }) + + world := CreateWorld() + RegisterSystem(world, s) + world.Tick(time.Millisecond) + + assert.True(t, called) +} + +func TestWorldComponentLifecycle(t *testing.T) { + world := CreateWorld() + entity := NewEntity() + comp := TestComponent{"value"} + + SetComponent(world, entity, comp) + ret, ok := GetComponent[TestComponent](world, entity) + assert.True(t, ok) + assert.Equal(t, comp, ret) + + DeleteComponent[TestComponent](world, entity) + _, ok = GetComponent[TestComponent](world, entity) + assert.False(t, ok) +} + +func TestWorldResourceManagement(t *testing.T) { + world := CreateWorld() + resourceKey := Resource("test_resource") + value := "some data" + + SetResource(world, resourceKey, value) + ret, err := GetResource[string](world, resourceKey) + assert.NoError(t, err) + assert.Equal(t, value, ret) + + RemoveResource(world, resourceKey) + _, err = GetResource[string](world, resourceKey) + assert.Error(t, err) +} + +func TestEntityDeletion(t *testing.T) { + world := CreateWorld() + entity := NewEntity() + comp := TestComponent{"remove me"} + SetComponent(world, entity, comp) + + DeleteEntity(world, entity) + _, ok := GetComponent[TestComponent](world, entity) + assert.False(t, ok) +} + +func TestIterateEntitiesWithComponent(t *testing.T) { + world := CreateWorld() + entities := []Entity{ + NewEntity(), + NewEntity(), + NewEntity(), + } + for _, e := range entities { + SetComponent(world, e, TestComponent{"iter"}) + } + + count := 0 + for range IterateEntitiesWithComponent[TestComponent](world) { + count++ + } + + assert.Equal(t, len(entities), count) +} + +func TestQueryEntitiesWithComponent(t *testing.T) { + world := CreateWorld() + match := NewEntity() + nonMatch := NewEntity() + SetComponent(world, match, TestComponent{"match"}) + SetComponent(world, nonMatch, TestComponent{"skip"}) + + results := []Entity{} + + for e := range QueryEntitiesWithComponent(world, func(c TestComponent) bool { + return c.Value == "match" + }) { + results = append(results, e) + } + + assert.Len(t, results, 1) + assert.Equal(t, match, results[0]) +} + +type SecondComponent struct{ Flag bool } + +func (SecondComponent) Type() ComponentType { return 2 } + +func TestFindEntitiesWithComponents(t *testing.T) { + world := CreateWorld() + entity1 := NewEntity() + entity2 := NewEntity() + + SetComponent(world, entity1, TestComponent{"a"}) + SetComponent(world, entity1, SecondComponent{true}) + SetComponent(world, entity2, TestComponent{"b"}) + + results := FindEntitiesWithComponents(world, 1, 2) + assert.Len(t, results, 1) + assert.Equal(t, entity1, results[0]) +} diff --git a/internal/ecs/error.go b/internal/ecs/error.go index b1b6a26..c417b61 100644 --- a/internal/ecs/error.go +++ b/internal/ecs/error.go @@ -12,12 +12,6 @@ func newECSError(v ...any) *ecsError { } } -func newFormattedECSError(format string, v ...any) *ecsError { - return &ecsError{ - err: fmt.Sprintf(format, v...), - } -} - func (err *ecsError) Error() string { return err.err } diff --git a/internal/ecs/log/lastmud-2025-06-27_09-01-07.log b/internal/ecs/log/lastmud-2025-06-27_09-01-07.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/ecs/log/lastmud-2025-06-27_09-01-17.log b/internal/ecs/log/lastmud-2025-06-27_09-01-17.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/ecs/log/lastmud-2025-06-27_09-05-00.log b/internal/ecs/log/lastmud-2025-06-27_09-05-00.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/ecs/log/lastmud-2025-06-27_09-05-02.log b/internal/ecs/log/lastmud-2025-06-27_09-05-02.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/ecs/log/lastmud-2025-06-27_09-05-04.log b/internal/ecs/log/lastmud-2025-06-27_09-05-04.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/ecs/log/lastmud-2025-06-27_09-10-43.log b/internal/ecs/log/lastmud-2025-06-27_09-10-43.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/ecs/log/lastmud-2025-06-27_09-34-39.log b/internal/ecs/log/lastmud-2025-06-27_09-34-39.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/game/game.go b/internal/game/game.go index 1cfa8cf..8174e02 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -129,6 +129,8 @@ func (game *LastMUDGame) consumeOutputs() { contents: contents.Contents, }) } + + ecs.DeleteEntities(game.world.World, entities...) } func (game *LastMUDGame) shutdown() {