feat(client): 客户端网络层、zh_cn 中文语言文件、NSIS 中文安装脚本、UI 渲染器与 HUD(gl 标签)

This commit is contained in:
NianGao Dev
2026-08-15 23:21:37 +08:00
parent 4167bf287d
commit 8b6b78d02c
9 changed files with 935 additions and 1 deletions

114
assets/lang/zh_cn.json Normal file
View File

@@ -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": "发送消息"
}

View File

@@ -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 passUI与输入.md §23D 之后、状态隔离)
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.1427×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,

View File

@@ -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 每 tick20Hz会话逻辑架构.md §3.2)。
func (s *Session) Tick(dt float64) {
// 物理积分(速度由输入层写入 Player.Vel

View File

@@ -0,0 +1,155 @@
// Package netclient 客户端网络层:连接服务器、登录、接收区块/方块变更、发送移动(多人同步.md §2
//
// 线程模型(架构.md §41 读 + 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() }

View 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("未收到方块变更")
}
}

107
internal/ui/hud.go Normal file
View File

@@ -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.1427×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.4182×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 §4HUD 中央进度)
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})
}
}

322
internal/ui/ui.go Normal file
View File

@@ -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 // 保持引用(长文本光栅化路径后置启用)

95
scripts/installer.nsi Normal file
View File

@@ -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

Binary file not shown.