feat(mpsync): 多人同步 MVP——登录区块流、方块变更广播;修复 netserver 关闭竞态与世界 worker 退出死锁

This commit is contained in:
NianGao Dev
2026-08-15 23:17:34 +08:00
parent 4eaab7f3f9
commit 4167bf287d
6 changed files with 303 additions and 23 deletions

81
internal/mpsync/hub.go Normal file
View File

@@ -0,0 +1,81 @@
// Package mpsync 多人同步:登录发送区块数据、方块变更广播(多人同步.md §2、§4–§5)。
//
// 服务器权威:方块破坏/放置由服务器执行后广播(多人同步.md §5)。
package mpsync
import (
"sync"
"mc/internal/block"
"mc/internal/netproto"
"mc/internal/netserver"
"mc/internal/save"
"mc/internal/world"
)
// Hub 同步中心:玩家会话与广播。
type Hub struct {
mu sync.Mutex
clients map[*netserver.Conn]struct{} // 已登录连接
world *world.World
viewDist int
}
// NewHub 创建同步中心。
func NewHub(w *world.World, viewDist int) *Hub {
return &Hub{clients: make(map[*netserver.Conn]struct{}), world: w, viewDist: viewDist}
}
// AddPlayer 玩家登录:加入广播集并发送周围区块(多人同步.md §4 区块流)。
func (h *Hub) AddPlayer(c *netserver.Conn, spawnX, spawnZ float64) {
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
c.Send(netproto.Frame{MsgID: netproto.MsgLoginResponse, Payload: []byte{0}})
// 发送视距内已加载区块(按距离排序,多人同步.md §4)
chunks := h.world.ActiveChunks()
for _, ch := range chunks {
if absi32(ch.CX-int32(spawnX)/16) > int32(h.viewDist) || absi32(ch.CZ-int32(spawnZ)/16) > int32(h.viewDist) {
continue
}
data := save.EncodeChunk(ch)
c.Send(netproto.Frame{MsgID: netproto.MsgChunkData, Payload: data})
}
}
// RemovePlayer 玩家断开:移出广播集。
func (h *Hub) RemovePlayer(c *netserver.Conn) {
h.mu.Lock()
delete(h.clients, c)
h.mu.Unlock()
}
// BroadcastBlockChange 广播方块变更(多人同步.md §5:只发视距内,MVP 全量广播)。
func (h *Hub) BroadcastBlockChange(x, y, z int32, id uint16, meta uint8) {
msg := netproto.BlockChange{X: x, Y: y, Z: z, BlockID: id, Meta: meta}
payload := msg.Encode()
h.mu.Lock()
clients := make([]*netserver.Conn, 0, len(h.clients))
for c := range h.clients {
clients = append(clients, c)
}
h.mu.Unlock()
for _, c := range clients {
c.Send(netproto.Frame{MsgID: netproto.MsgBlockChange, Payload: payload})
}
}
// SetBlockWorld 服务器权威写方块:修改世界并广播(多人同步.md §2 权威链路)。
func (h *Hub) SetBlockWorld(x, y, z int32, s block.State) {
h.world.SetBlock(x, y, z, s)
h.BroadcastBlockChange(x, y, z, s.ID(), s.Meta())
}
// absi32 绝对值。
func absi32(v int32) int32 {
if v < 0 {
return -v
}
return v
}

174
internal/mpsync/hub_test.go Normal file
View File

@@ -0,0 +1,174 @@
package mpsync
import (
"context"
"net"
"path/filepath"
"testing"
"time"
"mc/internal/block"
"mc/internal/logx"
"mc/internal/netproto"
"mc/internal/netserver"
"mc/internal/world"
"mc/internal/worldgen"
)
// syncHandler 测试用服务器处理器:登录 → hub.AddPlayer。
type syncHandler struct {
hub *Hub
recv chan *netserver.Conn
}
func (h *syncHandler) OnConnect(c *netserver.Conn) {}
func (h *syncHandler) OnHandshake(c *netserver.Conn, hs netproto.Handshake) uint8 {
if h.recv != nil {
h.recv <- c
}
h.hub.AddPlayer(c, 8, 8)
return 0
}
func (h *syncHandler) OnMove(c *netserver.Conn, m netproto.PlayerMove) {}
func (h *syncHandler) OnDisconnect(c *netserver.Conn) { h.hub.RemovePlayer(c) }
// TestLoginReceivesChunk 登录后客户端收到区块数据帧(多人同步.md §4)。
func TestLoginReceivesChunk(t *testing.T) {
reg, err := block.Load(filepath.Join("..", "..", "assets", "config", "blocks.json"))
if err != nil {
t.Fatalf("加载注册表失败: %v", err)
}
gen, err := worldgen.New(reg, 7, 0.005, 0.01, 0.1)
if err != nil {
t.Fatalf("创建生成器失败: %v", err)
}
w, err := world.New(reg, gen, 4)
if err != nil {
t.Fatalf("创建世界失败: %v", err)
}
defer w.Close()
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
w.Update(8, 64, 8, 8*time.Millisecond)
if w.Block(8, 0, 8) != block.Air {
break
}
time.Sleep(2 * time.Millisecond)
}
hub := NewHub(w, 4)
log, _ := logx.New("", logx.LevelDebug)
h := &syncHandler{hub: hub, recv: make(chan *netserver.Conn, 1)}
srv := netserver.New("127.0.0.1:0", log, h)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("监听失败: %v", err)
}
nc, err := net.DialTimeout("tcp", srv.Addr(), 3*time.Second)
if err != nil {
t.Fatalf("拨号失败: %v", err)
}
defer nc.Close()
hs := netproto.Handshake{ProtocolVersion: 1, ClientID: "同步测试"}
_, _ = nc.Write(netproto.EncodeFrame(netproto.Frame{MsgID: netproto.MsgHandshake, Payload: hs.Encode()}))
gotChunk := false
buf := make([]byte, 64*1024)
_ = nc.SetReadDeadline(time.Now().Add(5 * time.Second))
acc := []byte{}
for !gotChunk {
n, err := nc.Read(buf)
if err != nil {
break
}
acc = append(acc, buf[:n]...)
for len(acc) >= 4 {
f, rest, err := netproto.DecodeFrame(acc)
if err != nil {
break
}
acc = rest
if f.MsgID == netproto.MsgChunkData {
gotChunk = true
}
}
}
if !gotChunk {
t.Fatal("登录后未收到区块数据")
}
}
// TestBlockChangeBroadcast 服务器权威写方块 → 客户端收到广播(多人同步.md §5)。
func TestBlockChangeBroadcast(t *testing.T) {
reg, err := block.Load(filepath.Join("..", "..", "assets", "config", "blocks.json"))
if err != nil {
t.Fatalf("加载注册表失败: %v", err)
}
gen, err := worldgen.New(reg, 8, 0.005, 0.01, 0.1)
if err != nil {
t.Fatalf("创建生成器失败: %v", err)
}
w, err := world.New(reg, gen, 2)
if err != nil {
t.Fatalf("创建世界失败: %v", err)
}
defer w.Close()
hub := NewHub(w, 4)
log, _ := logx.New("", logx.LevelDebug)
h := &syncHandler{hub: hub, recv: make(chan *netserver.Conn, 1)}
srv := netserver.New("127.0.0.1:0", log, h)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("监听失败: %v", err)
}
nc, err := net.DialTimeout("tcp", srv.Addr(), 3*time.Second)
if err != nil {
t.Fatalf("拨号失败: %v", err)
}
defer nc.Close()
_, _ = nc.Write(netproto.EncodeFrame(netproto.Frame{MsgID: netproto.MsgHandshake, Payload: netproto.Handshake{ProtocolVersion: 1}.Encode()}))
// 等待服务器完成登录注册(避免广播早于 AddPlayer 执行,多人同步.md §2 时序)
select {
case <-h.recv:
case <-time.After(3 * time.Second):
t.Fatal("未完成登录注册")
}
torch, _ := reg.ID("torch")
go hub.SetBlockWorld(8, 70, 8, block.NewState(torch, 0))
buf := make([]byte, 64*1024)
_ = nc.SetReadDeadline(time.Now().Add(5 * time.Second))
acc := []byte{}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
n, err := nc.Read(buf)
if err != nil {
break
}
acc = append(acc, buf[:n]...)
for len(acc) >= 4 {
f, rest, err := netproto.DecodeFrame(acc)
if err != nil {
break
}
acc = rest
if f.MsgID != netproto.MsgBlockChange {
continue
}
m, err := netproto.DecodeBlockChange(f.Payload)
if err != nil {
t.Fatalf("解码失败: %v", err)
}
if m.X == 8 && m.Y == 70 && m.Z == 8 && m.BlockID == torch {
return
}
}
}
t.Fatal("未收到方块变更广播")
}