diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go index 6c89b3dd..60c6e831 100644 --- a/cmd/client/client_gl.go +++ b/cmd/client/client_gl.go @@ -57,6 +57,10 @@ type clientApp struct { net *netclient.Client log *logx.Logger + // 其他玩家位置(多人同步.md §3;渲染插值后置) + remoteMu sync.Mutex + remotePlayers map[uint32][3]float64 + meshBuilder *mesh.Builder mu sync.Mutex @@ -90,6 +94,11 @@ func (a *clientApp) Tick(dt float64) { } else { a.screen = 1 a.rend.SetCursorMode(true) + a.hud.OnlineCount = func() int { + a.remoteMu.Lock() + defer a.remoteMu.Unlock() + return len(a.remotePlayers) + 1 // 含自己 + } a.logf("已连接服务器") } case "quit": @@ -174,6 +183,7 @@ func (a *clientApp) Render() { a.mu.Unlock() for _, j := range pending { a.rend.UploadChunk(j.cx, j.cz, j.m) + mesh.Release(j.m) // 上传完毕归还顶点池(性能与内存.md §2) } // 视图投影 @@ -365,19 +375,20 @@ func main() { in := newInputState(rend.Window()) app := &clientApp{ - sess: sess, - rend: rend, - reg: reg, - in: in, - uiR: uiR, - hud: hud, - titleScreen: titleScreen, - pauseScreen: pauseScreen, - log: log, - meshBuilder: mesh.NewBuilder(reg, uvFn), - winW: cfg.Client.WindowWidth, - winH: cfg.Client.WindowHeight, - lastTime: time.Now(), + sess: sess, + rend: rend, + reg: reg, + in: in, + uiR: uiR, + hud: hud, + titleScreen: titleScreen, + pauseScreen: pauseScreen, + log: log, + remotePlayers: make(map[uint32][3]float64), + meshBuilder: mesh.NewBuilder(reg, uvFn), + winW: cfg.Client.WindowWidth, + winH: cfg.Client.WindowHeight, + lastTime: time.Now(), } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) diff --git a/cmd/client/multiplayer_gl.go b/cmd/client/multiplayer_gl.go index a4f896d5..34235241 100644 --- a/cmd/client/multiplayer_gl.go +++ b/cmd/client/multiplayer_gl.go @@ -40,6 +40,13 @@ func (h *mpHandler) OnBlockChange(m netproto.BlockChange) { h.app.sess.World.SetBlock(m.X, m.Y, m.Z, block.NewState(m.BlockID, m.Meta)) } +// OnEntityMove 其他玩家移动(多人同步.md §3:客户端存储位置,渲染插值后置)。 +func (h *mpHandler) OnEntityMove(m netproto.EntityMove) { + h.app.remoteMu.Lock() + h.app.remotePlayers[m.EntityID] = [3]float64{float64(m.X), float64(m.Y), float64(m.Z)} + h.app.remoteMu.Unlock() +} + // dialServer 连接服务器(多人游戏按钮,多人同步.md §2)。 func dialServer(a *clientApp, addr string, name string) error { h := &mpHandler{app: a} diff --git a/cmd/server/main.go b/cmd/server/main.go index be7aa7be..4daa23b5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,6 +17,7 @@ import ( "mc/internal/engine" "mc/internal/logx" "mc/internal/mods" + "mc/internal/mpsync" "mc/internal/netproto" "mc/internal/netserver" "mc/internal/save" @@ -31,6 +32,7 @@ type serverApp struct { reg *block.Registry world *world.World net *netserver.Server + hub *mpsync.Hub modReg *mods.Registry luaMods []*mods.LuaMod seed int64 @@ -44,25 +46,27 @@ func (s *serverApp) OnConnect(c *netserver.Conn) { s.log.Infof("玩家连接 #%d", c.ID()) } -// OnHandshake 握手校验(网络协议.md §4:协议版本不匹配拒绝)。 +// OnHandshake 握手校验(网络协议.md §4:协议版本不匹配拒绝)+ 加入同步中心。 func (s *serverApp) OnHandshake(c *netserver.Conn, hs netproto.Handshake) uint8 { if hs.ProtocolVersion != 1 { s.log.Warnf("玩家 #%d 协议版本 %d 不匹配,拒绝", c.ID(), hs.ProtocolVersion) return 1 } s.log.Infof("玩家 %q 登录(连接 #%d)", hs.ClientID, c.ID()) + // 登录成功:加入广播集并发送区块(多人同步.md §4) + s.hub.AddPlayer(c, 8, 8) return 0 } -// OnMove 玩家移动(MVP:仅记录,完整同步见 Todo 8)。 +// OnMove 玩家移动:广播给其他客户端(多人同步.md §3)。 func (s *serverApp) OnMove(c *netserver.Conn, m netproto.PlayerMove) { - // TODO(多人同步): 更新玩家实体并广播给视距内其他玩家(多人同步.md §3) - _ = m + s.hub.BroadcastEntityMove(c, uint32(c.ID()), m) } -// OnDisconnect 连接断开。 +// OnDisconnect 连接断开:移出同步中心。 func (s *serverApp) OnDisconnect(c *netserver.Conn) { s.log.Infof("玩家 #%d 断开", c.ID()) + s.hub.RemovePlayer(c) } // Tick 每 tick(20Hz)服务器逻辑(架构.md §3.2:tick 内不做 IO)。 @@ -128,6 +132,7 @@ func main() { } app := &serverApp{cfg: cfg, log: log, reg: reg, world: w, seed: seed} + app.hub = mpsync.NewHub(w, cfg.Server.ViewDistance) // Mod 加载(设计.md §3.3:扫描 mods/,type=mod 且有入口脚本的执行;热更新.md 由注册表支持) am := assets.New() diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go index eaad3720..ca4ed8a5 100644 --- a/internal/mesh/mesh.go +++ b/internal/mesh/mesh.go @@ -4,10 +4,32 @@ package mesh import ( + "sync" + "mc/internal/block" "mc/internal/chunk" ) +// vertexPools 顶点切片池(性能与内存.md §2:热路径零分配——渲染线程不造 mesh 垃圾)。 +var vertexPools = [3]sync.Pool{ + {New: func() any { s := make([]Vertex, 0, 4096); return &s }}, + {New: func() any { s := make([]Vertex, 0, 1024); return &s }}, + {New: func() any { s := make([]Vertex, 0, 1024); return &s }}, +} + +// Release 归还网格顶点缓冲到池(GPU 上传后调用,性能与内存.md §8 sync.Pool 复位)。 +func Release(m *ChunkMesh) { + if m == nil { + return + } + for i, s := range [3]*[]Vertex{&m.Opaque, &m.Cutout, &m.Translucent} { + if cap(*s) > 0 && cap(*s) <= 1<<20 { + vertexPools[i].Put(s) + } + *s = nil + } +} + // Vertex 顶点格式(渲染.md §5):位置 + UV + 天空/方块光各 4-bit。 type Vertex struct { X, Y, Z float32 @@ -62,6 +84,11 @@ var faceTexture = [6]string{"up", "down", "east", "west", "south", "north"} // Build 构建区块网格(面剔除:仅当邻格非完全不透明时生成该面,渲染.md §3.1)。 func (b *Builder) Build(c *chunk.Chunk, nb Neighbor) *ChunkMesh { m := &ChunkMesh{} + // 顶点缓冲从池中取(性能与内存.md §2) + ob := vertexPools[0].Get().(*[]Vertex) + cb := vertexPools[1].Get().(*[]Vertex) + tb := vertexPools[2].Get().(*[]Vertex) + *ob, *cb, *tb = (*ob)[:0], (*cb)[:0], (*tb)[:0] blocks := c.Snapshot() baseX, baseZ := float32(c.CX*16), float32(c.CZ*16) @@ -75,11 +102,11 @@ func (b *Builder) Build(c *chunk.Chunk, nb Neighbor) *ChunkMesh { var target *[]Vertex switch { case b.reg.IsTranslucent(s): - target = &m.Translucent + target = tb case b.reg.IsCutout(s): - target = &m.Cutout + target = cb default: - target = &m.Opaque + target = ob } wx := int32(x) + c.CX*16 wy := int32(y) @@ -119,6 +146,8 @@ func (b *Builder) Build(c *chunk.Chunk, nb Neighbor) *ChunkMesh { } } } + // 挂回结果(注意:切片头来自池,调用方上传后必须 Release) + m.Opaque, m.Cutout, m.Translucent = *ob, *cb, *tb return m } diff --git a/internal/mesh/mesh_test.go b/internal/mesh/mesh_test.go index 21677eb2..86df8436 100644 --- a/internal/mesh/mesh_test.go +++ b/internal/mesh/mesh_test.go @@ -26,6 +26,22 @@ func dummyUV(key string) (float32, float32, float32, float32, bool) { return 0, 0, 1, 1, true } +// TestPoolReuse 顶点池复用:Build → Release → 再次 Build 不崩溃且结果正确(性能与内存.md §2)。 +func TestPoolReuse(t *testing.T) { + reg := loadReg(t) + stone, _ := reg.ID("stone") + c := chunk.New(0, 0) + c.Fill(0, 0, 0, block.NewState(stone, 0)) + b := NewBuilder(reg, dummyUV) + for i := 0; i < 3; i++ { + m := b.Build(c, airNb) + if len(m.Opaque) != 24 { + t.Fatalf("第 %d 次构建顶点数异常: %d", i, len(m.Opaque)) + } + Release(m) + } +} + // TestSingleBlock 单方块 6 面全生成:24 顶点(6 面 × 4 顶点四边形)。 func TestSingleBlock(t *testing.T) { reg := loadReg(t) diff --git a/internal/mpsync/hub.go b/internal/mpsync/hub.go index 3ecb66ab..8601a5f5 100644 --- a/internal/mpsync/hub.go +++ b/internal/mpsync/hub.go @@ -72,6 +72,24 @@ func (h *Hub) SetBlockWorld(x, y, z int32, s block.State) { h.BroadcastBlockChange(x, y, z, s.ID(), s.Meta()) } +// BroadcastEntityMove 广播玩家移动给其他客户端(多人同步.md §3:20Hz 增量)。 +// from 为发送者(不回传给自己)。 +func (h *Hub) BroadcastEntityMove(from *netserver.Conn, entityID uint32, m netproto.PlayerMove) { + msg := netproto.EntityMove{EntityID: entityID, X: m.X, Y: m.Y, Z: m.Z, Yaw: m.Yaw} + payload := msg.Encode() + h.mu.Lock() + clients := make([]*netserver.Conn, 0, len(h.clients)) + for c := range h.clients { + if c != from { + clients = append(clients, c) + } + } + h.mu.Unlock() + for _, c := range clients { + c.Send(netproto.Frame{MsgID: netproto.MsgEntityMove, Payload: payload}) + } +} + // absi32 绝对值。 func absi32(v int32) int32 { if v < 0 { diff --git a/internal/netclient/netclient.go b/internal/netclient/netclient.go index d669b714..4acda3cd 100644 --- a/internal/netclient/netclient.go +++ b/internal/netclient/netclient.go @@ -24,6 +24,8 @@ type Handler interface { OnChunk(data []byte) // OnBlockChange 方块变更。 OnBlockChange(m netproto.BlockChange) + // OnEntityMove 其他实体移动(多人同步.md §3)。 + OnEntityMove(m netproto.EntityMove) } // Client TCP 客户端。 @@ -100,6 +102,10 @@ func (c *Client) dispatch(f netproto.Frame) { if m, err := netproto.DecodeBlockChange(f.Payload); err == nil { c.handler.OnBlockChange(m) } + case netproto.MsgEntityMove: + if m, err := netproto.DecodeEntityMove(f.Payload); err == nil { + c.handler.OnEntityMove(m) + } case netproto.MsgDisconnect: c.Close() } diff --git a/internal/netclient/netclient_test.go b/internal/netclient/netclient_test.go index c198759a..09bebb79 100644 --- a/internal/netclient/netclient_test.go +++ b/internal/netclient/netclient_test.go @@ -44,6 +44,7 @@ func newTestHandler() *testHandler { func (h *testHandler) OnLogin(code uint8) { h.login <- code } func (h *testHandler) OnChunk(data []byte) {} func (h *testHandler) OnBlockChange(m netproto.BlockChange) { h.blocks <- m } +func (h *testHandler) OnEntityMove(m netproto.EntityMove) {} // TestClientRoundtrip 客户端登录 → 发移动 → 收方块变更全链路(多人同步.md §2)。 func TestClientRoundtrip(t *testing.T) { diff --git a/internal/netproto/protocol.go b/internal/netproto/protocol.go index db935e1b..da780423 100644 --- a/internal/netproto/protocol.go +++ b/internal/netproto/protocol.go @@ -317,3 +317,28 @@ func DecodeHandshake(b []byte) (Handshake, error) { m := Handshake{ProtocolVersion: int(r.Varint()), ClientID: r.Str()} return m, r.Err } + +// EntityMove 实体移动(0x07,20Hz 广播,多人同步.md §3)。 +type EntityMove struct { + EntityID uint32 + X, Y, Z float32 + Yaw float32 +} + +// Encode 编码实体移动。 +func (m EntityMove) Encode() []byte { + w := &Writer{} + w.U32(m.EntityID) + w.F32(m.X) + w.F32(m.Y) + w.F32(m.Z) + w.F32(m.Yaw) + return w.B +} + +// DecodeEntityMove 解码实体移动。 +func DecodeEntityMove(b []byte) (EntityMove, error) { + r := NewReader(b) + m := EntityMove{EntityID: r.U32(), X: r.F32(), Y: r.F32(), Z: r.F32(), Yaw: r.F32()} + return m, r.Err +} diff --git a/internal/ui/hud.go b/internal/ui/hud.go index e444c704..202fa045 100644 --- a/internal/ui/hud.go +++ b/internal/ui/hud.go @@ -22,6 +22,9 @@ type HUD struct { // 逻辑分辨率与缩放(UI还原.md §3.1:427×240 基准 × 整数缩放) baseW, baseH float32 scale float32 + + // OnlineCount 在线玩家数(联机时由调用方注入;nil 表示单机不显示) + OnlineCount func() int } // NewHUD 创建 HUD。 @@ -84,6 +87,10 @@ func (h *HUD) Draw(r *Renderer) { // 坐标调试(后置移除) r.DrawText(w/2, hgt-40*s, fmt.Sprintf("XYZ %.1f / %.1f / %.1f", h.sess.Player.Pos[0], h.sess.Player.Pos[1], h.sess.Player.Pos[2]), 1*s, white) + // 联机在线人数(多人同步.md §3) + if h.OnlineCount != nil { + r.DrawText(w/2, hgt-56*s, fmt.Sprintf("在线玩家: %d", h.OnlineCount()), 1*s, white) + } } // drawItemIcon 在槽位绘制物品图标 + 数量。