ECS refactor

This commit is contained in:
Miroslav Vasilev 2025-06-25 20:14:07 +03:00
parent 87f5c2f842
commit b2212a279c
16 changed files with 598 additions and 200 deletions

View file

@ -0,0 +1,29 @@
package components
import "code.haedhutner.dev/mvv/LastMUD/internal/game/ecs"
const (
TypeName ecs.ComponentType = iota
TypeDescription
TypePlayerState
TypeInRoom
TypeNeighbors
TypeIsRoom
TypeIsPlayer
)
type NameComponent struct {
Name string
}
func (c NameComponent) Type() ecs.ComponentType {
return TypeName
}
type DescriptionComponent struct {
Description string
}
func (c DescriptionComponent) Type() ecs.ComponentType {
return TypeDescription
}

View file

@ -0,0 +1,35 @@
package components
import "code.haedhutner.dev/mvv/LastMUD/internal/game/ecs"
type PlayerState = byte
const (
PlayerStateJoining PlayerState = iota
PlayerStateLoggingIn
PlayerStateRegistering
PlayerStatePlaying
PlayerStateLeaving
)
type PlayerStateComponent struct {
State PlayerState
}
func (c PlayerStateComponent) Type() ecs.ComponentType {
return TypePlayerState
}
type InRoomComponent struct {
Room ecs.Entity
}
func (c InRoomComponent) Type() ecs.ComponentType {
return TypeInRoom
}
type IsPlayerComponent struct{}
func (c IsPlayerComponent) Type() ecs.ComponentType {
return TypeIsPlayer
}

View file

@ -0,0 +1,18 @@
package components
import "code.haedhutner.dev/mvv/LastMUD/internal/game/ecs"
type IsRoomComponent struct {
}
func (c IsRoomComponent) Type() ecs.ComponentType {
return TypeIsRoom
}
type NeighborsComponent struct {
North, South, East, West ecs.Entity
}
func (c NeighborsComponent) Type() ecs.ComponentType {
return TypeNeighbors
}

View file

@ -1,31 +0,0 @@
package ecs
type PlayerState = byte
const (
PlayerStateJoining PlayerState = iota
PlayerStateLoggingIn
PlayerStateRegistering
PlayerStatePlaying
PlayerStateLeaving
)
type PlayerStateComponent struct {
State PlayerState
}
type NameComponent struct {
Name string
}
type DescriptionComponent struct {
Description string
}
type InRoomComponent struct {
InRoom Entity
}
type NeighboringRoomsComponent struct {
North, South, East, West Entity
}

196
internal/game/ecs/ecs.go Normal file
View file

@ -0,0 +1,196 @@
package ecs
import (
"slices"
"time"
"code.haedhutner.dev/mvv/LastMUD/internal/logging"
"github.com/google/uuid"
)
type Entity uuid.UUID
func CreateEntity(uuid uuid.UUID) Entity {
return Entity(uuid)
}
func NewEntity() Entity {
return Entity(uuid.New())
}
func NilEntity() Entity {
return Entity(uuid.Nil)
}
func (e Entity) AsUUID() uuid.UUID {
return uuid.UUID(e)
}
type ComponentType int16
type Resource string
type Component interface {
Type() ComponentType
}
type ComponentStorage[T Component] struct {
forType ComponentType
storage map[Entity]T
}
func CreateComponentStorage[T Component](forType ComponentType) *ComponentStorage[T] {
return &ComponentStorage[T]{
forType: forType,
storage: map[Entity]T{},
}
}
func (cs *ComponentStorage[T]) ComponentType() ComponentType {
return cs.forType
}
func (cs *ComponentStorage[T]) Set(e Entity, component T) {
cs.storage[e] = component
}
func (cs *ComponentStorage[T]) Get(e Entity) (component T, ok bool) {
component, ok = cs.storage[e]
return
}
func (cs *ComponentStorage[T]) Delete(e Entity) {
delete(cs.storage, e)
}
func (cs *ComponentStorage[T]) All() map[Entity]T {
return cs.storage
}
type System struct {
name string
priority int
work func(world *World, delta time.Duration) (err error)
}
func CreateSystem(name string, priority int, work func(world *World, delta time.Duration) (err error)) *System {
return &System{
name: name,
priority: priority,
work: work,
}
}
func (s *System) Priority() int {
return s.priority
}
func (s *System) DoWork(world *World, delta time.Duration) {
err := s.work(world, delta)
if err != nil {
logging.Error("Error in system '", s.name, "': ", err.Error())
}
}
type World struct {
systems []*System
componentsByType map[ComponentType]any
componentsByEntity map[Entity]map[ComponentType]any
resources map[Resource]any
}
func CreateWorld() (world *World) {
world = &World{
systems: []*System{},
componentsByType: map[ComponentType]any{},
componentsByEntity: map[Entity]map[ComponentType]any{}, // TODO: Can't figure out use-case right now
resources: map[Resource]any{},
}
return
}
func (w *World) Tick(delta time.Duration) {
for _, s := range w.systems {
s.DoWork(w, delta)
}
}
func DeleteEntity(world *World, entity Entity) {
for _, s := range world.componentsByType {
storage, ok := s.(*ComponentStorage[Component])
if ok {
storage.Delete(entity)
}
}
}
func SetResource(world *World, r Resource, val any) {
world.resources[r] = val
}
func GetResource[T any](world *World, r Resource) (res T, err error) {
val, ok := world.resources[r]
if !ok {
err = newECSError("Resource '", r, "' not found.")
return
}
res, ok = val.(T)
if !ok {
err = newECSError("Incompatible type for resource '", r, "'")
}
return
}
func RemoveResource(world *World, r Resource) {
delete(world.resources, r)
}
func RegisterComponent[T Component](world *World, compType ComponentType) {
world.componentsByType[compType] = CreateComponentStorage[T](compType)
}
func SetComponent[T Component](world *World, entity Entity, component T) {
compStorage := world.componentsByType[component.Type()].(*ComponentStorage[T])
compStorage.Set(entity, component)
// if _, ok := world.componentsByEntity[entity]; !ok {
// world.componentsByEntity[entity] = map[ComponentType]any{}
// }
// world.componentsByEntity[entity][component.Type()] = component
}
func GetComponent[T Component](world *World, entity Entity) (component T, exists bool) {
storage := GetComponentStorage[T](world)
return storage.Get(entity)
}
func DeleteComponent[T Component](world *World, entity Entity) {
storage := GetComponentStorage[T](world)
storage.Delete(entity)
}
func GetComponentStorage[T Component](world *World) (compStorage *ComponentStorage[T]) {
var zero T
return world.componentsByType[zero.Type()].(*ComponentStorage[T])
}
func RegisterSystem(world *World, s *System) {
world.systems = append(world.systems, s)
slices.SortFunc(
world.systems,
func(a, b *System) int {
return a.priority - b.priority
},
)
}

View file

@ -1,19 +0,0 @@
package ecs
import "github.com/google/uuid"
type Entity uuid.UUID
type Room struct {
Entity
NameComponent
DescriptionComponent
NeighboringRoomsComponent
}
type Player struct {
Entity
PlayerStateComponent
NameComponent
InRoomComponent
}

View file

@ -0,0 +1,23 @@
package ecs
import "fmt"
type ecsError struct {
err string
}
func newECSError(v ...any) *ecsError {
return &ecsError{
err: fmt.Sprint(v...),
}
}
func newFormattedECSError(format string, v ...any) *ecsError {
return &ecsError{
err: fmt.Sprintf(format, v...),
}
}
func (err *ecsError) Error() string {
return err.err
}

View file

@ -1 +0,0 @@
package ecs

View file

@ -1,15 +0,0 @@
package ecs
type World struct {
Players []*Player
Rooms []*Room
DefaultRoom *Room
}
func CreateWorld() *World {
world := &World{
Players: []*Player{},
Rooms: []*Room{},
}
}

39
internal/game/entities.go Normal file
View file

@ -0,0 +1,39 @@
package game
import (
"code.haedhutner.dev/mvv/LastMUD/internal/game/components"
"code.haedhutner.dev/mvv/LastMUD/internal/game/ecs"
"github.com/google/uuid"
)
func CreatePlayer(world *ecs.World, id uuid.UUID, state components.PlayerState) (entity ecs.Entity, err error) {
entity = ecs.CreateEntity(id)
defaultRoom, err := ecs.GetResource[ecs.Entity](world, ResourceDefaultRoom)
if err != nil {
return
}
ecs.SetComponent(world, entity, components.PlayerStateComponent{State: state})
ecs.SetComponent(world, entity, components.NameComponent{Name: id.String()})
ecs.SetComponent(world, entity, components.InRoomComponent{Room: defaultRoom})
ecs.SetComponent(world, entity, components.IsPlayerComponent{})
return
}
func CreateRoom(
world *ecs.World,
name, description string,
north, south, east, west ecs.Entity,
) ecs.Entity {
entity := ecs.NewEntity()
ecs.SetComponent(world, entity, components.IsRoomComponent{})
ecs.SetComponent(world, entity, components.NameComponent{Name: name})
ecs.SetComponent(world, entity, components.DescriptionComponent{Description: description})
ecs.SetComponent(world, entity, components.NeighborsComponent{North: north, South: south, East: east, West: west})
return entity
}

View file

@ -4,6 +4,8 @@ import (
"time"
"code.haedhutner.dev/mvv/LastMUD/internal/game/command"
"code.haedhutner.dev/mvv/LastMUD/internal/game/components"
"code.haedhutner.dev/mvv/LastMUD/internal/game/ecs"
"code.haedhutner.dev/mvv/LastMUD/internal/logging"
"github.com/google/uuid"
)
@ -23,10 +25,14 @@ func (pje *PlayerJoinEvent) Type() EventType {
}
func (pje *PlayerJoinEvent) Handle(game *LastMUDGame, delta time.Duration) {
p := CreateJoiningPlayer(pje.connectionId)
game.world.AddPlayerToDefaultRoom(p)
game.enqeueOutput(game.CreateOutput(p.Identity(), []byte("Welcome to LastMUD!")))
game.enqeueOutput(game.CreateOutput(p.Identity(), []byte("Please enter your name:")))
p, err := CreatePlayer(game.world.World, pje.connectionId, components.PlayerStateJoining)
if err != nil {
logging.Error("Unabled to create player: ", err)
}
game.enqeueOutput(game.CreateOutput(p.AsUUID(), []byte("Welcome to LastMUD!")))
game.enqeueOutput(game.CreateOutput(p.AsUUID(), []byte("Please enter your name:")))
}
type PlayerLeaveEvent struct {
@ -44,7 +50,7 @@ func (ple *PlayerLeaveEvent) Type() EventType {
}
func (ple *PlayerLeaveEvent) Handle(game *LastMUDGame, delta time.Duration) {
game.world.RemovePlayerById(ple.connectionId)
ecs.DeleteEntity(game.world.World, ecs.CreateEntity(ple.connectionId))
}
type PlayerCommandEvent struct {
@ -72,8 +78,6 @@ func (pce *PlayerCommandEvent) Type() EventType {
}
func (pce *PlayerCommandEvent) Handle(game *LastMUDGame, delta time.Duration) {
player := game.world.FindPlayerById(pce.connectionId)
if player == nil {
logging.Error("Unable to handle player command from player with id", pce.connectionId, ": Player does not exist")
return
@ -82,7 +86,7 @@ func (pce *PlayerCommandEvent) Handle(game *LastMUDGame, delta time.Duration) {
event := pce.parseCommandIntoEvent(game, player)
}
func (pce *PlayerCommandEvent) parseCommandIntoEvent(game *LastMUDGame, player *Player) GameEvent {
func (pce *PlayerCommandEvent) parseCommandIntoEvent(game *LastMUDGame, player ecs.Entity) GameEvent {
switch pce.command.Command().Definition().Name() {
case SayCommand:
speech, err := pce.command.Command().Parameters()[0].AsString()

View file

@ -41,7 +41,7 @@ type LastMUDGame struct {
cmdRegistry *command.CommandRegistry
world *World
world *GameWorld
eventBus *EventBus
@ -54,7 +54,7 @@ func CreateGame(ctx context.Context, wg *sync.WaitGroup) (game *LastMUDGame) {
ctx: ctx,
eventBus: CreateEventBus(MaxEnqueuedGameEventsPerTick),
output: make(chan GameOutput, MaxEnqueuedOutputPerTick),
world: CreateWorld(),
world: CreateGameWorld(),
}
game.cmdRegistry = game.CreateGameCommandRegistry()

View file

@ -1,39 +1,39 @@
package game
import "github.com/google/uuid"
// import "github.com/google/uuid"
type Player struct {
id uuid.UUID
// type Player struct {
// id uuid.UUID
state PlayerState
// state PlayerState
currentRoom *Room
}
// currentRoom *Room
// }
func CreateJoiningPlayer(identity uuid.UUID) *Player {
return &Player{
id: identity,
state: PlayerStateJoining,
currentRoom: nil,
}
}
// func CreateJoiningPlayer(identity uuid.UUID) *Player {
// return &Player{
// id: identity,
// state: PlayerStateJoining,
// currentRoom: nil,
// }
// }
func CreatePlayer(identity uuid.UUID, state PlayerState, room *Room) *Player {
return &Player{
id: identity,
state: state,
currentRoom: room,
}
}
// func CreatePlayer(identity uuid.UUID, state PlayerState, room *Room) *Player {
// return &Player{
// id: identity,
// state: state,
// currentRoom: room,
// }
// }
func (p *Player) Identity() uuid.UUID {
return p.id
}
// func (p *Player) Identity() uuid.UUID {
// return p.id
// }
func (p *Player) SetRoom(r *Room) {
p.currentRoom = r
}
// func (p *Player) SetRoom(r *Room) {
// p.currentRoom = r
// }
func (p *Player) CurrentRoom() *Room {
return p.currentRoom
}
// func (p *Player) CurrentRoom() *Room {
// return p.currentRoom
// }

View file

@ -1,51 +1,51 @@
package game
import "github.com/google/uuid"
// import "github.com/google/uuid"
type RoomPlayer interface {
Identity() uuid.UUID
SetRoom(room *Room)
}
// type RoomPlayer interface {
// Identity() uuid.UUID
// SetRoom(room *Room)
// }
type Room struct {
world *World
// type Room struct {
// world *World
North *Room
South *Room
East *Room
West *Room
// North *Room
// South *Room
// East *Room
// West *Room
Name string
Description string
// Name string
// Description string
players map[uuid.UUID]RoomPlayer
}
// players map[uuid.UUID]RoomPlayer
// }
func CreateRoom(world *World, name, description string) *Room {
return &Room{
world: world,
Name: name,
Description: description,
players: map[uuid.UUID]RoomPlayer{},
}
}
// func CreateRoom(world *World, name, description string) *Room {
// return &Room{
// world: world,
// Name: name,
// Description: description,
// players: map[uuid.UUID]RoomPlayer{},
// }
// }
func (r *Room) PlayerJoinRoom(player RoomPlayer) (err error) {
r.players[player.Identity()] = player
// func (r *Room) PlayerJoinRoom(player RoomPlayer) (err error) {
// r.players[player.Identity()] = player
return
}
// return
// }
func (r *Room) PlayerLeaveRoom(player RoomPlayer) (err error) {
delete(r.players, player.Identity())
// func (r *Room) PlayerLeaveRoom(player RoomPlayer) (err error) {
// delete(r.players, player.Identity())
return
}
// return
// }
func (r *Room) Players() map[uuid.UUID]RoomPlayer {
return r.players
}
// func (r *Room) Players() map[uuid.UUID]RoomPlayer {
// return r.players
// }
func (r *Room) World() *World {
return r.world
}
// func (r *Room) World() *World {
// return r.world
// }

1
internal/game/systems.go Normal file
View file

@ -0,0 +1 @@
package game

View file

@ -1,80 +1,199 @@
package game
import "github.com/google/uuid"
import (
"code.haedhutner.dev/mvv/LastMUD/internal/game/components"
"code.haedhutner.dev/mvv/LastMUD/internal/game/ecs"
)
type World struct {
rooms []*Room
players map[uuid.UUID]*Player
defaultRoom *Room
const (
ResourceDefaultRoom ecs.Resource = "world:room:default"
)
type GameWorld struct {
*ecs.World
}
func CreateWorld() (world *World) {
world = &World{
players: map[uuid.UUID]*Player{},
func CreateGameWorld() (gw *GameWorld) {
gw = &GameWorld{
World: ecs.CreateWorld(),
}
forest := CreateRoom(world, "Forest", "A dense, misty forest stretches endlessly, its towering trees whispering secrets through rustling leaves. Sunbeams filter through the canopy, dappling the mossy ground with golden light.")
cabin := CreateRoom(world, "Wooden Cabin", "The cabins interior is cozy and rustic, with wooden beams overhead and a stone fireplace crackling warmly. A wool rug lies on creaky floorboards, and shelves brim with books, mugs, and old lanterns.")
lake := CreateRoom(world, "Ethermere Lake", "Ethermire Lake lies shrouded in mist, its dark, still waters reflecting a sky perpetually overcast. Whispers ride the wind, and strange lights flicker beneath the surface, never breaking it.")
graveyard := CreateRoom(world, "Graveyard", "An overgrown graveyard shrouded in fog, with cracked headstones and leaning statues. The wind sighs through dead trees, and unseen footsteps echo faintly among the mossy graves.")
chapel := CreateRoom(world, "Chapel of the Hollow Light", "This ruined chapel leans under ivy and age. Faint light filters through shattered stained glass, casting broken rainbows across dust-choked pews and a long-silent altar.")
forest := CreateRoom(
gw.World,
"Forest",
"A dense, misty forest stretches endlessly, its towering trees whispering secrets through rustling leaves. Sunbeams filter through the canopy, dappling the mossy ground with golden light.",
ecs.NilEntity(),
ecs.NilEntity(),
ecs.NilEntity(),
ecs.NilEntity(),
)
forest.North = cabin
forest.South = graveyard
forest.East = lake
forest.West = chapel
ecs.SetResource(gw.World, ResourceDefaultRoom, forest)
cabin.South = forest
cabin.West = chapel
cabin.East = lake
cabin := CreateRoom(
gw.World,
"Wooden Cabin",
"The cabins interior is cozy and rustic, with wooden beams overhead and a stone fireplace crackling warmly. A wool rug lies on creaky floorboards, and shelves brim with books, mugs, and old lanterns.",
ecs.NilEntity(),
ecs.NilEntity(),
ecs.NilEntity(),
ecs.NilEntity(),
)
chapel.North = cabin
chapel.South = graveyard
chapel.East = forest
lake := CreateRoom(
gw.World,
"Ethermere Lake",
"Ethermire Lake lies shrouded in mist, its dark, still waters reflecting a sky perpetually overcast. Whispers ride the wind, and strange lights flicker beneath the surface, never breaking it.",
ecs.NilEntity(),
ecs.NilEntity(),
ecs.NilEntity(),
ecs.NilEntity(),
)
lake.West = forest
lake.North = cabin
lake.South = graveyard
graveyard := CreateRoom(
gw.World,
"Graveyard",
"An overgrown graveyard shrouded in fog, with cracked headstones and leaning statues. The wind sighs through dead trees, and unseen footsteps echo faintly among the mossy graves.",
ecs.NilEntity(),
ecs.NilEntity(),
ecs.NilEntity(),
ecs.NilEntity(),
)
graveyard.North = forest
graveyard.West = chapel
graveyard.East = lake
chapel := CreateRoom(
gw.World,
"Chapel of the Hollow Light",
"This ruined chapel leans under ivy and age. Faint light filters through shattered stained glass, casting broken rainbows across dust-choked pews and a long-silent altar.",
ecs.NilEntity(),
ecs.NilEntity(),
ecs.NilEntity(),
ecs.NilEntity(),
)
world.rooms = []*Room{
forest,
cabin,
lake,
graveyard,
chapel,
}
ecs.SetComponent(gw.World, forest, components.NeighborsComponent{
North: cabin,
South: graveyard,
East: lake,
West: chapel,
})
world.defaultRoom = forest
ecs.SetComponent(gw.World, cabin, components.NeighborsComponent{
South: graveyard,
West: chapel,
East: lake,
})
ecs.SetComponent(gw.World, chapel, components.NeighborsComponent{
North: cabin,
South: graveyard,
East: forest,
})
ecs.SetComponent(gw.World, lake, components.NeighborsComponent{
West: forest,
North: cabin,
South: graveyard,
})
ecs.SetComponent(gw.World, graveyard, components.NeighborsComponent{
North: forest,
West: chapel,
East: lake,
})
return
}
func (w *World) AddPlayerToDefaultRoom(p *Player) {
w.players[p.Identity()] = p
w.defaultRoom.PlayerJoinRoom(p)
p.SetRoom(w.defaultRoom)
}
// type World struct {
// // rooms []*Room
// // players map[uuid.UUID]*Player
// // defaultRoom *Room
func (w *World) RemovePlayerById(id uuid.UUID) {
p, ok := w.players[id]
// entities []Entity
// systems []*System
// components map[ComponentType]any
// }
if ok {
p.currentRoom.PlayerLeaveRoom(p)
delete(w.players, id)
return
}
}
// func CreateWorld() (world *World) {
// world = &World{
// entities: []Entity{},
// systems: []*System{},
// components: map[ComponentType]any{},
// }
// // world = &World{
// // players: map[uuid.UUID]*Player{},
// // }
func (w *World) FindPlayerById(id uuid.UUID) *Player {
p, ok := w.players[id]
// // forest := CreateRoom(world, "Forest", "A dense, misty forest stretches endlessly, its towering trees whispering secrets through rustling leaves. Sunbeams filter through the canopy, dappling the mossy ground with golden light.")
// // cabin := CreateRoom(world, "Wooden Cabin", "The cabins interior is cozy and rustic, with wooden beams overhead and a stone fireplace crackling warmly. A wool rug lies on creaky floorboards, and shelves brim with books, mugs, and old lanterns.")
// // lake := CreateRoom(world, "Ethermere Lake", "Ethermire Lake lies shrouded in mist, its dark, still waters reflecting a sky perpetually overcast. Whispers ride the wind, and strange lights flicker beneath the surface, never breaking it.")
// // graveyard := CreateRoom(world, "Graveyard", "An overgrown graveyard shrouded in fog, with cracked headstones and leaning statues. The wind sighs through dead trees, and unseen footsteps echo faintly among the mossy graves.")
// // chapel := CreateRoom(world, "Chapel of the Hollow Light", "This ruined chapel leans under ivy and age. Faint light filters through shattered stained glass, casting broken rainbows across dust-choked pews and a long-silent altar.")
if ok {
return p
} else {
return nil
}
}
// // forest.North = cabin
// // forest.South = graveyard
// // forest.East = lake
// // forest.West = chapel
// // cabin.South = forest
// // cabin.West = chapel
// // cabin.East = lake
// // chapel.North = cabin
// // chapel.South = graveyard
// // chapel.East = forest
// // lake.West = forest
// // lake.North = cabin
// // lake.South = graveyard
// // graveyard.North = forest
// // graveyard.West = chapel
// // graveyard.East = lake
// // world.rooms = []*Room{
// // forest,
// // cabin,
// // lake,
// // graveyard,
// // chapel,
// // }
// // world.defaultRoom = forest
// return
// }
// func RegisterComponentType[T any](world *World, compType ComponentType) {
// world.components[compType] = CreateComponentStorage[any](compType)
// }
// func SetComponent[T](compType ComponentType, ent Entity, component any) {
// world.components[compType]
// }
// func (w *World) AddPlayerToDefaultRoom(p *Player) {
// w.players[p.Identity()] = p
// w.defaultRoom.PlayerJoinRoom(p)
// p.SetRoom(w.defaultRoom)
// }
// func (w *World) RemovePlayerById(id uuid.UUID) {
// p, ok := w.players[id]
// if ok {
// p.currentRoom.PlayerLeaveRoom(p)
// delete(w.players, id)
// return
// }
// }
// func (w *World) FindPlayerById(id uuid.UUID) *Player {
// p, ok := w.players[id]
// if ok {
// return p
// } else {
// return nil
// }
// }