Add basic entity tracking, mob spawning and syncing

This commit is contained in:
Master290 2026-06-27 21:13:20 +03:00
parent 7e285e8068
commit 7af0acd3a3
6 changed files with 238 additions and 7 deletions

View file

@ -46,6 +46,8 @@ func main() {
saveCtx, saveStop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) saveCtx, saveStop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
autosaveDone := srv.Chunks().StartAutosave(saveCtx, log, 30*time.Second) autosaveDone := srv.Chunks().StartAutosave(saveCtx, log, 30*time.Second)
srv.StartSpawning()
ln := network.NewListener(srv, log) ln := network.NewListener(srv, log)
// ListenAndServe blocks until the listener stops (on the same signals). // ListenAndServe blocks until the listener stops (on the same signals).

View file

@ -42,6 +42,7 @@ func (h *handler) beginPlay() error {
go h.streamer.run(h.ctx) go h.streamer.run(h.ctx)
h.streamer.requestRecenter(0, 0) h.streamer.requestRecenter(0, 0)
go h.keepAliveLoop() go h.keepAliveLoop()
go h.entitySyncLoop()
return nil 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; // handlePlay dispatches serverbound play packets. Most are tolerated for now;
// teleport and keep-alive are acknowledged/logged. // teleport and keep-alive are acknowledged/logged.
func (h *handler) handlePlay(pkt protocol.Packet) error { func (h *handler) handlePlay(pkt protocol.Packet) error {

View file

@ -80,6 +80,17 @@ const (
PlayBlockUpdate = 0x08 PlayBlockUpdate = 0x08
PlayBlockChangedAck = 0x04 PlayBlockChangedAck = 0x04
PlaySystemChat = 0x79 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). // Play, serverbound (protocol 775).

View file

@ -54,6 +54,7 @@ type Server struct {
cfg Config cfg Config
chunks *world.Cache chunks *world.Cache
store *world.Store // nil when persistence is disabled 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 // New constructs a Server from cfg. When cfg.WorldDir is set, the world is
@ -61,10 +62,11 @@ type Server struct {
// only. A returned error (e.g. the world dir cannot be created) is fatal. // only. A returned error (e.g. the world dir cannot be created) is fatal.
func New(cfg Config) (*Server, error) { func New(cfg Config) (*Server, error) {
gen := world.NewVanillaGenerator(cfg.WorldSeed) gen := world.NewVanillaGenerator(cfg.WorldSeed)
em := world.NewEntityManager()
if cfg.WorldDir == "" { if cfg.WorldDir == "" {
// No persistence; keep eviction off too (flat/test worlds expect full // No persistence; keep eviction off too (flat/test worlds expect full
// presence). Real servers set WorldDir and MaxCachedChunks together. // 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) store, err := world.NewStore(cfg.WorldDir)
if err != nil { if err != nil {
@ -74,6 +76,7 @@ func New(cfg Config) (*Server, error) {
cfg: cfg, cfg: cfg,
chunks: world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, store, cfg.MaxCachedChunks), chunks: world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, store, cfg.MaxCachedChunks),
store: store, store: store,
entities: em,
}, nil }, nil
} }
@ -83,6 +86,9 @@ func (s *Server) Config() Config { return s.cfg }
// Chunks returns the shared chunk cache. // Chunks returns the shared chunk cache.
func (s *Server) Chunks() *world.Cache { return s.chunks } 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. // Store returns the on-disk world store, or nil if persistence is disabled.
func (s *Server) Store() *world.Store { return s.store } func (s *Server) Store() *world.Store { return s.store }

View file

@ -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,
})
}
}

77
internal/world/entity.go Normal file
View file

@ -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
}