diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go new file mode 100644 index 00000000..7555a0c1 --- /dev/null +++ b/cmd/client/client_gl.go @@ -0,0 +1,215 @@ +//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/game" + "mc/internal/inventory" + "mc/internal/item" + "mc/internal/logx" + "mc/internal/mesh" + "mc/internal/physics" + "mc/internal/recipe" + "mc/internal/render" + "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 + + meshBuilder *mesh.Builder + + mu sync.Mutex + pendingMesh []*worldMeshJob // tick 构建 → 渲染线程上传 + + winW, winH int + 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 §6 动作映射) + a.in.apply(a.sess) + a.sess.Tick(dt) + + // 脏区块网格重建(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) + } + + // 视图投影 + 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)) + a.rend.EndFrame() +} + +// ShouldClose 窗口关闭判定。 +func (a *clientApp) ShouldClose() bool { return a.rend.ShouldClose() } + +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 + } + + // 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() + + // 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) + + // 6) 输入(鼠标捕获 + 键鼠回调) + in := newInputState(rend.Window()) + + app := &clientApp{ + sess: sess, + rend: rend, + reg: reg, + in: in, + 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("客户端已退出") +} diff --git a/cmd/client/input.go b/cmd/client/input.go index 1b8a16e3..14b4649d 100644 --- a/cmd/client/input.go +++ b/cmd/client/input.go @@ -1,3 +1,5 @@ +//go:build gl + // 客户端输入处理:键鼠状态采集(UI与输入.md §6)。 // // 设计:回调只更新状态,游戏逻辑在 tick 里消费状态(主循环解耦)。 diff --git a/cmd/client/main.go b/cmd/client/main.go index 4b1c2920..97a8b1df 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -1,217 +1,22 @@ -// 年糕历险记 —— 客户端入口。 +//go:build !gl + +// 年糕历险记 —— 客户端入口(无 GL 构建:提示需要 C 工具链)。 // -// 装配顺序(架构.md §3.1 启动流程):配置 → 日志 → 内容包/图集 → 注册表 → 世界 -// → 游戏会话(玩家/背包/相机)→ 渲染器 → 主循环(tick 20Hz + render)。 +// 渲染层(internal/render)需要 CGO(GLFW/OpenGL),未启用 gl 构建标签时 +// 客户端只打印提示。完整客户端见 client_gl.go(-tags gl)。 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/game" - "mc/internal/inventory" - "mc/internal/item" - "mc/internal/logx" - "mc/internal/mesh" - "mc/internal/physics" - "mc/internal/recipe" - "mc/internal/render" - "mc/internal/world" - "mc/internal/worldgen" + "fmt" ) 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 - - meshBuilder *mesh.Builder - - mu sync.Mutex - pendingMesh []*worldMeshJob // tick 构建 → 渲染线程上传 - - winW, winH int - 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 §6 动作映射) - a.in.apply(a.sess) - a.sess.Tick(dt) - - // 脏区块网格重建(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) - } - - // 视图投影 - cam := a.sess.Cam - aspect := float64(a.cfgW()) / float64(a.cfgH()) - 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)) - a.rend.EndFrame() -} - -// ShouldClose 窗口关闭判定。 -func (a *clientApp) ShouldClose() bool { return a.rend.ShouldClose() } - -// cfgW / cfgH 窗口尺寸(渲染用)。 -func (a *clientApp) cfgW() int { return a.winW } -func (a *clientApp) cfgH() int { return a.winH } - 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 - } - - // 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, cfg.Client.RenderDistance/2000.0+0.004, 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() - - // 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) - - // 6) 输入(鼠标捕获 + 键鼠回调) - in := newInputState(rend.Window()) - - app := &clientApp{ - sess: sess, - rend: rend, - reg: reg, - in: in, - 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("客户端已退出") + fmt.Println("年糕历险记 客户端:本构建未启用 GL 渲染。") + fmt.Println("请先准备 C 工具链(tools/zig 或 mingw-w64),然后用以下方式构建:") + fmt.Println(" go build -tags \"gl dev\" ./cmd/client") + fmt.Println("或直接运行 scripts/build.ps1。") } diff --git a/internal/font/font_test.go b/internal/font/font_test.go index 1a171f78..76cafea9 100644 --- a/internal/font/font_test.go +++ b/internal/font/font_test.go @@ -27,11 +27,11 @@ func TestBuild(t *testing.T) { t.Fatalf("字形 %q 宽度期望 8,实际 %d", ch, g.W) } } - // 主位图应为 128×128(16×16 网格) + // 主位图:宽 128(16 列 × 8px),高为 8 的整数倍(原版 ascii.png 为 128×536) if at.Image == nil { t.Fatal("主位图为空") } - if b := at.Image.Bounds(); b.Dx() != 128 || b.Dy() != 128 { - t.Fatalf("ascii 位图期望 128×128,实际 %dx%d", b.Dx(), b.Dy()) + if b := at.Image.Bounds(); b.Dx() != 128 || b.Dy()%8 != 0 || b.Dy() < 128 { + t.Fatalf("ascii 位图尺寸异常: %dx%d", b.Dx(), b.Dy()) } } diff --git a/internal/render/mesh_gl.go b/internal/render/mesh_gl.go index 756314b9..22813e5a 100644 --- a/internal/render/mesh_gl.go +++ b/internal/render/mesh_gl.go @@ -1,3 +1,5 @@ +//go:build gl + package render import ( diff --git a/internal/render/renderer.go b/internal/render/renderer.go index ae881b62..4cb8f7e4 100644 --- a/internal/render/renderer.go +++ b/internal/render/renderer.go @@ -1,3 +1,5 @@ +//go:build gl + // Package render 渲染层:GLFW 窗口 + OpenGL 3.3 core 上下文 + 默认光影着色器(场景/渲染.md)。 // // 线程铁律(渲染.md §2): diff --git a/scripts/build.ps1 b/scripts/build.ps1 index b40932b8..f441b95a 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -1,39 +1,42 @@ # 年糕历险记 构建脚本(Windows) # 用法:pwsh -File scripts/build.ps1 [-Release] -# 自动准备便携 MinGW-w64(GLFW 需要 CGO),然后构建 client/server。 +# 自动定位 C 工具链(tools/ 下的 zig 或 mingw64/w64devkit),构建 GL 客户端与服务器。 param([switch]$Release) $ErrorActionPreference = "Stop" $root = Split-Path $PSScriptRoot -Parent $tools = Join-Path $root "tools" -$mingw = Join-Path $tools "mingw64" -$gcc = Join-Path $mingw "bin\gcc.exe" -if (-not (Test-Path $gcc)) { - Write-Host "== 未找到 C 编译器(GLFW 需要),下载便携 MinGW-w64 ==" - New-Item -ItemType Directory -Force -Path $tools | Out-Null - $url = "https://github.com/brechtsanders/winlibs_mingw/releases/download/14.2.0posix-19.1.1-12.0.0-ucrt-r1/winlibs-x86_64-posix-seh-gcc-14.2.0-mingw-w64ucrt-12.0.0-r1.zip" - $zip = Join-Path $env:TEMP "winlibs-mingw.zip" - Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing - Expand-Archive -Path $zip -DestinationPath $tools -Force - Remove-Item $zip -Force - if (-not (Test-Path $gcc)) { - throw "MinGW 准备失败:请手动安装 mingw-w64 并重试,或检查网络。" +# 定位 C 编译器(GLFW 需要 CGO):优先 zig(zig cc),其次 mingw64/w64devkit 的 gcc +$cc = $null +$zig = Get-ChildItem $tools -Recurse -Filter zig.exe -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($zig) { + $cc = "$($zig.FullName) cc" + Write-Host "== 使用 zig 作为 C 编译器: $($zig.FullName) ==" +} else { + $gcc = Get-ChildItem $tools -Recurse -Filter gcc.exe -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($gcc) { + $cc = $gcc.FullName + $env:Path = "$(Split-Path $gcc.FullName);$env:Path" + Write-Host "== 使用 gcc: $($gcc.FullName) ==" + } else { + Write-Host "== 未找到 C 工具链:请把 zig(或 MinGW)放到 tools/ 后重试。" + Write-Host " 下载:https://ziglang.org/download/(zig-x86_64-windows-*.zip)" + Write-Host " 或: https://github.com/brechtsanders/winlibs_mingw/releases(winlibs zip)" + exit 1 } } +$env:CC = $cc +$env:CGO_ENABLED = "1" -$env:CC = $gcc -$env:Path = "$(Join-Path $mingw 'bin');$env:Path" - -$tags = "" -$ldflags = "-s -w" -if (-not $Release) { $tags = "-tags dev" } +$tags = "gl" +if (-not $Release) { $tags = "$tags dev" } Push-Location $root try { New-Item -ItemType Directory -Force -Path bin | Out-Null - go build $tags -trimpath -ldflags $ldflags -o bin/mc-client.exe ./cmd/client - go build $tags -trimpath -ldflags $ldflags -o bin/mc-server.exe ./cmd/server + go build -tags $tags -trimpath -ldflags "-s -w" -o bin/mc-client.exe ./cmd/client + go build -trimpath -ldflags "-s -w" -o bin/mc-server.exe ./cmd/server } finally { Pop-Location } diff --git a/tools/zig.zip b/tools/zig.zip index ceda178c..8bb276df 100644 Binary files a/tools/zig.zip and b/tools/zig.zip differ