From 7af0acd3a3fb720aa63daf312f753e2dfb2656bc Mon Sep 17 00:00:00 2001 From: Master290 Date: Sat, 27 Jun 2026 21:13:20 +0300 Subject: [PATCH] Add basic entity tracking, mob spawning and syncing --- cmd/regionio/main.go | 2 + internal/network/play.go | 69 ++++++++++++++++++++++++++++++++++ internal/protocol/ids.go | 11 ++++++ internal/server/server.go | 20 ++++++---- internal/server/spawner.go | 66 ++++++++++++++++++++++++++++++++ internal/world/entity.go | 77 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 internal/server/spawner.go create mode 100644 internal/world/entity.go diff --git a/cmd/regionio/main.go b/cmd/regionio/main.go index ec40419..306a520 100644 --- a/cmd/regionio/main.go +++ b/cmd/regionio/main.go @@ -46,6 +46,8 @@ func main() { saveCtx, saveStop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) autosaveDone := srv.Chunks().StartAutosave(saveCtx, log, 30*time.Second) + srv.StartSpawning() + ln := network.NewListener(srv, log) // ListenAndServe blocks until the listener stops (on the same signals). diff --git a/internal/network/play.go b/internal/network/play.go index d203501..9dc72cc 100644 --- a/internal/network/play.go +++ b/internal/network/play.go @@ -42,6 +42,7 @@ func (h *handler) beginPlay() error { go h.streamer.run(h.ctx) h.streamer.requestRecenter(0, 0) go h.keepAliveLoop() + go h.entitySyncLoop() return nil } @@ -133,6 +134,74 @@ func (h *handler) keepAliveLoop() { } } +// entitySyncLoop periodically sends all entities in the world to the client. +// In a real server this would track which entities the player can see and send updates. +func (h *handler) entitySyncLoop() { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + known := make(map[int32]bool) + + for { + select { + case <-h.ctx.Done(): + return + case <-ticker.C: + all := h.srv.Entities().All() + current := make(map[int32]bool) + for _, e := range all { + current[e.ID] = true + if !known[e.ID] { + h.sendAddEntity(e) + known[e.ID] = true + } else { + h.sendEntityTeleport(e) + } + } + // remove entities that disappeared + for id := range known { + if !current[id] { + h.sendRemoveEntity(id) + delete(known, id) + } + } + } + } +} + +// sendAddEntity sends the minecraft:add_entity packet. +func (h *handler) sendAddEntity(e *world.Entity) error { + w := protocol.NewWriter(64) + w.VarInt(e.ID) + w.UUID(e.UUID) + w.VarInt(int32(e.TypeID)) + w.Float64(e.X).Float64(e.Y).Float64(e.Z) + w.Byte(byte(e.Pitch * 256.0 / 360.0)) + w.Byte(byte(e.Yaw * 256.0 / 360.0)) + w.Byte(byte(e.HeadYaw * 256.0 / 360.0)) + w.VarInt(0) // Data + w.Uint16(uint16(e.VelocityX)) + w.Uint16(uint16(e.VelocityY)) + w.Uint16(uint16(e.VelocityZ)) + return h.conn.SendWriter(protocol.PlayAddEntity, w) +} + +func (h *handler) sendEntityTeleport(e *world.Entity) error { + w := protocol.NewWriter(64) + w.VarInt(e.ID) + w.Float64(e.X).Float64(e.Y).Float64(e.Z) + w.Byte(byte(e.Yaw * 256.0 / 360.0)) + w.Byte(byte(e.Pitch * 256.0 / 360.0)) + w.Bool(true) // On ground + return h.conn.SendWriter(protocol.PlayTeleportEntity, w) +} + +func (h *handler) sendRemoveEntity(id int32) error { + w := protocol.NewWriter(16) + w.VarInt(1) // count + w.VarInt(id) + return h.conn.SendWriter(protocol.PlayRemoveEntities, w) +} + // handlePlay dispatches serverbound play packets. Most are tolerated for now; // teleport and keep-alive are acknowledged/logged. func (h *handler) handlePlay(pkt protocol.Packet) error { diff --git a/internal/protocol/ids.go b/internal/protocol/ids.go index c3344d3..75d526f 100644 --- a/internal/protocol/ids.go +++ b/internal/protocol/ids.go @@ -80,6 +80,17 @@ const ( PlayBlockUpdate = 0x08 PlayBlockChangedAck = 0x04 PlaySystemChat = 0x79 + + // Entity packets + PlayAddEntity = 0x01 + PlayTeleportEntity = 0x7D + PlayMoveEntityPos = 0x35 + PlayMoveEntityPosRot = 0x36 + PlayMoveEntityRot = 0x38 + PlaySetEntityMotion = 0x65 + PlayRemoveEntities = 0x4D + PlaySetEntityData = 0x63 + PlaySetEquipment = 0x66 ) // Play, serverbound (protocol 775). diff --git a/internal/server/server.go b/internal/server/server.go index d0fca6a..a7e1c6b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -51,9 +51,10 @@ func DefaultConfig() Config { // Server is the top-level core shared across all connections. type Server struct { - cfg Config - chunks *world.Cache - store *world.Store // nil when persistence is disabled + cfg Config + chunks *world.Cache + store *world.Store // nil when persistence is disabled + entities *world.EntityManager } // New constructs a Server from cfg. When cfg.WorldDir is set, the world is @@ -61,19 +62,21 @@ type Server struct { // only. A returned error (e.g. the world dir cannot be created) is fatal. func New(cfg Config) (*Server, error) { gen := world.NewVanillaGenerator(cfg.WorldSeed) + em := world.NewEntityManager() if cfg.WorldDir == "" { // No persistence; keep eviction off too (flat/test worlds expect full // presence). Real servers set WorldDir and MaxCachedChunks together. - return &Server{cfg: cfg, chunks: world.NewCache(int32(cfg.CompressionThreshold), gen)}, nil + return &Server{cfg: cfg, chunks: world.NewCache(int32(cfg.CompressionThreshold), gen), entities: em}, nil } store, err := world.NewStore(cfg.WorldDir) if err != nil { return nil, err } return &Server{ - cfg: cfg, - chunks: world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, store, cfg.MaxCachedChunks), - store: store, + cfg: cfg, + chunks: world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, store, cfg.MaxCachedChunks), + store: store, + entities: em, }, nil } @@ -83,6 +86,9 @@ func (s *Server) Config() Config { return s.cfg } // Chunks returns the shared chunk cache. func (s *Server) Chunks() *world.Cache { return s.chunks } +// Entities returns the shared entity manager. +func (s *Server) Entities() *world.EntityManager { return s.entities } + // Store returns the on-disk world store, or nil if persistence is disabled. func (s *Server) Store() *world.Store { return s.store } diff --git a/internal/server/spawner.go b/internal/server/spawner.go new file mode 100644 index 0000000..f9b4e8c --- /dev/null +++ b/internal/server/spawner.go @@ -0,0 +1,66 @@ +package server + +import ( + "math/rand" + "time" + + "regionio/internal/registry" + "regionio/internal/world" +) + +// StartSpawning begins the entity tick and spawn loops. +func (s *Server) StartSpawning() { + go s.entityTickLoop() + go s.mobSpawnLoop() +} + +func (s *Server) entityTickLoop() { + ticker := time.NewTicker(50 * time.Millisecond) // 20 TPS + defer ticker.Stop() + for range ticker.C { + all := s.entities.All() + for _, e := range all { + // Basic random wandering + e.X += (rand.Float64() - 0.5) * 0.2 + e.Z += (rand.Float64() - 0.5) * 0.2 + e.Yaw += float32((rand.Float64() - 0.5) * 10.0) + } + } +} + +func (s *Server) mobSpawnLoop() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + pigType := registry.Index("minecraft:entity_type", "minecraft:pig") + zombieType := registry.Index("minecraft:entity_type", "minecraft:zombie") + if pigType < 0 || zombieType < 0 { + return + } + + for range ticker.C { + all := s.entities.All() + if len(all) > 50 { + continue // limit to 50 entities + } + + // Spawn near the spawn point (8.5, 200, 8.5) + x := (rand.Float64() - 0.5) * 30.0 + z := (rand.Float64() - 0.5) * 30.0 + + t := pigType + name := "minecraft:pig" + if rand.Float32() < 0.5 { + t = zombieType + name = "minecraft:zombie" + } + + s.entities.Add(&world.Entity{ + TypeID: t, + TypeName: name, + X: x + 8.5, + Y: 200.0, // They float for now since there's no gravity + Z: z + 8.5, + }) + } +} diff --git a/internal/world/entity.go b/internal/world/entity.go new file mode 100644 index 0000000..b6e9d57 --- /dev/null +++ b/internal/world/entity.go @@ -0,0 +1,77 @@ +package world + +import ( + "crypto/rand" + "sync" + "sync/atomic" +) + +// Entity represents an in-game movable entity (mob, animal, etc). +type Entity struct { + ID int32 + UUID [16]byte + TypeID int // Network ID from the minecraft:entity_type registry + TypeName string + + X, Y, Z float64 + Pitch, Yaw float32 + HeadYaw float32 + VelocityX int16 + VelocityY int16 + VelocityZ int16 +} + +// EntityManager tracks active entities in the server and manages thread-safe access. +type EntityManager struct { + mu sync.RWMutex + entities map[int32]*Entity + nextID int32 +} + +// NewEntityManager returns a fresh manager starting at a high ID. +func NewEntityManager() *EntityManager { + return &EntityManager{ + entities: make(map[int32]*Entity), + nextID: 1000, // keep IDs above early players + } +} + +// Add assigns an ID and UUID (if missing) and tracks the entity. +func (em *EntityManager) Add(e *Entity) int32 { + em.mu.Lock() + defer em.mu.Unlock() + e.ID = atomic.AddInt32(&em.nextID, 1) + if e.UUID == [16]byte{} { + rand.Read(e.UUID[:]) + // Version 4 UUID + e.UUID[6] = (e.UUID[6] & 0x0f) | 0x40 + e.UUID[8] = (e.UUID[8] & 0x3f) | 0x80 + } + em.entities[e.ID] = e + return e.ID +} + +// Remove drops the entity by its ID. +func (em *EntityManager) Remove(id int32) { + em.mu.Lock() + defer em.mu.Unlock() + delete(em.entities, id) +} + +// Get retrieves an entity by ID, or nil if not found. +func (em *EntityManager) Get(id int32) *Entity { + em.mu.RLock() + defer em.mu.RUnlock() + return em.entities[id] +} + +// All returns a snapshot slice of all active entities. +func (em *EntityManager) All() []*Entity { + em.mu.RLock() + defer em.mu.RUnlock() + list := make([]*Entity, 0, len(em.entities)) + for _, e := range em.entities { + list = append(list, e) + } + return list +}