401 lines
11 KiB
Go
401 lines
11 KiB
Go
//go:build gl
|
||
|
||
// 年糕历险记 —— 客户端入口(GL 构建)。
|
||
//
|
||
// 装配顺序(架构.md §3.1 启动流程):配置 → 日志 → 内容包/图集 → 注册表 → 世界
|
||
// → 游戏会话(玩家/背包/相机)→ 渲染器 → 主循环(tick 20Hz + render)。
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"flag"
|
||
"os"
|
||
"os/signal"
|
||
"sync"
|
||
"time"
|
||
|
||
"mc/internal/assets"
|
||
"mc/internal/atlas"
|
||
"mc/internal/block"
|
||
"mc/internal/camera"
|
||
"mc/internal/config"
|
||
"mc/internal/engine"
|
||
"mc/internal/font"
|
||
"mc/internal/game"
|
||
"mc/internal/inventory"
|
||
"mc/internal/item"
|
||
"mc/internal/logx"
|
||
"mc/internal/mesh"
|
||
"mc/internal/netclient"
|
||
"mc/internal/netproto"
|
||
"mc/internal/physics"
|
||
"mc/internal/recipe"
|
||
"mc/internal/render"
|
||
"mc/internal/save"
|
||
"mc/internal/ui"
|
||
"mc/internal/world"
|
||
"mc/internal/worldgen"
|
||
)
|
||
|
||
var devMode = flag.Bool("dev", devDefault, "开发模式:直接读取 assets/(默认随构建 tag)")
|
||
|
||
// clientApp 客户端应用:把会话与渲染粘合起来(engine.Ticker + engine.Renderer)。
|
||
type clientApp struct {
|
||
sess *game.Session
|
||
rend *render.Renderer
|
||
reg *block.Registry
|
||
in *inputState
|
||
uiR *ui.Renderer
|
||
hud *ui.HUD
|
||
|
||
// 屏幕状态机:0 主菜单 / 1 游戏中 / 2 暂停(UI还原.md §2)
|
||
screen int
|
||
titleScreen *ui.TitleScreen
|
||
pauseScreen *ui.PauseScreen
|
||
|
||
// 联机(多人同步.md §2:无连接时为单机)
|
||
net *netclient.Client
|
||
log *logx.Logger
|
||
|
||
// 其他玩家位置(多人同步.md §3;渲染插值后置)
|
||
remoteMu sync.Mutex
|
||
remotePlayers map[uint32][3]float64
|
||
|
||
meshBuilder *mesh.Builder
|
||
|
||
mu sync.Mutex
|
||
pendingMesh []*worldMeshJob // tick 构建 → 渲染线程上传
|
||
|
||
winW, winH int
|
||
tickCnt uint64
|
||
lastTime time.Time
|
||
}
|
||
|
||
// worldMeshJob 待上传网格。
|
||
type worldMeshJob struct {
|
||
cx, cz int32
|
||
m *mesh.ChunkMesh
|
||
}
|
||
|
||
// Tick 每 tick(20Hz):输入 → 玩家 → 会话 → 网格构建(渲染.md §3.3 dirty 重建)。
|
||
func (a *clientApp) Tick(dt float64) {
|
||
// 屏幕状态机(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 "multi":
|
||
// 联机:连接本机服务器(多人同步.md §2)
|
||
if err := dialServer(a, "127.0.0.1:25565", "玩家"); err != nil {
|
||
a.logf("连接服务器失败: %v", err)
|
||
} else {
|
||
a.screen = 1
|
||
a.rend.SetCursorMode(true)
|
||
a.hud.OnlineCount = func() int {
|
||
a.remoteMu.Lock()
|
||
defer a.remoteMu.Unlock()
|
||
return len(a.remotePlayers) + 1 // 含自己
|
||
}
|
||
a.logf("已连接服务器")
|
||
}
|
||
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)
|
||
|
||
// 联机:上报移动(20Hz,多人同步.md §3)
|
||
if a.net != nil {
|
||
p := a.sess.Player
|
||
a.net.SendMove(netproto.PlayerMove{
|
||
X: float32(p.Pos[0]), Y: float32(p.Pos[1]), Z: float32(p.Pos[2]),
|
||
Yaw: float32(a.sess.Cam.Yaw), Pitch: float32(a.sess.Cam.Pitch),
|
||
OnGround: p.OnGround,
|
||
})
|
||
}
|
||
|
||
// 定时存档:30s(存档与持久化.md §2)
|
||
a.tickCnt++
|
||
if a.tickCnt%(20*30) == 0 && a.net == nil {
|
||
if err := save.SaveWorld(a.sess.World, "worlds", "NewWorld", 123456789, int64(a.tickCnt)); err != nil {
|
||
// 日志由调用方输出(避免每次 tick 刷屏)
|
||
_ = err
|
||
}
|
||
}
|
||
|
||
// 脏区块网格重建(CPU 侧,预算内)
|
||
start := time.Now()
|
||
for _, c := range a.sess.World.ActiveChunks() {
|
||
if time.Since(start) > 6*time.Millisecond {
|
||
break // 帧预算(区块管理.md §4.3.2)
|
||
}
|
||
if !c.Dirty.Load() {
|
||
continue
|
||
}
|
||
m := a.meshBuilder.Build(c, func(x, y, z int32) block.State {
|
||
return a.sess.World.Block(x, y, z)
|
||
})
|
||
a.mu.Lock()
|
||
a.pendingMesh = append(a.pendingMesh, &worldMeshJob{cx: c.CX, cz: c.CZ, m: m})
|
||
a.mu.Unlock()
|
||
c.Dirty.Store(false)
|
||
}
|
||
}
|
||
|
||
// Render 每帧渲染(渲染线程):上传待传网格 → 视图矩阵 → 三 pass 绘制。
|
||
func (a *clientApp) Render() {
|
||
// 上传 tick 构建好的网格(GL 调用必须在渲染线程)
|
||
a.mu.Lock()
|
||
pending := a.pendingMesh
|
||
a.pendingMesh = nil
|
||
a.mu.Unlock()
|
||
for _, j := range pending {
|
||
a.rend.UploadChunk(j.cx, j.cz, j.m)
|
||
mesh.Release(j.m) // 上传完毕归还顶点池(性能与内存.md §2)
|
||
}
|
||
|
||
// 视图投影
|
||
cam := a.sess.Cam
|
||
aspect := float64(a.winW) / float64(a.winH)
|
||
a.rend.SetViewProj(cam.View(), cam.Projection(aspect, 0.05, 1024))
|
||
|
||
// 清屏 + 绘制(视锥剔除简化为全量绘制,后置 LOD)
|
||
a.rend.BeginFrame()
|
||
chunks := a.sess.World.ActiveChunks()
|
||
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()
|
||
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()
|
||
}
|
||
|
||
// ShouldClose 窗口关闭判定。
|
||
func (a *clientApp) ShouldClose() bool { return a.rend.ShouldClose() }
|
||
|
||
// logf 客户端日志(联机回调使用)。
|
||
func (a *clientApp) logf(format string, args ...any) {
|
||
if a.log != nil {
|
||
a.log.Infof(format, args...)
|
||
}
|
||
}
|
||
|
||
func main() {
|
||
flag.Parse()
|
||
|
||
log, err := logx.New("logs/client.log", logx.LevelInfo)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
cfg, err := config.Load("config/client.yaml", config.DefaultClient())
|
||
if err != nil {
|
||
log.Warnf("配置加载失败,使用默认值:%v", err)
|
||
}
|
||
log.Infof("%s 客户端启动:%dx%d,语言 %s,视距 %d", cfg.Client.WindowTitle, cfg.Client.WindowWidth, cfg.Client.WindowHeight, cfg.Client.Language, cfg.Client.RenderDistance)
|
||
|
||
// 1) 内容包与图集(设计.md §1.3:扫描 mods/ + resourcepacks/)
|
||
am := assets.New()
|
||
if err := am.Load("assets", "mods", "resourcepacks"); err != nil {
|
||
log.Warnf("内容包加载失败:%v", err)
|
||
}
|
||
log.Infof("已加载 %d 个内容包", len(am.Packs()))
|
||
|
||
keys, err := am.ExpandAtlas("blocks")
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
atlasImg, uvRects, err := atlas.Build(am.ResolveTexture, keys, 16)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
log.Infof("blocks 图集:%d 纹理,%dx%d", len(uvRects), atlasImg.Bounds().Dx(), atlasImg.Bounds().Dy())
|
||
|
||
// UV 函数(渲染.md §4:inset 防渗色)
|
||
uvFn := func(texKey string) (u0, v0, u1, v1 float32, ok bool) {
|
||
rect, found := uvRects[texKey]
|
||
if !found {
|
||
return 0, 0, 1, 1, false
|
||
}
|
||
W, H := float32(atlasImg.Bounds().Dx()), float32(atlasImg.Bounds().Dy())
|
||
const inset = 0.5
|
||
u0 = (float32(rect.Min.X) + inset) / W
|
||
v0 = (float32(rect.Min.Y) + inset) / H
|
||
u1 = (float32(rect.Max.X) - inset) / W
|
||
v1 = (float32(rect.Max.Y) - inset) / H
|
||
return u0, v0, u1, v1, true
|
||
}
|
||
|
||
// items 图集(HUD 热键栏图标,UI还原.md §7)
|
||
itemKeys, err := am.ExpandAtlas("items")
|
||
if err != nil {
|
||
log.Warnf("items 图集展开失败: %v", err)
|
||
}
|
||
itemImg, itemUVs, err := atlas.Build(am.ResolveTexture, itemKeys, 16)
|
||
if err != nil {
|
||
log.Warnf("items 图集构建失败: %v", err)
|
||
}
|
||
|
||
// 字体(UI还原.md §4:素材包位图字体)
|
||
fontAtlas, err := font.Build(am, "default")
|
||
if err != nil {
|
||
log.Warnf("字体构建失败: %v", err)
|
||
}
|
||
|
||
// 2) 注册表(设计.md §6.1/§6.2 数据驱动)
|
||
reg, err := block.Load("assets/config/blocks.json")
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
items, err := item.Load("assets/config/items.json")
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
recipes, err := recipe.Load("assets/config/recipes.json")
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
|
||
// 3) 世界与生成器(区块管理.md §4 异步流水线)
|
||
gen, err := worldgen.New(reg, 123456789, 0.005, 0.01, 0.1)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
w, err := world.New(reg, gen, cfg.Client.RenderDistance)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
defer w.Close()
|
||
|
||
// 单机存档(存档与持久化.md §3:diff-only 载入,无存档则新建)
|
||
if _, err := os.Stat("worlds/NewWorld/level.dat"); err == nil {
|
||
if _, err := save.LoadWorld(w, "worlds", "NewWorld"); err != nil {
|
||
log.Warnf("存档载入失败: %v", err)
|
||
} else {
|
||
log.Infof("已载入世界 NewWorld")
|
||
}
|
||
}
|
||
|
||
// 4) 游戏会话(玩家先高空下落,区块加载后落地)
|
||
p := physics.NewPlayer(8.5, 100, 8.5)
|
||
cam := camera.New(p.Eye(), 0, 0, cfg.Client.FOV)
|
||
inv := inventory.New(items)
|
||
sess := game.NewSession(w, reg, items, recipes, p, inv, cam)
|
||
|
||
// 5) 渲染器(GL 线程亲和在其内部锁定)
|
||
rend, err := render.New(cfg.Client.WindowTitle, cfg.Client.WindowWidth, cfg.Client.WindowHeight)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
defer rend.Shutdown()
|
||
rend.SetAtlas(atlasImg)
|
||
rend.SetCursorMode(true)
|
||
|
||
// 光影包(渲染.md §9:未安装 → 默认光影;坏包 → 回退默认)
|
||
if sp, err := render.LoadShaderPack(am); err != nil {
|
||
log.Warnf("光影包加载失败(使用默认光影):%v", err)
|
||
} else if sp != nil {
|
||
if err := rend.ApplyShaderPack(sp); err != nil {
|
||
log.Warnf("光影包应用失败(使用默认光影):%v", err)
|
||
} else {
|
||
log.Infof("已加载光影包 %q", sp.ID)
|
||
}
|
||
}
|
||
|
||
// UI 层(UI还原.md §3.1:427×240 基准 × 3 缩放 = 1280×720)
|
||
guiScale := float32(3)
|
||
uiR, err := ui.New(cfg.Client.WindowWidth, cfg.Client.WindowHeight, guiScale)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
if fontAtlas != nil {
|
||
uiR.SetFont(fontAtlas)
|
||
}
|
||
hud := ui.NewHUD(sess, items, 427, 240, guiScale)
|
||
if itemImg != nil {
|
||
itemTex := uiR.UploadImage(itemImg)
|
||
itemUV := make(map[string][4]float32, len(itemUVs))
|
||
for k, rect := range itemUVs {
|
||
W, H := float32(itemImg.Bounds().Dx()), float32(itemImg.Bounds().Dy())
|
||
itemUV[k] = [4]float32{
|
||
float32(rect.Min.X) / W, float32(rect.Min.Y) / H,
|
||
float32(rect.Max.X) / W, float32(rect.Max.Y) / H,
|
||
}
|
||
}
|
||
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())
|
||
|
||
app := &clientApp{
|
||
sess: sess,
|
||
rend: rend,
|
||
reg: reg,
|
||
in: in,
|
||
uiR: uiR,
|
||
hud: hud,
|
||
titleScreen: titleScreen,
|
||
pauseScreen: pauseScreen,
|
||
log: log,
|
||
remotePlayers: make(map[uint32][3]float64),
|
||
meshBuilder: mesh.NewBuilder(reg, uvFn),
|
||
winW: cfg.Client.WindowWidth,
|
||
winH: cfg.Client.WindowHeight,
|
||
lastTime: time.Now(),
|
||
}
|
||
|
||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||
defer stop()
|
||
|
||
log.Infof("进入游戏主循环")
|
||
engine.Run(ctx, app, app)
|
||
log.Infof("客户端已退出")
|
||
}
|