feat: NSIS 中文安装器(年糕历险记-Setup-v0.1.0.exe 实际构建+静默安装验证)、菜单鼠标可见性修复、首页 UI 原版复刻(土质背景/原版按钮/splash/版本角标)、全 goroutine 崩溃兜底

This commit is contained in:
NianGao Dev
2026-08-16 06:46:25 +08:00
parent 854440216f
commit 6dd5a8122a
434 changed files with 63991 additions and 106 deletions

View File

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

View File

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

View File

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

View File

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