//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("客户端已退出") }