Implement multiplayer persistence and vanilla lighting
This commit is contained in:
parent
8f7cacf9d9
commit
cae06eb97e
47 changed files with 3784 additions and 465 deletions
|
|
@ -4,6 +4,10 @@ package server
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
|
|
@ -57,31 +61,96 @@ type Server struct {
|
|||
store *world.Store // nil when persistence is disabled
|
||||
entities *world.EntityManager
|
||||
|
||||
// playerPos tracks the last known position of each player by name.
|
||||
playerPos sync.Map // map[string][3]float64
|
||||
playersMu sync.RWMutex
|
||||
players map[[16]byte]*PlayerSession
|
||||
playerNames map[string][16]byte
|
||||
nextPlayerID int32
|
||||
}
|
||||
|
||||
// PacketSender is the connection capability retained by the session registry.
|
||||
// The network package supplies Conn.Send without introducing an import cycle.
|
||||
type PacketSender func(id int32, body []byte) error
|
||||
|
||||
// PlayerSession is one active play-state client. Position is guarded separately
|
||||
// so entity ticks can inspect players without holding the server registry lock.
|
||||
type PlayerSession struct {
|
||||
EntityID int32
|
||||
Profile Profile
|
||||
send PacketSender
|
||||
mu sync.RWMutex
|
||||
position [3]float64
|
||||
yaw float32
|
||||
pitch float32
|
||||
onGround bool
|
||||
viewDist int
|
||||
}
|
||||
|
||||
// PlayerSnapshot is an immutable view of one play-state session.
|
||||
type PlayerSnapshot struct {
|
||||
EntityID int32
|
||||
Profile Profile
|
||||
X, Y, Z float64
|
||||
Yaw, Pitch float32
|
||||
OnGround bool
|
||||
ViewDistance int
|
||||
}
|
||||
|
||||
var (
|
||||
ErrServerFull = errors.New("server: player limit reached")
|
||||
ErrDuplicatePlayer = errors.New("server: player is already connected")
|
||||
)
|
||||
|
||||
// New constructs a Server from cfg. When cfg.WorldDir is set, the world is
|
||||
// backed by an on-disk store under that directory; otherwise it is in-memory
|
||||
// only. A returned error (e.g. the world dir cannot be created) is fatal.
|
||||
func New(cfg Config) (*Server, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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), entities: em}, nil
|
||||
return newServerState(cfg,
|
||||
world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, nil, cfg.MaxCachedChunks), nil, em), nil
|
||||
}
|
||||
store, err := world.NewStore(cfg.WorldDir)
|
||||
store, err := world.NewStoreForSeed(cfg.WorldDir, cfg.WorldSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newServerState(cfg,
|
||||
world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, store, cfg.MaxCachedChunks), store, em), nil
|
||||
}
|
||||
|
||||
// NewWithCache constructs a server around an existing cache. It is useful for
|
||||
// embedding and integration tests that provide a specialized world generator.
|
||||
func NewWithCache(cfg Config, chunks *world.Cache) (*Server, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if chunks == nil {
|
||||
return nil, errors.New("server: nil chunk cache")
|
||||
}
|
||||
return newServerState(cfg, chunks, nil, world.NewEntityManager()), nil
|
||||
}
|
||||
|
||||
func validateConfig(cfg Config) error {
|
||||
if cfg.Port < 1 || cfg.Port > 65535 {
|
||||
return fmt.Errorf("server: port %d out of range", cfg.Port)
|
||||
}
|
||||
if cfg.MaxPlayers < 1 {
|
||||
return fmt.Errorf("server: max players must be positive")
|
||||
}
|
||||
if cfg.MaxCachedChunks < 0 {
|
||||
return fmt.Errorf("server: max cached chunks must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newServerState(cfg Config, chunks *world.Cache, store *world.Store, entities *world.EntityManager) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
chunks: world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, store, cfg.MaxCachedChunks),
|
||||
store: store,
|
||||
entities: em,
|
||||
}, nil
|
||||
cfg: cfg, chunks: chunks, store: store, entities: entities,
|
||||
players: make(map[[16]byte]*PlayerSession), playerNames: make(map[string][16]byte),
|
||||
}
|
||||
}
|
||||
|
||||
// Config returns the active configuration.
|
||||
|
|
@ -93,30 +162,202 @@ func (s *Server) Chunks() *world.Cache { return s.chunks }
|
|||
// Entities returns the shared entity manager.
|
||||
func (s *Server) Entities() *world.EntityManager { return s.entities }
|
||||
|
||||
// SetPlayerPosition updates the tracked position of a player.
|
||||
func (s *Server) SetPlayerPosition(name string, x, y, z float64) {
|
||||
s.playerPos.Store(name, [3]float64{x, y, z})
|
||||
// RegisterPlayer adds a profile to the active play-state registry.
|
||||
func (s *Server) RegisterPlayer(profile Profile, send PacketSender) (*PlayerSession, error) {
|
||||
s.playersMu.Lock()
|
||||
defer s.playersMu.Unlock()
|
||||
nameKey := strings.ToLower(profile.Name)
|
||||
if _, exists := s.players[profile.UUID]; exists {
|
||||
return nil, ErrDuplicatePlayer
|
||||
}
|
||||
if _, exists := s.playerNames[nameKey]; exists {
|
||||
return nil, ErrDuplicatePlayer
|
||||
}
|
||||
if s.cfg.MaxPlayers > 0 && len(s.players) >= s.cfg.MaxPlayers {
|
||||
return nil, ErrServerFull
|
||||
}
|
||||
s.nextPlayerID++
|
||||
session := &PlayerSession{
|
||||
EntityID: s.nextPlayerID,
|
||||
Profile: profile,
|
||||
send: send,
|
||||
onGround: true,
|
||||
viewDist: 4,
|
||||
}
|
||||
s.players[profile.UUID] = session
|
||||
s.playerNames[nameKey] = profile.UUID
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// RemovePlayerPosition removes a player from tracking.
|
||||
func (s *Server) RemovePlayerPosition(name string) {
|
||||
s.playerPos.Delete(name)
|
||||
// UnregisterPlayer removes exactly the supplied session. Pointer identity keeps
|
||||
// a delayed disconnect from removing a future session with the same profile.
|
||||
func (s *Server) UnregisterPlayer(session *PlayerSession) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
s.playersMu.Lock()
|
||||
defer s.playersMu.Unlock()
|
||||
if current := s.players[session.Profile.UUID]; current == session {
|
||||
delete(s.players, session.Profile.UUID)
|
||||
delete(s.playerNames, strings.ToLower(session.Profile.Name))
|
||||
}
|
||||
}
|
||||
|
||||
// SetPlayerPosition updates a session's authoritative position snapshot.
|
||||
func (s *Server) SetPlayerPosition(session *PlayerSession, x, y, z float64) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
session.mu.Lock()
|
||||
session.position = [3]float64{x, y, z}
|
||||
session.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetPlayerTransform updates all movement fields received from the client.
|
||||
func (s *Server) SetPlayerTransform(session *PlayerSession, x, y, z float64, yaw, pitch float32, onGround bool) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
session.mu.Lock()
|
||||
session.position = [3]float64{x, y, z}
|
||||
session.yaw = yaw
|
||||
session.pitch = pitch
|
||||
session.onGround = onGround
|
||||
session.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetPlayerViewDistance records the clamped chunk radius used for visibility.
|
||||
func (s *Server) SetPlayerViewDistance(session *PlayerSession, distance int) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
if distance < 2 {
|
||||
distance = 4
|
||||
}
|
||||
if distance > 16 {
|
||||
distance = 16
|
||||
}
|
||||
session.mu.Lock()
|
||||
session.viewDist = distance
|
||||
session.mu.Unlock()
|
||||
}
|
||||
|
||||
// Snapshot returns a consistent copy of this session's gameplay state.
|
||||
func (session *PlayerSession) Snapshot() PlayerSnapshot {
|
||||
if session == nil {
|
||||
return PlayerSnapshot{}
|
||||
}
|
||||
session.mu.RLock()
|
||||
snapshot := PlayerSnapshot{
|
||||
EntityID: session.EntityID,
|
||||
Profile: session.Profile,
|
||||
X: session.position[0],
|
||||
Y: session.position[1],
|
||||
Z: session.position[2],
|
||||
Yaw: session.yaw,
|
||||
Pitch: session.pitch,
|
||||
OnGround: session.onGround,
|
||||
ViewDistance: session.viewDist,
|
||||
}
|
||||
session.mu.RUnlock()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// PlayerSnapshots returns consistent copies of all active play sessions.
|
||||
func (s *Server) PlayerSnapshots() []PlayerSnapshot {
|
||||
s.playersMu.RLock()
|
||||
players := make([]*PlayerSession, 0, len(s.players))
|
||||
for _, player := range s.players {
|
||||
players = append(players, player)
|
||||
}
|
||||
s.playersMu.RUnlock()
|
||||
|
||||
snapshots := make([]PlayerSnapshot, 0, len(players))
|
||||
for _, player := range players {
|
||||
snapshots = append(snapshots, player.Snapshot())
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
// PlayerCount returns the number of tracked players.
|
||||
func (s *Server) PlayerCount() int {
|
||||
s.playersMu.RLock()
|
||||
defer s.playersMu.RUnlock()
|
||||
return len(s.players)
|
||||
}
|
||||
|
||||
// Broadcast sends one already-encoded packet body to every active player. A
|
||||
// failed recipient cannot make another player's gameplay handler fail.
|
||||
func (s *Server) Broadcast(id int32, body []byte) {
|
||||
s.playersMu.RLock()
|
||||
senders := make([]PacketSender, 0, len(s.players))
|
||||
for _, player := range s.players {
|
||||
senders = append(senders, player.send)
|
||||
}
|
||||
s.playersMu.RUnlock()
|
||||
for _, send := range senders {
|
||||
if send != nil {
|
||||
_ = send(id, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastChunk sends a packet only to players whose chunk view contains the
|
||||
// target chunk. It is used for block and light changes.
|
||||
func (s *Server) BroadcastChunk(cx, cz int32, id int32, body []byte) {
|
||||
s.playersMu.RLock()
|
||||
players := make([]*PlayerSession, 0, len(s.players))
|
||||
for _, player := range s.players {
|
||||
players = append(players, player)
|
||||
}
|
||||
s.playersMu.RUnlock()
|
||||
|
||||
for _, player := range players {
|
||||
snapshot := player.Snapshot()
|
||||
pcx := int32(int64(math.Floor(snapshot.X)) >> 4)
|
||||
pcz := int32(int64(math.Floor(snapshot.Z)) >> 4)
|
||||
if chunkDistance(pcx, pcz, cx, cz) <= int32(snapshot.ViewDistance) && player.send != nil {
|
||||
_ = player.send(id, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func chunkDistance(ax, az, bx, bz int32) int32 {
|
||||
dx := ax - bx
|
||||
if dx < 0 {
|
||||
dx = -dx
|
||||
}
|
||||
dz := az - bz
|
||||
if dz < 0 {
|
||||
dz = -dz
|
||||
}
|
||||
if dz > dx {
|
||||
return dz
|
||||
}
|
||||
return dx
|
||||
}
|
||||
|
||||
// NearestPlayer returns the position of the nearest player to (x, y, z).
|
||||
// Returns false if no players are online.
|
||||
func (s *Server) NearestPlayer(x, y, z float64) (pos [3]float64, ok bool) {
|
||||
minDist := float64(-1)
|
||||
s.playerPos.Range(func(key, value any) bool {
|
||||
p := value.([3]float64)
|
||||
s.playersMu.RLock()
|
||||
players := make([]*PlayerSession, 0, len(s.players))
|
||||
for _, player := range s.players {
|
||||
players = append(players, player)
|
||||
}
|
||||
s.playersMu.RUnlock()
|
||||
for _, player := range players {
|
||||
player.mu.RLock()
|
||||
p := player.position
|
||||
player.mu.RUnlock()
|
||||
dist := (p[0]-x)*(p[0]-x) + (p[1]-y)*(p[1]-y) + (p[2]-z)*(p[2]-z)
|
||||
if minDist < 0 || dist < minDist {
|
||||
minDist = dist
|
||||
pos = p
|
||||
ok = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
130
internal/server/server_test.go
Normal file
130
internal/server/server_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"regionio/internal/world"
|
||||
)
|
||||
|
||||
func TestPlayerRegistrySupportsFourPlayersAndEnforcesLimit(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.MaxPlayers = 4
|
||||
srv, err := NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var sends atomic.Int32
|
||||
var sessions []*PlayerSession
|
||||
for _, name := range []string{"Alice", "Bob", "Carol", "Dave"} {
|
||||
profile := Profile{Name: name, UUID: OfflineUUID(name)}
|
||||
session, err := srv.RegisterPlayer(profile, func(int32, []byte) error {
|
||||
sends.Add(1)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("register %s: %v", name, err)
|
||||
}
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
if got := srv.PlayerCount(); got != 4 {
|
||||
t.Fatalf("player count = %d, want 4", got)
|
||||
}
|
||||
if _, err := srv.RegisterPlayer(Profile{Name: "Eve", UUID: OfflineUUID("Eve")}, nil); !errors.Is(err, ErrServerFull) {
|
||||
t.Fatalf("fifth player error = %v, want ErrServerFull", err)
|
||||
}
|
||||
|
||||
srv.Broadcast(1, []byte("packet"))
|
||||
if got := sends.Load(); got != 4 {
|
||||
t.Fatalf("broadcast sends = %d, want 4", got)
|
||||
}
|
||||
for _, session := range sessions {
|
||||
srv.UnregisterPlayer(session)
|
||||
}
|
||||
if got := srv.PlayerCount(); got != 0 {
|
||||
t.Fatalf("player count after disconnect = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentFourPlayerTransformsAndChunkBroadcast(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.WorldDir = ""
|
||||
cfg.MaxPlayers = 4
|
||||
srv, err := NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var sends [4]atomic.Int32
|
||||
sessions := make([]*PlayerSession, 4)
|
||||
for i, name := range []string{"Alice", "Bob", "Carol", "Dave"} {
|
||||
i := i
|
||||
sessions[i], err = srv.RegisterPlayer(Profile{Name: name, UUID: OfflineUUID(name)}, func(int32, []byte) error {
|
||||
sends[i].Add(1)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.SetPlayerViewDistance(sessions[i], 2)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, session := range sessions {
|
||||
i, session := i, session
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for step := 0; step < 500; step++ {
|
||||
srv.SetPlayerTransform(session, float64(i*16+step), 80, float64(-step), float32(step%360), 10, step%2 == 0)
|
||||
_ = srv.PlayerSnapshots()
|
||||
srv.BroadcastChunk(int32(step>>4), int32(-step>>4), 1, nil)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got := len(srv.PlayerSnapshots()); got != 4 {
|
||||
t.Fatalf("snapshot count = %d, want 4", got)
|
||||
}
|
||||
|
||||
for i, session := range sessions {
|
||||
x := float64(i * 160)
|
||||
srv.SetPlayerTransform(session, x, 80, 0, 0, 0, true)
|
||||
before := sends[i].Load()
|
||||
srv.BroadcastChunk(0, 0, 2, nil)
|
||||
got := sends[i].Load() - before
|
||||
if i == 0 && got != 1 {
|
||||
t.Fatalf("near player received %d packets, want 1", got)
|
||||
}
|
||||
if i > 0 && got != 0 {
|
||||
t.Fatalf("far player %d received %d packets, want 0", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerRegistryRejectsDuplicateName(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
srv, err := NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := Profile{Name: "Alice", UUID: OfflineUUID("Alice")}
|
||||
if _, err := srv.RegisterPlayer(first, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate := Profile{Name: "ALICE", UUID: OfflineUUID("ALICE")}
|
||||
if _, err := srv.RegisterPlayer(duplicate, nil); !errors.Is(err, ErrDuplicatePlayer) {
|
||||
t.Fatalf("duplicate error = %v, want ErrDuplicatePlayer", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRejectsNegativeCacheLimit(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.MaxCachedChunks = -1
|
||||
if _, err := NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat)); err == nil {
|
||||
t.Fatal("negative cache limit was accepted")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
|
@ -10,92 +11,102 @@ import (
|
|||
)
|
||||
|
||||
// StartSpawning begins the entity tick and spawn loops.
|
||||
func (s *Server) StartSpawning() {
|
||||
go s.entityTickLoop()
|
||||
go s.mobSpawnLoop()
|
||||
func (s *Server) StartSpawning(ctx context.Context) {
|
||||
go s.entityTickLoop(ctx)
|
||||
go s.mobSpawnLoop(ctx)
|
||||
}
|
||||
|
||||
func (s *Server) entityTickLoop() {
|
||||
func (s *Server) entityTickLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(50 * time.Millisecond) // 20 TPS
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
all := s.entities.All()
|
||||
for _, e := range all {
|
||||
// Apply gravity
|
||||
yBelow := int(e.Y - 0.1) // slightly below the entity
|
||||
blockBelow := s.chunks.GetBlock(int(e.X), yBelow, int(e.Z))
|
||||
|
||||
if blockBelow == world.StateAir || blockBelow == world.StateWater { // Air or Water
|
||||
e.VelocityY -= 80 // gravity acceleration
|
||||
if e.VelocityY < -3000 {
|
||||
e.VelocityY = -3000 // terminal velocity
|
||||
}
|
||||
} else {
|
||||
e.VelocityY = 0
|
||||
e.Y = float64(yBelow + 1)
|
||||
|
||||
// Basic random wandering or player tracking when on ground
|
||||
pos, ok := s.NearestPlayer(e.X, e.Y, e.Z)
|
||||
|
||||
if ok && e.TypeName == "minecraft:zombie" {
|
||||
// Zombies move towards the player
|
||||
dx := pos[0] - e.X
|
||||
dz := pos[2] - e.Z
|
||||
dist := math.Sqrt(dx*dx + dz*dz)
|
||||
if dist > 1.0 && dist < 32.0 {
|
||||
e.X += (dx / dist) * 0.15
|
||||
e.Z += (dz / dist) * 0.15
|
||||
// Simple yaw calculation
|
||||
e.Yaw = float32(math.Atan2(-dx, dz) * (180 / math.Pi))
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
all := s.entities.All()
|
||||
for i := range all {
|
||||
snapshot := all[i]
|
||||
yBelow := int(math.Floor(snapshot.Y - 0.1))
|
||||
blockBelow := s.chunks.GetBlock(int(math.Floor(snapshot.X)), yBelow, int(math.Floor(snapshot.Z)))
|
||||
nearest, hasPlayer := s.NearestPlayer(snapshot.X, snapshot.Y, snapshot.Z)
|
||||
s.entities.Update(all[i].ID, func(e *world.Entity) {
|
||||
// Apply gravity
|
||||
if blockBelow == world.StateAir || blockBelow == world.StateWater { // Air or Water
|
||||
e.VelocityY -= 80 // gravity acceleration
|
||||
if e.VelocityY < -3000 {
|
||||
e.VelocityY = -3000 // terminal velocity
|
||||
}
|
||||
} else {
|
||||
e.VelocityY = 0
|
||||
e.Y = float64(yBelow + 1)
|
||||
|
||||
// Basic random wandering or player tracking when on ground
|
||||
if hasPlayer && e.TypeName == "minecraft:zombie" {
|
||||
// Zombies move towards the player
|
||||
dx := nearest[0] - e.X
|
||||
dz := nearest[2] - e.Z
|
||||
dist := math.Sqrt(dx*dx + dz*dz)
|
||||
if dist > 1.0 && dist < 32.0 {
|
||||
e.X += (dx / dist) * 0.15
|
||||
e.Z += (dz / dist) * 0.15
|
||||
// Simple yaw calculation
|
||||
e.Yaw = float32(math.Atan2(-dx, dz) * (180 / math.Pi))
|
||||
}
|
||||
} else {
|
||||
// Random wander
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Random wander
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if e.VelocityY != 0 {
|
||||
e.Y += float64(e.VelocityY) / 8000.0
|
||||
|
||||
if e.VelocityY != 0 {
|
||||
e.Y += float64(e.VelocityY) / 8000.0
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) mobSpawnLoop() {
|
||||
func (s *Server) mobSpawnLoop(ctx context.Context) {
|
||||
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")
|
||||
pigType := registry.EntityTypeIndex("minecraft:pig")
|
||||
zombieType := registry.EntityTypeIndex("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
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if s.PlayerCount() == 0 || s.entities.Count() >= 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"
|
||||
}
|
||||
// Spawn near the spawn point (8.5, 200, 8.5)
|
||||
x := (rand.Float64() - 0.5) * 30.0
|
||||
z := (rand.Float64() - 0.5) * 30.0
|
||||
|
||||
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,
|
||||
})
|
||||
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,
|
||||
Z: z + 8.5,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue