Files
ngzz-mc/cmd/client/client_gl.go

401 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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 每 tick20Hz输入 → 玩家 → 会话 → 网格构建(渲染.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 passUI与输入.md §23D 之后、状态隔离)
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 §4inset 防渗色)
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 §3diff-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.1427×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("客户端已退出")
}