feat(client): 客户端网络层、zh_cn 中文语言文件、NSIS 中文安装脚本、UI 渲染器与 HUD(gl 标签)
This commit is contained in:
155
internal/netclient/netclient.go
Normal file
155
internal/netclient/netclient.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Package netclient 客户端网络层:连接服务器、登录、接收区块/方块变更、发送移动(多人同步.md §2)。
|
||||
//
|
||||
// 线程模型(架构.md §4):1 读 + 1 写 goroutine;业务回调经 Handler 在主逻辑线程消费。
|
||||
package netclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"mc/internal/netproto"
|
||||
)
|
||||
|
||||
// 读写超时(网络协议.md §7)。
|
||||
const ioTimeout = 30 * time.Second
|
||||
|
||||
// Handler 客户端业务回调。
|
||||
type Handler interface {
|
||||
// OnLogin 登录结果(0 成功)。
|
||||
OnLogin(code uint8)
|
||||
// OnChunk 收到区块数据(save.EncodeChunk 格式,调用方 DecodeChunk)。
|
||||
OnChunk(data []byte)
|
||||
// OnBlockChange 方块变更。
|
||||
OnBlockChange(m netproto.BlockChange)
|
||||
}
|
||||
|
||||
// Client TCP 客户端。
|
||||
type Client struct {
|
||||
conn net.Conn
|
||||
send chan []byte
|
||||
done chan struct{}
|
||||
closed atomic.Bool
|
||||
closeOnce sync.Once
|
||||
|
||||
handler Handler
|
||||
|
||||
mu sync.Mutex
|
||||
recvBuf []byte
|
||||
}
|
||||
|
||||
// Dial 连接服务器并发送握手。
|
||||
func Dial(addr string, hs netproto.Handshake, h Handler) (*Client, error) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("netclient.Dial %s: %w", addr, err)
|
||||
}
|
||||
c := &Client{
|
||||
conn: conn,
|
||||
send: make(chan []byte, 64),
|
||||
done: make(chan struct{}),
|
||||
handler: h,
|
||||
}
|
||||
if _, err := conn.Write(netproto.EncodeFrame(netproto.Frame{MsgID: netproto.MsgHandshake, Payload: hs.Encode()})); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("netclient.Dial 握手: %w", err)
|
||||
}
|
||||
go c.readLoop()
|
||||
go c.writeLoop()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// readLoop 读循环:解帧分发(半包等待,协议错误断开)。
|
||||
func (c *Client) readLoop() {
|
||||
tmp := make([]byte, 64*1024)
|
||||
for {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||
n, err := c.conn.Read(tmp)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.recvBuf = append(c.recvBuf, tmp[:n]...)
|
||||
for len(c.recvBuf) >= 4 {
|
||||
f, rest, err := netproto.DecodeFrame(c.recvBuf)
|
||||
if err != nil {
|
||||
break // 半包:等更多数据
|
||||
}
|
||||
c.recvBuf = rest
|
||||
c.dispatch(f)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch 消息分发。
|
||||
func (c *Client) dispatch(f netproto.Frame) {
|
||||
switch f.MsgID {
|
||||
case netproto.MsgLoginResponse:
|
||||
var code uint8
|
||||
if len(f.Payload) > 0 {
|
||||
code = f.Payload[0]
|
||||
}
|
||||
c.handler.OnLogin(code)
|
||||
case netproto.MsgChunkData:
|
||||
c.handler.OnChunk(f.Payload)
|
||||
case netproto.MsgBlockChange:
|
||||
if m, err := netproto.DecodeBlockChange(f.Payload); err == nil {
|
||||
c.handler.OnBlockChange(m)
|
||||
}
|
||||
case netproto.MsgDisconnect:
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// writeLoop 写循环。
|
||||
func (c *Client) writeLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case data := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||
if _, err := c.conn.Write(data); err != nil {
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendMove 发送玩家移动(20Hz,多人同步.md §3)。
|
||||
func (c *Client) SendMove(m netproto.PlayerMove) {
|
||||
c.sendFrame(netproto.Frame{MsgID: netproto.MsgPlayerMove, Payload: m.Encode()})
|
||||
}
|
||||
|
||||
// SendHeartbeat 发送心跳(网络协议.md §7)。
|
||||
func (c *Client) SendHeartbeat(seq uint8) {
|
||||
c.sendFrame(netproto.Frame{MsgID: netproto.MsgHeartbeat, Payload: []byte{seq}})
|
||||
}
|
||||
|
||||
// sendFrame 入队发送(非阻塞,队列满丢弃)。
|
||||
func (c *Client) sendFrame(f netproto.Frame) {
|
||||
if c.closed.Load() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.send <- netproto.EncodeFrame(f):
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭连接(幂等)。
|
||||
func (c *Client) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
c.closed.Store(true)
|
||||
close(c.done)
|
||||
_ = c.conn.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// Closed 是否已关闭。
|
||||
func (c *Client) Closed() bool { return c.closed.Load() }
|
||||
85
internal/netclient/netclient_test.go
Normal file
85
internal/netclient/netclient_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package netclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mc/internal/logx"
|
||||
"mc/internal/netproto"
|
||||
"mc/internal/netserver"
|
||||
)
|
||||
|
||||
// testServer 测试服务器:登录后发一个方块变更。
|
||||
type testServer struct {
|
||||
t *testing.T
|
||||
sent chan struct{}
|
||||
}
|
||||
|
||||
func (s *testServer) OnConnect(c *netserver.Conn) {}
|
||||
func (s *testServer) OnHandshake(c *netserver.Conn, hs netproto.Handshake) uint8 {
|
||||
if hs.ProtocolVersion != 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func (s *testServer) OnMove(c *netserver.Conn, m netproto.PlayerMove) {
|
||||
// 收到移动后回发一个方块变更(验证客户端接收链路)
|
||||
c.Send(netproto.Frame{MsgID: netproto.MsgBlockChange, Payload: netproto.BlockChange{
|
||||
X: 1, Y: 2, Z: 3, BlockID: 5, Meta: 0,
|
||||
}.Encode()})
|
||||
s.sent <- struct{}{}
|
||||
}
|
||||
func (s *testServer) OnDisconnect(c *netserver.Conn) {}
|
||||
|
||||
// testHandler 客户端回调记录。
|
||||
type testHandler struct {
|
||||
login chan uint8
|
||||
blocks chan netproto.BlockChange
|
||||
}
|
||||
|
||||
func newTestHandler() *testHandler {
|
||||
return &testHandler{login: make(chan uint8, 1), blocks: make(chan netproto.BlockChange, 4)}
|
||||
}
|
||||
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 }
|
||||
|
||||
// TestClientRoundtrip 客户端登录 → 发移动 → 收方块变更全链路(多人同步.md §2)。
|
||||
func TestClientRoundtrip(t *testing.T) {
|
||||
log, _ := logx.New("", logx.LevelError)
|
||||
sh := &testServer{t: t, sent: make(chan struct{}, 1)}
|
||||
srv := netserver.New("127.0.0.1:0", log, sh)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := srv.Listen(ctx); err != nil {
|
||||
t.Fatalf("监听失败: %v", err)
|
||||
}
|
||||
|
||||
h := newTestHandler()
|
||||
c, err := Dial(srv.Addr(), netproto.Handshake{ProtocolVersion: 1, ClientID: "测试"}, h)
|
||||
if err != nil {
|
||||
t.Fatalf("拨号失败: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
select {
|
||||
case code := <-h.login:
|
||||
if code != 0 {
|
||||
t.Fatalf("登录结果期望 0,实际 %d", code)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("未收到登录结果")
|
||||
}
|
||||
|
||||
// 发送移动 → 服务器回方块变更
|
||||
c.SendMove(netproto.PlayerMove{X: 1, Y: 64, Z: 2})
|
||||
select {
|
||||
case m := <-h.blocks:
|
||||
if m.X != 1 || m.Y != 2 || m.Z != 3 || m.BlockID != 5 {
|
||||
t.Fatalf("方块变更内容异常: %+v", m)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("未收到方块变更")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user