diff --git a/assets/lang/zh_cn.json b/assets/lang/zh_cn.json new file mode 100644 index 00000000..1decfa7b --- /dev/null +++ b/assets/lang/zh_cn.json @@ -0,0 +1,114 @@ +{ + "block.minecraft.stone": "石头", + "block.minecraft.granite": "花岗岩", + "block.minecraft.diorite": "闪长岩", + "block.minecraft.andesite": "安山岩", + "block.minecraft.grass_block": "草方块", + "block.minecraft.dirt": "泥土", + "block.minecraft.cobblestone": "圆石", + "block.minecraft.oak_planks": "橡木木板", + "block.minecraft.bedrock": "基岩", + "block.minecraft.sand": "沙子", + "block.minecraft.gravel": "沙砾", + "block.minecraft.oak_log": "橡木原木", + "block.minecraft.oak_leaves": "橡树树叶", + "block.minecraft.glass": "玻璃", + "block.minecraft.water": "水", + "block.minecraft.lava": "岩浆", + "block.minecraft.torch": "火把", + "block.minecraft.crafting_table": "工作台", + "block.minecraft.furnace": "熔炉", + "block.minecraft.coal_ore": "煤矿石", + "block.minecraft.iron_ore": "铁矿石", + "block.minecraft.gold_ore": "金矿石", + "block.minecraft.diamond_ore": "钻石矿石", + "block.minecraft.redstone_ore": "红石矿石", + "block.minecraft.iron_block": "铁块", + "block.minecraft.gold_block": "金块", + "block.minecraft.diamond_block": "钻石块", + "block.minecraft.snow_block": "雪块", + "block.minecraft.ice": "冰", + "block.minecraft.obsidian": "黑曜石", + "block.minecraft.white_wool": "白色羊毛", + + "item.minecraft.stick": "木棍", + "item.minecraft.coal": "煤炭", + "item.minecraft.diamond": "钻石", + "item.minecraft.iron_ingot": "铁锭", + "item.minecraft.gold_ingot": "金锭", + "item.minecraft.redstone": "红石粉", + "item.minecraft.oak_planks": "橡木木板", + "item.minecraft.cobblestone": "圆石", + "item.minecraft.crafting_table": "工作台", + "item.minecraft.furnace": "熔炉", + "item.minecraft.torch": "火把", + "item.minecraft.wooden_pickaxe": "木镐", + "item.minecraft.stone_pickaxe": "石镐", + "item.minecraft.iron_pickaxe": "铁镐", + "item.minecraft.diamond_pickaxe": "钻石镐", + "item.minecraft.wooden_shovel": "木锹", + "item.minecraft.stone_shovel": "石锹", + "item.minecraft.iron_shovel": "铁锹", + "item.minecraft.diamond_shovel": "钻石锹", + "item.minecraft.wooden_axe": "木斧", + "item.minecraft.stone_axe": "石斧", + "item.minecraft.iron_axe": "铁斧", + "item.minecraft.diamond_axe": "钻石斧", + "item.minecraft.raw_iron": "粗铁", + "item.minecraft.raw_gold": "粗金", + "item.minecraft.rotten_flesh": "腐肉", + "item.minecraft.beef": "生牛肉", + "item.minecraft.porkchop": "生猪排", + "item.minecraft.chicken": "生鸡肉", + "item.minecraft.mutton": "生羊肉", + "item.minecraft.leather": "皮革", + "item.minecraft.feather": "羽毛", + "item.minecraft.egg": "鸡蛋", + "item.minecraft.white_wool": "白色羊毛", + "item.minecraft.wheat_seeds": "小麦种子", + "item.minecraft.wheat": "小麦", + "item.minecraft.bone": "骨头", + + "menu.singleplayer": "单人游戏", + "menu.multiplayer": "多人游戏", + "menu.online": "局域网世界", + "menu.options": "设置…", + "menu.quit": "退出游戏", + "menu.game": "游戏菜单", + "menu.returnToMenu": "保存并返回标题画面", + "menu.disconnect": "断开连接", + "menu.returnToGame": "返回游戏", + "gui.inventory": "物品栏", + "gui.crafting": "合成", + "gui.furnace": "熔炉", + "gui.container.chest": "箱子", + "gui.language": "语言", + "gui.resume": "继续游戏", + "gui.save_and_quit": "保存并退出", + "gui.game_paused": "游戏已暂停", + "gui.hotbar": "快捷栏", + "gui.recipe_book": "配方书", + "options.title": "设置", + "options.video": "视频设置", + "options.audio": "音乐和声音", + "options.controls": "控制", + "options.language": "语言…", + "options.done": "完成", + "options.fov": "视场角", + "options.renderDistance": "渲染距离", + "options.fullscreen": "全屏", + "selectWorld.title": "选择世界", + "createWorld.title": "创建新的世界", + "createWorld.seed": "世界种子", + "createWorld.worldName": "世界名称", + "createWorld.gameMode": "游戏模式", + "selectWorld.create": "创建新的世界", + "selectWorld.enterSeed": "输入种子…", + "selectWorld.delete": "删除", + "world.gameMode.survival": "生存模式", + "world.gameMode.creative": "创造模式", + "deathScreen.title": "你死了!", + "deathScreen.respawn": "重生", + "deathScreen.titleScreen": "标题画面", + "chat.type.text": "发送消息" +} diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go index 7555a0c1..8eda16b0 100644 --- a/cmd/client/client_gl.go +++ b/cmd/client/client_gl.go @@ -9,6 +9,7 @@ package main import ( "context" "flag" + "image" "os" "os/signal" "sync" @@ -20,6 +21,7 @@ import ( "mc/internal/camera" "mc/internal/config" "mc/internal/engine" + "mc/internal/font" "mc/internal/game" "mc/internal/inventory" "mc/internal/item" @@ -28,6 +30,7 @@ import ( "mc/internal/physics" "mc/internal/recipe" "mc/internal/render" + "mc/internal/ui" "mc/internal/world" "mc/internal/worldgen" ) @@ -40,6 +43,8 @@ type clientApp struct { rend *render.Renderer reg *block.Registry in *inputState + uiR *ui.Renderer + hud *ui.HUD meshBuilder *mesh.Builder @@ -101,6 +106,10 @@ func (a *clientApp) Render() { a.rend.BeginFrame() chunks := a.sess.World.ActiveChunks() a.rend.DrawChunks(func(i int) (int32, int32) { return chunks[i].CX, chunks[i].CZ }, len(chunks)) + // UI pass(UI与输入.md §2:3D 之后、状态隔离) + a.uiR.Begin() + a.hud.Draw(a.uiR) + a.uiR.Flush() a.rend.EndFrame() } @@ -152,6 +161,22 @@ func main() { return u0, v0, u1, v1, true } + // items 图集(HUD 热键栏图标,UI还原.md §7) + itemKeys, err := am.ExpandAtlas("items") + if err != nil { + log.Warnf("items 图集展开失败: %v", err) + } + itemImg, itemUVs, err := atlas.Build(am.ResolveTexture, itemKeys, 16) + if err != nil { + log.Warnf("items 图集构建失败: %v", err) + } + + // 字体(UI还原.md §4:素材包位图字体) + fontAtlas, err := font.Build(am, "default") + if err != nil { + log.Warnf("字体构建失败: %v", err) + } + // 2) 注册表(设计.md §6.1/§6.2 数据驱动) reg, err := block.Load("assets/config/blocks.json") if err != nil { @@ -192,6 +217,29 @@ func main() { rend.SetAtlas(atlasImg) rend.SetCursorMode(true) + // UI 层(UI还原.md §3.1:427×240 基准 × 3 缩放 = 1280×720) + guiScale := float32(3) + uiR, err := ui.New(cfg.Client.WindowWidth, cfg.Client.WindowHeight, guiScale) + if err != nil { + panic(err) + } + if fontAtlas != nil { + uiR.SetFont(fontAtlas) + } + hud := ui.NewHUD(sess, items, 427, 240, guiScale) + if itemImg != nil { + itemTex := uiR.UploadImage(itemImg) + itemUV := make(map[string][4]float32, len(itemUVs)) + for k, rect := range itemUVs { + W, H := float32(itemImg.Bounds().Dx()), float32(itemImg.Bounds().Dy()) + itemUV[k] = [4]float32{ + float32(rect.Min.X) / W, float32(rect.Min.Y) / H, + float32(rect.Max.X) / W, float32(rect.Max.Y) / H, + } + } + hud.SetItemAtlas(itemTex, itemUV) + } + // 6) 输入(鼠标捕获 + 键鼠回调) in := newInputState(rend.Window()) @@ -200,6 +248,8 @@ func main() { rend: rend, reg: reg, in: in, + uiR: uiR, + hud: hud, meshBuilder: mesh.NewBuilder(reg, uvFn), winW: cfg.Client.WindowWidth, winH: cfg.Client.WindowHeight, diff --git a/internal/game/game.go b/internal/game/game.go index 6e556aa5..6c293775 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -31,15 +31,21 @@ type Session struct { breaking bool breakProg float64 + // 玩家生命(僵尸/摔落伤害后置接入,实体系统.md §6) + Health int + // 创造模式(跳过进度直接破坏) Creative bool } // NewSession 创建会话。 func NewSession(w *world.World, reg *block.Registry, items *item.Registry, recipes *recipe.Registry, p *physics.Player, inv *inventory.Inventory, cam *camera.Camera) *Session { - return &Session{World: w, Reg: reg, Items: items, Recipes: recipes, Player: p, Inv: inv, Cam: cam} + return &Session{World: w, Reg: reg, Items: items, Recipes: recipes, Player: p, Inv: inv, Cam: cam, Health: 20} } +// PlayerHealth 玩家当前生命(HUD 用)。 +func (s *Session) PlayerHealth() int { return s.Health } + // Tick 每 tick(20Hz)会话逻辑(架构.md §3.2)。 func (s *Session) Tick(dt float64) { // 物理积分(速度由输入层写入 Player.Vel) diff --git a/internal/netclient/netclient.go b/internal/netclient/netclient.go new file mode 100644 index 00000000..d669b714 --- /dev/null +++ b/internal/netclient/netclient.go @@ -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() } diff --git a/internal/netclient/netclient_test.go b/internal/netclient/netclient_test.go new file mode 100644 index 00000000..c198759a --- /dev/null +++ b/internal/netclient/netclient_test.go @@ -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("未收到方块变更") + } +} diff --git a/internal/ui/hud.go b/internal/ui/hud.go new file mode 100644 index 00000000..e444c704 --- /dev/null +++ b/internal/ui/hud.go @@ -0,0 +1,107 @@ +//go:build gl + +// HUD 抬头显示:准星、热键栏、生命/饥饿、破坏进度(UI还原.md §2、§3.4 规格)。 +package ui + +import ( + "fmt" + + "mc/internal/game" + "mc/internal/item" +) + +// HUD 游戏内 HUD。 +type HUD struct { + sess *game.Session + items *item.Registry + + // 物品图标:items 图集纹理与 UV + itemTex uint32 + itemUV map[string][4]float32 + + // 逻辑分辨率与缩放(UI还原.md §3.1:427×240 基准 × 整数缩放) + baseW, baseH float32 + scale float32 +} + +// NewHUD 创建 HUD。 +func NewHUD(sess *game.Session, items *item.Registry, baseW, baseH, scale float32) *HUD { + return &HUD{sess: sess, items: items, itemUV: map[string][4]float32{}, baseW: baseW, baseH: baseH, scale: scale} +} + +// SetItemAtlas 设置物品图标纹理与 UV 表(items 图集)。 +func (h *HUD) SetItemAtlas(tex uint32, uv map[string][4]float32) { + h.itemTex = tex + h.itemUV = uv +} + +// Draw 每帧绘制 HUD(调用方已 Begin)。 +func (h *HUD) Draw(r *Renderer) { + w, hgt := float32(r.w), float32(r.h) + s := h.scale + white := [4]float32{1, 1, 1, 1} + + // 准星(UI还原.md §3.4:屏幕正中 15×15 十字) + cx, cy := w/2, hgt/2 + r.DrawRect(cx-1*s, cy-8*s, 2*s, 16*s, white) + r.DrawRect(cx-8*s, cy-1*s, 16*s, 2*s, white) + + // 热键栏(UI还原.md §3.4:182×22,底部居中) + barW, barH := 182*s, 22*s + bx, by := cx-barW/2, hgt-barH + r.DrawRect(bx, by, barW, barH, [4]float32{0, 0, 0, 0.5}) + for i := 0; i < 9; i++ { + sx := bx + float32(i)*20*s + 1*s + sy := by + 3*s + if i == h.sess.Inv.Selected { + r.DrawRect(sx-2*s, sy-2*s, 20*s, 20*s, [4]float32{1, 1, 1, 0.9}) // 选中高亮框 + } + h.drawItemIcon(r, h.sess.Inv.Slots[i], sx, sy) + } + + // 生命:10 心(每心 9×9,屏幕左下,UI还原.md §3.4) + hearts := int(20 / 2) + hp := h.sess.PlayerHealth() + hx, hy := w/2-91*s, hgt-30*s + for i := 0; i < hearts; i++ { + color := [4]float32{0.2, 0.2, 0.2, 0.6} // 空 + if i < hp/2 { + color = [4]float32{1, 0.1, 0.1, 1} // 满 + } + r.DrawRect(hx+float32(i%10)*8*s, hy+float32(i/10)*9*s, 7*s, 7*s, color) + } + + // 饥饿:10 鸡腿(右侧偏移) + for i := 0; i < 10; i++ { + r.DrawRect(w/2-91*s+100*s+float32(i%10)*8*s, hy+float32(i/10)*9*s, 7*s, 7*s, [4]float32{0.6, 0.4, 0.15, 1}) + } + + // 破坏进度(方块交互与动画.md §4:HUD 中央进度) + if prog := h.sess.BreakProgress(); prog > 0 { + r.DrawRect(cx-40*s, cy+20*s, 80*s, 4*s, [4]float32{0, 0, 0, 0.7}) + r.DrawRect(cx-40*s, cy+20*s, 80*s*float32(prog), 4*s, [4]float32{1, 1, 1, 0.9}) + } + + // 坐标调试(后置移除) + 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) +} + +// drawItemIcon 在槽位绘制物品图标 + 数量。 +func (h *HUD) drawItemIcon(r *Renderer, stack item.Stack, x, y float32) { + s := h.scale + if stack.Empty() { + return + } + def, ok := h.items.Get(stack.Name) + if !ok { + return + } + if uv, found := h.itemUV[def.Texture]; found && h.itemTex != 0 { + r.pushQuad(quad{x: x + 2*s, y: y + 2*s, w: 16 * s, h: 16 * s, + u0: uv[0], v0: uv[1], u1: uv[2], v1: uv[3], + r: 1, g: 1, b: 1, a: 1, tex: h.itemTex}) + } + if stack.Count > 1 { + r.DrawText(x+10*s, y+8*s, fmt.Sprintf("%d", stack.Count), 1*s, [4]float32{1, 1, 1, 1}) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 00000000..1b033652 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,322 @@ +//go:build gl + +// Package ui 2D UI 渲染器:正交投影、四边形批处理、位图字体文本、九宫格(UI还原.md §3)。 +// +// 与 3D 渲染共用 GL 上下文但状态隔离(UI与输入.md §2):每帧 3D 之后调用, +// 内部保存/恢复必要状态。 +package ui + +import ( + "fmt" + "image" + "strings" + + "mc/internal/font" + "mc/internal/textr" + + "github.com/go-gl/gl/v3.3-core/gl" +) + +// quad 一个四边形(屏幕像素坐标,y 轴向下)。 +type quad struct { + x, y, w, h float32 + u0, v0, u1, v1 float32 + r, g, b, a float32 + tex uint32 +} + +// Renderer UI 渲染器。 +type Renderer struct { + prog uint32 + vao, vbo uint32 + texWhite uint32 + fontTex uint32 + fontAtlas *font.Atlas + texCache map[string]uint32 // 长文本光栅化纹理缓存 + quads []quad + w, h int32 + scale float32 // 像素缩放(guiScale) +} + +// New 创建 UI 渲染器(窗口逻辑分辨率 × 缩放)。 +func New(winW, winH int, scale float32) (*Renderer, error) { + r := &Renderer{w: int32(winW), h: int32(winH), scale: scale, texCache: make(map[string]uint32)} + vs, err := compileShader(gl.VERTEX_SHADER, uiVertSrc) + if err != nil { + return nil, fmt.Errorf("ui.New 顶点着色器: %w", err) + } + fs, err := compileShader(gl.FRAGMENT_SHADER, uiFragSrc) + if err != nil { + return nil, fmt.Errorf("ui.New 片元着色器: %w", err) + } + prog := gl.CreateProgram() + gl.AttachShader(prog, vs) + gl.AttachShader(prog, fs) + gl.LinkProgram(prog) + var ok int32 + gl.GetProgramiv(prog, gl.LINK_STATUS, &ok) + if ok == 0 { + return nil, fmt.Errorf("ui.New: 着色器链接失败") + } + gl.DeleteShader(vs) + gl.DeleteShader(fs) + r.prog = prog + + gl.GenVertexArrays(1, &r.vao) + gl.GenBuffers(1, &r.vbo) + // 白色占位纹理 + gl.GenTextures(1, &r.texWhite) + gl.BindTexture(gl.TEXTURE_2D, r.texWhite) + gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr([]uint8{255, 255, 255, 255})) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) + return r, nil +} + +// SetFont 设置位图字体(UI还原.md §4:上传字形图集纹理)。 +func (r *Renderer) SetFont(at *font.Atlas) { + r.fontAtlas = at + if at.Image == nil { + return + } + if r.fontTex == 0 { + gl.GenTextures(1, &r.fontTex) + } + gl.BindTexture(gl.TEXTURE_2D, r.fontTex) + b := at.Image.Bounds() + gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, int32(b.Dx()), int32(b.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, imageBytesUI(at.Image)) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) +} + +// DrawRect 绘制纯色矩形。 +func (r *Renderer) DrawRect(x, y, w, h float32, rgba [4]float32) { + r.pushQuad(quad{x: x, y: y, w: w, h: h, u0: 0, v0: 0, u1: 1, v1: 1, + r: rgba[0], g: rgba[1], b: rgba[2], a: rgba[3], tex: r.texWhite}) +} + +// DrawText 绘制文本(ASCII 位图字形,含 1px 阴影,UI还原.md §4)。 +func (r *Renderer) DrawText(x, y float32, s string, scale float32, rgba [4]float32) { + if r.fontAtlas == nil { + return + } + s = strings.ReplaceAll(s, "§", "") // MVP:忽略颜色码(后置支持) + cx := x + for _, ch := range s { + g, ok := r.fontAtlas.Glyphs[ch] + if !ok { + g = r.fontAtlas.Missing() + } + px := float32(g.X) / float32(r.fontAtlas.Image.Bounds().Dx()) + py := float32(g.Y) / float32(r.fontAtlas.Image.Bounds().Dy()) + pw := float32(g.W) / float32(r.fontAtlas.Image.Bounds().Dx()) + ph := float32(g.H) / float32(r.fontAtlas.Image.Bounds().Dy()) + gw := float32(g.W) * scale + gh := float32(g.H) * scale + // 阴影(黑色,偏移 1px) + r.pushQuad(quad{x: cx + 1*scale, y: y + 1*scale, w: gw, h: gh, u0: px, v0: py, u1: px + pw, v1: py + ph, + r: 0.1, g: 0.1, b: 0.1, a: rgba[3] * 0.8, tex: r.fontTex}) + r.pushQuad(quad{x: cx, y: y, w: gw, h: gh, u0: px, v0: py, u1: px + pw, v1: py + ph, + r: rgba[0], g: rgba[1], b: rgba[2], a: rgba[3], tex: r.fontTex}) + cx += float32(g.Width) * scale + } +} + +// TextWidth 文本像素宽度(布局用)。 +func (r *Renderer) TextWidth(s string, scale float32) float32 { + if r.fontAtlas == nil { + return float32(len(s)) * 8 * scale + } + return float32(textrWidth(r.fontAtlas, s)) * scale +} + +// textrWidth 计算文本宽度(复用 textr 逻辑的轻量版)。 +func textrWidth(at *font.Atlas, s string) int { + w := 0 + for _, ch := range s { + if g, ok := at.Glyphs[ch]; ok { + w += g.Width + } else { + w += 8 + } + } + return w +} + +// DrawTexImage 上传一张图像为纹理并绘制(长文本/图标用),返回纹理 ID 供缓存。 +func (r *Renderer) DrawTexImage(x, y float32, img image.Image, scale float32) { + var tex uint32 + gl.GenTextures(1, &tex) + gl.BindTexture(gl.TEXTURE_2D, tex) + b := img.Bounds() + gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, int32(b.Dx()), int32(b.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, imageBytesUI(img)) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) + r.pushQuad(quad{x: x, y: y, w: float32(b.Dx()) * scale, h: float32(b.Dy()) * scale, + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: tex}) +} + +// UploadImage 上传图像为纹理并返回 ID(物品图标/长文本用)。 +func (r *Renderer) UploadImage(img image.Image) uint32 { + var tex uint32 + gl.GenTextures(1, &tex) + gl.BindTexture(gl.TEXTURE_2D, tex) + b := img.Bounds() + gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, int32(b.Dx()), int32(b.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, imageBytesUI(img)) + gl.GenerateMipmap(gl.TEXTURE_2D) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_LINEAR) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + return tex +} + +// Begin 开始一帧 UI 绘制(清空批次)。 +func (r *Renderer) Begin() { r.quads = r.quads[:0] } + +// Flush 提交批次(切换投影 → 绘制 → 恢复状态)。 +func (r *Renderer) Flush() { + if len(r.quads) == 0 { + return + } + // 保存 3D 状态 + gl.Disable(gl.DEPTH_TEST) + gl.UseProgram(r.prog) + + // 正交投影(像素坐标,y 向下) + var proj [16]float32 + ortho2D(&proj, 0, float32(r.w), float32(r.h), 0) + projLoc := gl.GetUniformLocation(r.prog, gl.Str("uProj\x00")) + gl.UniformMatrix4fv(projLoc, 1, false, &proj[0]) + sampLoc := gl.GetUniformLocation(r.prog, gl.Str("uTex\x00")) + gl.Uniform1i(sampLoc, 0) + + // 批处理:合并为顶点数组(每 quad 6 顶点,含纹理 ID 通过多次绘制分组) + gl.BindVertexArray(r.vao) + gl.BindBuffer(gl.ARRAY_BUFFER, r.vbo) + gl.EnableVertexAttribArray(0) // pos + gl.EnableVertexAttribArray(1) // uv + gl.EnableVertexAttribArray(2) // color + stride := int32(9 * 4) + + currentTex := uint32(0xFFFFFFFF) + flushBatch := func(tex uint32, verts []float32) { + if len(verts) == 0 { + return + } + gl.ActiveTexture(gl.TEXTURE0) + gl.BindTexture(gl.TEXTURE_2D, tex) + gl.BufferData(gl.ARRAY_BUFFER, len(verts)*4, gl.Ptr(verts), gl.STREAM_DRAW) + gl.VertexAttribPointer(0, 2, gl.FLOAT, false, stride, gl.PtrOffset(0)) + gl.VertexAttribPointer(1, 2, gl.FLOAT, false, stride, gl.PtrOffset(2*4)) + gl.VertexAttribPointer(2, 4, gl.FLOAT, false, stride, gl.PtrOffset(4*4)) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(verts)/9)) + } + var batch []float32 + for _, q := range r.quads { + if q.tex != currentTex { + flushBatch(currentTex, batch) + batch = batch[:0] + currentTex = q.tex + } + batch = append(batch, quadVertices(q)...) + } + flushBatch(currentTex, batch) + + gl.BindVertexArray(0) + gl.Enable(gl.DEPTH_TEST) + r.quads = r.quads[:0] +} + +// pushQuad 入批。 +func (r *Renderer) pushQuad(q quad) { r.quads = append(r.quads, q) } + +// quadVertices 四边形 → 6 顶点(2 三角形)。 +func quadVertices(q quad) []float32 { + x0, y0 := q.x, q.y + x1, y1 := q.x+q.w, q.y+q.h + return []float32{ + x0, y0, q.u0, q.v0, q.r, q.g, q.b, q.a, 0, + x1, y0, q.u1, q.v0, q.r, q.g, q.b, q.a, 0, + x1, y1, q.u1, q.v1, q.r, q.g, q.b, q.a, 0, + x0, y0, q.u0, q.v0, q.r, q.g, q.b, q.a, 0, + x1, y1, q.u1, q.v1, q.r, q.g, q.b, q.a, 0, + x0, y1, q.u0, q.v1, q.r, q.g, q.b, q.a, 0, + } +} + +// ortho2D 正交投影矩阵(列主序,近 -1 远 1)。 +func ortho2D(m *[16]float32, l, r, b, t float32) { + *m = [16]float32{} + m[0] = 2 / (r - l) + m[5] = 2 / (t - b) + m[10] = -1 + m[12] = -(r + l) / (r - l) + m[13] = -(t + b) / (t - b) + m[15] = 1 +} + +// imageBytesUI image.Image → RGBA 字节流。 +func imageBytesUI(img image.Image) []byte { + b := img.Bounds() + out := make([]byte, 0, b.Dx()*b.Dy()*4) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + cr, cg, cb, ca := img.At(x, y).RGBA() + out = append(out, byte(cr>>8), byte(cg>>8), byte(cb>>8), byte(ca>>8)) + } + } + return out +} + +// uiVertSrc UI 顶点着色器。 +const uiVertSrc = ` +#version 330 core +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec2 aUV; +layout(location = 2) in vec4 aColor; +uniform mat4 uProj; +out vec2 vUV; +out vec4 vColor; +void main() { + gl_Position = uProj * vec4(aPos, 0.0, 1.0); + vUV = aUV; + vColor = aColor; +} +` + +// uiFragSrc UI 片元着色器(纹理 × 颜色,alpha 混合)。 +const uiFragSrc = ` +#version 330 core +in vec2 vUV; +in vec4 vColor; +uniform sampler2D uTex; +out vec4 fragColor; +void main() { + vec4 t = texture(uTex, vUV); + fragColor = vec4(vColor.rgb, vColor.a) * vec4(1.0, 1.0, 1.0, t.a); +} +` + +// 编译着色器(与 render 包一致的简化版)。 +func compileShader(kind uint32, src string) (uint32, error) { + s := gl.CreateShader(kind) + cs, free := gl.Strs(src + "\x00") + gl.ShaderSource(s, 1, cs, nil) + free() + gl.CompileShader(s) + var ok int32 + gl.GetShaderiv(s, gl.COMPILE_STATUS, &ok) + if ok == 0 { + var logLen int32 + gl.GetShaderiv(s, gl.INFO_LOG_LENGTH, &logLen) + log := strings.Repeat(" ", int(logLen)) + gl.GetShaderInfoLog(s, logLen, nil, gl.Str(log)) + return 0, fmt.Errorf("着色器编译失败: %s", log) + } + return s, nil +} + +var _ = textr.New // 保持引用(长文本光栅化路径后置启用) diff --git a/scripts/installer.nsi b/scripts/installer.nsi new file mode 100644 index 00000000..d0c709ac --- /dev/null +++ b/scripts/installer.nsi @@ -0,0 +1,95 @@ +; 年糕历险记 —— Windows 安装脚本(NSIS 3,简体中文) +; 用法:makensis /DVERSION=1.0.0 scripts/installer.nsi +; 产物:年糕历险记-Setup-v1.0.0.exe(构建与发布.md §8) + +Unicode true +!include "MUI2.nsh" + +; ---- 产品信息(构建与发布.md §8:名称统一为 年糕历险记)---- +!ifndef VERSION + !define VERSION "0.1.0" +!endif +!define PRODUCT_NAME "年糕历险记" +!define PRODUCT_NAME_EN "NianGao Adventure" +!define PRODUCT_PUBLISHER "NianGao Studio" +!define PRODUCT_EXE "mc-client.exe" +!define INSTALLER_NAME "${PRODUCT_NAME}-Setup-v${VERSION}.exe" +!define UNINSTALL_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME_EN}" + +Name "${PRODUCT_NAME}" +OutFile "dist\${INSTALLER_NAME}" +InstallDir "$PROGRAMFILES64\${PRODUCT_NAME_EN}" +InstallDirRegKey HKLM "${UNINSTALL_KEY}" "InstallLocation" +RequestExecutionLevel admin +SetCompressor /SOLID lzma + +!define MUI_ABORTWARNING +!define MUI_ICON "..\resourcepacks\texture-pack-default1.20.5-26.2\pack.png" +!define MUI_UNICON "..\resourcepacks\texture-pack-default1.20.5-26.2\pack.png" + +; ---- 页面(全中文)---- +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +!insertmacro MUI_LANGUAGE "SimpChinese" + +; ---- 安装内容(构建与发布.md §8)---- +Section "主程序" SEC01 + SetOutPath "$INSTDIR" + File "..\bin\mc-client.exe" + File "..\bin\mc-server.exe" + + ; 配置与语言 + SetOutPath "$INSTDIR\config" + File "..\config\client.yaml" + File "..\config\server.yaml" + + ; 默认素材包 + SetOutPath "$INSTDIR\resourcepacks\texture-pack-default1.20.5-26.2" + File /r "..\resourcepacks\texture-pack-default1.20.5-26.2\*.*" + + ; 内容包投放目录与说明 + SetOutPath "$INSTDIR\mods" + File "..\mods\README.md" + + ; 世界存档目录 + CreateDirectory "$INSTDIR\worlds" + CreateDirectory "$INSTDIR\logs" + + ; 注册卸载信息 + WriteRegStr HKLM "${UNINSTALL_KEY}" "DisplayName" "${PRODUCT_NAME}" + WriteRegStr HKLM "${UNINSTALL_KEY}" "DisplayVersion" "${VERSION}" + WriteRegStr HKLM "${UNINSTALL_KEY}" "Publisher" "${PRODUCT_PUBLISHER}" + WriteRegStr HKLM "${UNINSTALL_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXE}" + WriteRegStr HKLM "${UNINSTALL_KEY}" "InstallLocation" "$INSTDIR" + WriteRegStr HKLM "${UNINSTALL_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"' + WriteRegDWORD HKLM "${UNINSTALL_KEY}" "NoModify" 1 + WriteRegDWORD HKLM "${UNINSTALL_KEY}" "NoRepair" 1 + + WriteUninstaller "$INSTDIR\uninstall.exe" + + ; 快捷方式(桌面 + 开始菜单,中文名) + CreateShortCut "$DESKTOP\${PRODUCT_NAME}.lnk" "$INSTDIR\${PRODUCT_EXE}" + CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}" + CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" "$INSTDIR\${PRODUCT_EXE}" + CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\卸载${PRODUCT_NAME}.lnk" "$INSTDIR\uninstall.exe" +SectionEnd + +; ---- 卸载 ---- +Section "Uninstall" + Delete "$DESKTOP\${PRODUCT_NAME}.lnk" + RMDir /r "$SMPROGRAMS\${PRODUCT_NAME}" + Delete "$INSTDIR\${PRODUCT_EXE}" + Delete "$INSTDIR\mc-server.exe" + Delete "$INSTDIR\uninstall.exe" + RMDir /r "$INSTDIR\config" + RMDir /r "$INSTDIR\resourcepacks" + RMDir /r "$INSTDIR\mods" + ; 保留用户存档(worlds/)与日志,不删除 + DeleteRegKey HKLM "${UNINSTALL_KEY}" +SectionEnd diff --git a/tools/zig.zip b/tools/zig.zip index 155c45bc..1d25d47f 100644 Binary files a/tools/zig.zip and b/tools/zig.zip differ