From 6cc0f120794d10fbc0c0b91f257d9409a5a690c1 Mon Sep 17 00:00:00 2001 From: NianGao Dev Date: Sat, 15 Aug 2026 23:43:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E4=B8=BB=E8=8F=9C=E5=8D=95/?= =?UTF-8?q?=E6=9A=82=E5=81=9C=E8=8F=9C=E5=8D=95=E5=B1=8F=E5=B9=95=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9C=BA=E2=80=94=E2=80=94=E4=B8=AD=E6=96=87=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E3=80=8C=E5=B9=B4=E7=B3=95=E5=8E=86=E9=99=A9=E8=AE=B0?= =?UTF-8?q?=E3=80=8D=E3=80=81=E6=8C=89=E9=92=AE=E7=82=B9=E5=87=BB=E3=80=81?= =?UTF-8?q?Esc=20=E6=9A=82=E5=81=9C=EF=BC=88=E7=83=9F=E9=9B=BE=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=2010=20=E7=A7=92=E6=97=A0=E5=B4=A9=E6=BA=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/client/client_gl.go | 65 ++++++++++++++++++++++++++- cmd/client/input.go | 12 ++++- cmd/client/screens_gl.go | 80 ++++++++++++++++++++++++++++++++++ internal/ui/screens.go | 94 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 cmd/client/screens_gl.go create mode 100644 internal/ui/screens.go diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go index 24c59aa1..a9af6238 100644 --- a/cmd/client/client_gl.go +++ b/cmd/client/client_gl.go @@ -45,6 +45,11 @@ type clientApp struct { uiR *ui.Renderer hud *ui.HUD + // 屏幕状态机:0 主菜单 / 1 游戏中 / 2 暂停(UI还原.md §2) + screen int + titleScreen *ui.TitleScreen + pauseScreen *ui.PauseScreen + meshBuilder *mesh.Builder mu sync.Mutex @@ -62,7 +67,46 @@ type worldMeshJob struct { // Tick 每 tick(20Hz):输入 → 玩家 → 会话 → 网格构建(渲染.md §3.3 dirty 重建)。 func (a *clientApp) Tick(dt float64) { - // 输入 → 玩家速度(UI与输入.md §6 动作映射) + // 屏幕状态机(UI还原.md §2:主菜单/暂停不处理游戏输入) + switch a.screen { + case 0: // 主菜单 + if a.in.clicked { + switch a.titleScreen.HitTest(a.in.clickX, a.in.clickY) { + case "play": + a.screen = 1 + a.rend.SetCursorMode(true) + case "quit": + a.rend.Window().SetShouldClose(true) + } + a.in.clicked = false + } + a.in.dyaw, a.in.dpitch = 0, 0 + return + case 2: // 暂停 + if a.in.escPressed { + a.screen = 1 + a.rend.SetCursorMode(true) + } + if a.in.clicked { + switch a.pauseScreen.HitTest(a.in.clickX, a.in.clickY) { + case "resume": + a.screen = 1 + a.rend.SetCursorMode(true) + case "quit": + a.rend.Window().SetShouldClose(true) + } + a.in.clicked = false + } + a.in.escPressed = false + return + } + // 游戏中 + if a.in.escPressed { + a.screen = 2 + a.rend.SetCursorMode(false) + a.in.escPressed = false + return + } a.in.apply(a.sess) a.sess.Tick(dt) @@ -107,7 +151,15 @@ func (a *clientApp) Render() { 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) + switch a.screen { + case 0: + a.titleScreen.Draw(a.uiR) + case 1: + a.hud.Draw(a.uiR) + case 2: + a.hud.Draw(a.uiR) // 暂停时世界仍可见(UI还原.md §2) + a.pauseScreen.Draw(a.uiR) + } a.uiR.Flush() a.rend.EndFrame() } @@ -250,6 +302,13 @@ func main() { hud.SetItemAtlas(itemTex, itemUV) } + // 界面屏幕(中文标题「年糕历险记」+ 按钮,UI还原.md §2) + lang, err := am.LoadLang(cfg.Client.Language) + if err != nil { + log.Warnf("语言加载失败: %v", err) + } + titleScreen, pauseScreen := buildScreens(uiR, lang) + // 6) 输入(鼠标捕获 + 键鼠回调) in := newInputState(rend.Window()) @@ -260,6 +319,8 @@ func main() { in: in, uiR: uiR, hud: hud, + titleScreen: titleScreen, + pauseScreen: pauseScreen, meshBuilder: mesh.NewBuilder(reg, uvFn), winW: cfg.Client.WindowWidth, winH: cfg.Client.WindowHeight, diff --git a/cmd/client/input.go b/cmd/client/input.go index 0728fc61..bbee1f30 100644 --- a/cmd/client/input.go +++ b/cmd/client/input.go @@ -31,6 +31,12 @@ type inputState struct { breakHeld bool placeDown bool selectSlot int // -1 无 + + // 鼠标与 UI 事件(UI与输入.md §6 屏幕交互) + cursorX, cursorY float64 + clickX, clickY float32 + clicked bool + escPressed bool } // newInputState 创建输入状态并注册回调。 @@ -63,7 +69,7 @@ func (in *inputState) keyCallback(_ *glfw.Window, key glfw.Key, _ int, action gl in.sprint = down case glfw.KeyEscape: if action == glfw.Press { - in.win.SetShouldClose(true) + in.escPressed = true // 暂停/继续由屏幕状态机处理 } case glfw.Key1, glfw.Key2, glfw.Key3, glfw.Key4, glfw.Key5, glfw.Key6, glfw.Key7, glfw.Key8, glfw.Key9: if action == glfw.Press { @@ -78,6 +84,9 @@ func (in *inputState) mouseButtonCallback(_ *glfw.Window, button glfw.MouseButto case glfw.MouseButtonLeft: if action == glfw.Press { in.breakDown = true + in.clicked = true + in.clickX = float32(in.cursorX) + in.clickY = float32(in.cursorY) } in.breakHeld = action != glfw.Release case glfw.MouseButtonRight: @@ -89,6 +98,7 @@ func (in *inputState) mouseButtonCallback(_ *glfw.Window, button glfw.MouseButto // cursorCallback 鼠标移动:yaw/pitch 累积(渲染.md §2 主线程消费)。 func (in *inputState) cursorCallback(_ *glfw.Window, x, y float64) { + in.cursorX, in.cursorY = x, y if in.firstMouse { in.lastX, in.lastY = x, y in.firstMouse = false diff --git a/cmd/client/screens_gl.go b/cmd/client/screens_gl.go new file mode 100644 index 00000000..caee0ee8 --- /dev/null +++ b/cmd/client/screens_gl.go @@ -0,0 +1,80 @@ +//go:build gl + +// 界面屏幕构建:中文标题与按钮(UI还原.md §2、§4 中文回退)。 +package main + +import ( + "image/color" + "os" + "path/filepath" + + "mc/internal/assets" + "mc/internal/cjkfont" + "mc/internal/ui" +) + +// cjkPaths 系统 CJK 字体候选。 +var cjkPaths = []string{ + filepath.Join(os.Getenv("WINDIR"), "Fonts", "msyh.ttc"), + filepath.Join(os.Getenv("WINDIR"), "Fonts", "msyh.ttf"), +} + +// buildScreens 构建主菜单与暂停菜单(按钮纹理 = 中文文本光栅化)。 +func buildScreens(r *ui.Renderer, lang *assets.Lang) (*ui.TitleScreen, *ui.PauseScreen) { + cjk := loadCJK() + text := func(key, fallback string, size float64) (uint32, float32, float32) { + label := fallback + if lang != nil { + label = lang.Get(key) + } + if cjk != nil { + img := cjk.Draw(label, color.RGBA{255, 255, 255, 255}) + tex := r.UploadImage(img) + return tex, float32(img.Bounds().Dx()), float32(img.Bounds().Dy()) + } + return 0, 0, 0 + } + + titleTex, titleW, titleH := text("menu.title", "年糕历险记", 48) + if titleW == 0 { + titleW, titleH = 320, 48 // 无 CJK 字体时占位 + } + + // 主菜单按钮(居中列,UI还原.md §2) + btn := func(key, fallback, action string, y float32, w, h int) ui.Button { + tex, tw, th := text(key, fallback, 24) + x := float32(w)/2 - tw/2 + return ui.Button{X: x, Y: y, W: tw, H: th, Tex: tex, Action: action} + } + title := &ui.TitleScreen{} + title.TitleTex = titleTex + title.TitleW = titleW + title.TitleH = titleH + title.Buttons = []ui.Button{ + btn("menu.singleplayer", "单人游戏", "play", 300, 1280, 720), + btn("menu.multiplayer", "多人游戏", "", 350, 1280, 720), + btn("menu.quit", "退出游戏", "quit", 400, 1280, 720), + } + _ = titleTex + _ = titleH + + pause := &ui.PauseScreen{} + pause.TitleTex, pause.TitleW, pause.TitleH = text("menu.game", "游戏菜单", 32) + pause.Buttons = []ui.Button{ + btn("gui.resume", "继续游戏", "resume", 300, 1280, 720), + btn("menu.quit", "保存并退出", "quit", 350, 1280, 720), + } + return title, pause +} + +// loadCJK 加载系统 CJK 字体(失败返回 nil → 按钮退化不显示文本)。 +func loadCJK() *cjkfont.Renderer { + for _, p := range cjkPaths { + if _, err := os.Stat(p); err == nil { + if r, err := cjkfont.Load(p, 24); err == nil { + return r + } + } + } + return nil +} diff --git a/internal/ui/screens.go b/internal/ui/screens.go new file mode 100644 index 00000000..ca4e7051 --- /dev/null +++ b/internal/ui/screens.go @@ -0,0 +1,94 @@ +//go:build gl + +// 界面屏幕:主菜单(中文标题「年糕历险记」)与暂停菜单(UI还原.md §2)。 +package ui + +import ( + "image" +) + +// TitleScreen 主菜单屏幕(UI还原.md §2:标题 + 按钮 + 背景)。 +type TitleScreen struct { + // 标题与按钮为预光栅化纹理(中文用 cjkfont、ASCII 用位图字体) + TitleTex uint32 + TitleW float32 + TitleH float32 + Buttons []Button +} + +// Button 一个按钮(点击区域 + 纹理)。 +type Button struct { + X, Y, W, H float32 + Tex uint32 + Action string // "play" / "quit" / "resume" +} + +// NewTitleScreen 创建主菜单(textures 由调用方预上传:标题与按钮标签)。 +func NewTitleScreen(titleTex uint32, titleW, titleH float32, buttons []Button) *TitleScreen { + return &TitleScreen{TitleTex: titleTex, TitleW: titleW, TitleH: titleH, Buttons: buttons} +} + +// HitTest 命中检测:返回点击的按钮动作。 +func (s *TitleScreen) HitTest(mx, my float32) string { + for _, b := range s.Buttons { + if mx >= b.X && mx <= b.X+b.W && my >= b.Y && my <= b.Y+b.H { + return b.Action + } + } + return "" +} + +// Draw 绘制主菜单(暗背景 + 标题 + 按钮,UI还原.md §2)。 +func (s *TitleScreen) Draw(r *Renderer) { + w, h := float32(r.w), float32(r.h) + // 背景(暗色遮罩) + r.DrawRect(0, 0, w, h, [4]float32{0.08, 0.10, 0.12, 1}) + // 标题(居中偏上) + if s.TitleTex != 0 { + r.pushQuad(quad{x: w/2 - s.TitleW/2, y: h * 0.2, w: s.TitleW, h: s.TitleH, + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.TitleTex}) + } + // 按钮 + for _, b := range s.Buttons { + if b.Tex != 0 { + r.pushQuad(quad{x: b.X, y: b.Y, w: b.W, h: b.H, + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: b.Tex}) + } + } +} + +// PauseScreen 暂停菜单(UI还原.md §2)。 +type PauseScreen struct { + TitleTex uint32 + TitleW float32 + TitleH float32 + Buttons []Button +} + +// HitTest 命中检测。 +func (s *PauseScreen) HitTest(mx, my float32) string { + for _, b := range s.Buttons { + if mx >= b.X && mx <= b.X+b.W && my >= b.Y && my <= b.Y+b.H { + return b.Action + } + } + return "" +} + +// Draw 绘制暂停菜单(半透明遮罩 + 标题 + 按钮)。 +func (s *PauseScreen) Draw(r *Renderer) { + w, h := float32(r.w), float32(r.h) + r.DrawRect(0, 0, w, h, [4]float32{0, 0, 0, 0.6}) + if s.TitleTex != 0 { + r.pushQuad(quad{x: w/2 - s.TitleW/2, y: h * 0.15, w: s.TitleW, h: s.TitleH, + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.TitleTex}) + } + for _, b := range s.Buttons { + if b.Tex != 0 { + r.pushQuad(quad{x: b.X, y: b.Y, w: b.W, h: b.H, + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: b.Tex}) + } + } +} + +var _ image.Image // 保持 image 引用