feat(ui): 主菜单/暂停菜单屏幕状态机——中文标题「年糕历险记」、按钮点击、Esc 暂停(烟雾测试 10 秒无崩溃)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
80
cmd/client/screens_gl.go
Normal 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
94
internal/ui/screens.go
Normal 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 引用
|
||||
Reference in New Issue
Block a user