feat(ui): 主菜单/暂停菜单屏幕状态机——中文标题「年糕历险记」、按钮点击、Esc 暂停(烟雾测试 10 秒无崩溃)

This commit is contained in:
NianGao Dev
2026-08-15 23:43:31 +08:00
parent 272f69b6a3
commit 6cc0f12079
4 changed files with 248 additions and 3 deletions

View File

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

View File

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

80
cmd/client/screens_gl.go Normal file
View File

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

94
internal/ui/screens.go Normal file
View File

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