feat: NSIS 中文安装器(年糕历险记-Setup-v0.1.0.exe 实际构建+静默安装验证)、菜单鼠标可见性修复、首页 UI 原版复刻(土质背景/原版按钮/splash/版本角标)、全 goroutine 崩溃兜底
This commit is contained in:
@@ -6,22 +6,45 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Guard 在 main 顶部 defer Guard();panic 时写 crash.log 后重抛(保留退出行为)。
|
||||
var (
|
||||
logMu sync.Mutex
|
||||
logPath string
|
||||
)
|
||||
|
||||
// SetLogPath 设置崩溃日志路径(main 启动时调用一次)。
|
||||
func SetLogPath(p string) { logPath = p }
|
||||
|
||||
func writeLog(msg string) {
|
||||
logMu.Lock()
|
||||
defer logMu.Unlock()
|
||||
msg = fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02 15:04:05"), msg)
|
||||
if logPath != "" {
|
||||
_ = os.MkdirAll(filepath.Dir(logPath), 0o755)
|
||||
if f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644); err == nil {
|
||||
_, _ = f.WriteString(msg)
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprint(os.Stderr, msg)
|
||||
}
|
||||
|
||||
// Guard 在 main 顶部 defer Guard();panic 时写崩溃日志后重抛(保留退出行为)。
|
||||
func Guard(logPath string) {
|
||||
if r := recover(); r != nil {
|
||||
msg := fmt.Sprintf("[%s] 崩溃: %v\n%s\n", time.Now().Format("2006-01-02 15:04:05"), r, debug.Stack())
|
||||
if logPath != "" {
|
||||
_ = os.MkdirAll(filepath.Dir(logPath), 0o755)
|
||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err == nil {
|
||||
_, _ = f.WriteString(msg)
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprint(os.Stderr, msg)
|
||||
writeLog(fmt.Sprintf("崩溃(主线程): %v\n%s", r, debug.Stack()))
|
||||
panic(r) // 保留原始 panic 行为(退出码非 0)
|
||||
}
|
||||
}
|
||||
|
||||
// RecoverGo 非主 goroutine 的兜底:panic 写日志后终止进程
|
||||
// (Go 中任何 goroutine 未恢复的 panic 都会导致整个进程静默退出)。
|
||||
func RecoverGo(name string) {
|
||||
if r := recover(); r != nil {
|
||||
writeLog(fmt.Sprintf("崩溃(goroutine %s): %v\n%s", name, r, debug.Stack()))
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ package engine
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"mc/internal/crash"
|
||||
)
|
||||
|
||||
// Ticker 世界逻辑接口:每 tick 调用,dt 为固定 tick 间隔秒数。
|
||||
@@ -31,6 +33,7 @@ func Run(ctx context.Context, tick Ticker, render Renderer) {
|
||||
const tickInterval = time.Second / 20
|
||||
stop := make(chan struct{})
|
||||
go func() {
|
||||
defer crash.RecoverGo("engine-tick") // 非主 goroutine 兜底:panic 写崩溃日志(防静默闪退)
|
||||
t := time.NewTicker(tickInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"mc/internal/crash"
|
||||
"mc/internal/netproto"
|
||||
)
|
||||
|
||||
@@ -65,6 +66,7 @@ func Dial(addr string, hs netproto.Handshake, h Handler) (*Client, error) {
|
||||
|
||||
// readLoop 读循环:解帧分发(半包等待,协议错误断开)。
|
||||
func (c *Client) readLoop() {
|
||||
defer crash.RecoverGo("netclient-read") // 非主 goroutine 兜底(防静默闪退)
|
||||
tmp := make([]byte, 64*1024)
|
||||
for {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||
@@ -113,6 +115,7 @@ func (c *Client) dispatch(f netproto.Frame) {
|
||||
|
||||
// writeLoop 写循环。
|
||||
func (c *Client) writeLoop() {
|
||||
defer crash.RecoverGo("netclient-write") // 非主 goroutine 兜底(防静默闪退)
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
//go:build gl
|
||||
|
||||
// 界面屏幕:主菜单(中文标题「年糕历险记」)与暂停菜单(UI还原.md §2)。
|
||||
// 界面屏幕:主菜单与暂停菜单(UI还原.md §2:布局/控件/版本角标复刻原版)。
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image"
|
||||
)
|
||||
// Button 一个按钮:区域 + 悬停高亮 + 标签纹理。
|
||||
type Button struct {
|
||||
X, Y, W, H float32
|
||||
Tex uint32 // 标签纹理(预光栅化中文)
|
||||
LabelW float32
|
||||
LabelH float32
|
||||
Action string
|
||||
}
|
||||
|
||||
// TitleScreen 主菜单屏幕(UI还原.md §2:标题 + 按钮 + 背景)。
|
||||
// TitleScreen 主菜单(UI还原.md §2:土质背景 + 标题 + 原版按钮 + 版本/版权角标)。
|
||||
type TitleScreen struct {
|
||||
// 标题与按钮为预光栅化纹理(中文用 cjkfont、ASCII 用位图字体)
|
||||
TitleTex uint32
|
||||
TitleW float32
|
||||
TitleH float32
|
||||
|
||||
SplashTex uint32 // 黄色 splash 文本(原版风格)
|
||||
SplashW float32
|
||||
SplashH float32
|
||||
|
||||
BgTex uint32 // 土质背景(16px dirt 平铺预渲染)
|
||||
BtnTex uint32 // 原版按钮控件(widget/button.png 200×20)
|
||||
BtnHover uint32 // 悬停高亮(button_highlighted.png)
|
||||
Buttons []Button
|
||||
|
||||
Version string // 左下角版本(原版角标)
|
||||
Copyright string // 右下角版权
|
||||
}
|
||||
|
||||
// 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 命中检测:返回点击的按钮动作。
|
||||
// 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 {
|
||||
@@ -38,30 +41,59 @@ func (s *TitleScreen) HitTest(mx, my float32) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Draw 绘制主菜单(暗背景 + 标题 + 按钮,UI还原.md §2)。
|
||||
func (s *TitleScreen) Draw(r *Renderer) {
|
||||
// Draw 绘制主菜单(原版布局:背景 → 标题+splash → 按钮列 → 版本/版权角标)。
|
||||
func (s *TitleScreen) Draw(r *Renderer, mx, my float32) {
|
||||
w, h := float32(r.w), float32(r.h)
|
||||
// 背景(暗色遮罩)
|
||||
r.DrawRect(0, 0, w, h, [4]float32{0.08, 0.10, 0.12, 1})
|
||||
// 标题(居中偏上)
|
||||
// 土质背景(UI还原.md §2 背景)
|
||||
if s.BgTex != 0 {
|
||||
r.pushQuad(quad{x: 0, y: 0, w: w, h: h, u0: 0, v0: 0, u1: 1, v1: 1,
|
||||
r: 1, g: 1, b: 1, a: 1, tex: s.BgTex})
|
||||
} else {
|
||||
r.DrawRect(0, 0, w, h, [4]float32{0.08, 0.1, 0.12, 1})
|
||||
}
|
||||
// 标题(居中偏上,原版 logo 位置)
|
||||
if s.TitleTex != 0 {
|
||||
r.pushQuad(quad{x: w/2 - s.TitleW/2, y: h * 0.2, w: s.TitleW, h: s.TitleH,
|
||||
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})
|
||||
}
|
||||
// 按钮
|
||||
// splash(标题右下,原版黄色倾斜文本——MVP 水平放置)
|
||||
if s.SplashTex != 0 {
|
||||
r.pushQuad(quad{x: w/2 + s.TitleW/2 - s.SplashW + 20, y: h*0.15 + s.TitleH, w: s.SplashW, h: s.SplashH,
|
||||
u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.SplashTex})
|
||||
}
|
||||
// 按钮列(原版:居中偏下,200×20 控件 + 标签)
|
||||
for _, b := range s.Buttons {
|
||||
hover := mx >= b.X && mx <= b.X+b.W && my >= b.Y && my <= b.Y+b.H
|
||||
tex := s.BtnTex
|
||||
if hover && s.BtnHover != 0 {
|
||||
tex = s.BtnHover
|
||||
}
|
||||
if 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: tex})
|
||||
}
|
||||
if b.Tex != 0 {
|
||||
r.pushQuad(quad{x: b.X, y: b.Y, w: b.W, h: b.H,
|
||||
// 标签居中
|
||||
r.pushQuad(quad{x: b.X + (b.W-b.LabelW)/2, y: b.Y + (b.H-b.LabelH)/2, w: b.LabelW, h: b.LabelH,
|
||||
u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: b.Tex})
|
||||
}
|
||||
}
|
||||
// 版本角标(左下)与版权(右下,原版样式)
|
||||
if s.Version != "" {
|
||||
r.DrawText(8, h-12, s.Version, 1, [4]float32{1, 1, 1, 1})
|
||||
}
|
||||
if s.Copyright != "" {
|
||||
r.DrawText(w-r.TextWidth(s.Copyright, 1)-8, h-12, s.Copyright, 1, [4]float32{1, 1, 1, 1})
|
||||
}
|
||||
}
|
||||
|
||||
// PauseScreen 暂停菜单(UI还原.md §2)。
|
||||
// PauseScreen 暂停菜单(UI还原.md §2:半透明遮罩 + 原版按钮)。
|
||||
type PauseScreen struct {
|
||||
TitleTex uint32
|
||||
TitleW float32
|
||||
TitleH float32
|
||||
BtnTex uint32
|
||||
BtnHover uint32
|
||||
Buttons []Button
|
||||
}
|
||||
|
||||
@@ -75,8 +107,8 @@ func (s *PauseScreen) HitTest(mx, my float32) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Draw 绘制暂停菜单(半透明遮罩 + 标题 + 按钮)。
|
||||
func (s *PauseScreen) Draw(r *Renderer) {
|
||||
// Draw 绘制暂停菜单。
|
||||
func (s *PauseScreen) Draw(r *Renderer, mx, my float32) {
|
||||
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 {
|
||||
@@ -84,11 +116,18 @@ func (s *PauseScreen) Draw(r *Renderer) {
|
||||
u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.TitleTex})
|
||||
}
|
||||
for _, b := range s.Buttons {
|
||||
hover := mx >= b.X && mx <= b.X+b.W && my >= b.Y && my <= b.Y+b.H
|
||||
tex := s.BtnTex
|
||||
if hover && s.BtnHover != 0 {
|
||||
tex = s.BtnHover
|
||||
}
|
||||
if 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: tex})
|
||||
}
|
||||
if b.Tex != 0 {
|
||||
r.pushQuad(quad{x: b.X, y: b.Y, w: b.W, h: b.H,
|
||||
r.pushQuad(quad{x: b.X + (b.W-b.LabelW)/2, y: b.Y + (b.H-b.LabelH)/2, w: b.LabelW, h: b.LabelH,
|
||||
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