diff --git a/cmd/client/input.go b/cmd/client/input.go new file mode 100644 index 00000000..1b8a16e3 --- /dev/null +++ b/cmd/client/input.go @@ -0,0 +1,199 @@ +// 客户端输入处理:键鼠状态采集(UI与输入.md §6)。 +// +// 设计:回调只更新状态,游戏逻辑在 tick 里消费状态(主循环解耦)。 +package main + +import ( + "math" + + "mc/internal/game" + "mc/internal/physics" + + "github.com/go-gl/glfw/v3.3/glfw" +) + +// inputState 键鼠输入状态。 +type inputState struct { + win *glfw.Window + + // 按键状态 + forward, back, left, right, jump, sneak, sprint bool + + // 鼠标 + lastX, lastY float64 + firstMouse bool + dyaw, dpitch float64 // 累计角度增量(弧度,tick 消费) + + // 按键事件(tick 消费) + breakDown bool + breakHeld bool + placeDown bool + selectSlot int // -1 无 +} + +// newInputState 创建输入状态并注册回调。 +func newInputState(win *glfw.Window) *inputState { + in := &inputState{win: win, firstMouse: true, selectSlot: -1} + win.SetKeyCallback(in.keyCallback) + win.SetMouseButtonCallback(in.mouseButtonCallback) + win.SetCursorPosCallback(in.cursorCallback) + win.SetScrollCallback(in.scrollCallback) + return in +} + +// keyCallback 键盘回调(动作映射:WASD/空格/Shift/Ctrl/Esc)。 +func (in *inputState) keyCallback(_ *glfw.Window, key glfw.Key, _ int, action glfw.Action, _ glfw.KeyMods) { + down := action != glfw.Release + switch key { + case glfw.KeyW: + in.forward = down + case glfw.KeyS: + in.back = down + case glfw.KeyA: + in.left = down + case glfw.KeyD: + in.right = down + case glfw.KeySpace: + in.jump = down + case glfw.KeyLeftShift: + in.sneak = down + case glfw.KeyLeftControl: + in.sprint = down + case glfw.KeyEscape: + if action == glfw.Press { + in.win.SetShouldClose(true) + } + case glfw.Key1, glfw.Key2, glfw.Key3, glfw.Key4, glfw.Key5, glfw.Key6, glfw.Key7, glfw.Key8, glfw.Key9: + if action == glfw.Press { + in.selectSlot = int(key) - int(glfw.Key1) + } + } +} + +// mouseButtonCallback 鼠标按键:左键破坏(按住累积)、右键放置(UI与输入.md §6 长按)。 +func (in *inputState) mouseButtonCallback(_ *glfw.Window, button glfw.MouseButton, action glfw.Action, _ glfw.ModifierKey) { + switch button { + case glfw.MouseButtonLeft: + if action == glfw.Press { + in.breakDown = true + } + in.breakHeld = action != glfw.Release + case glfw.MouseButtonRight: + if action == glfw.Press { + in.placeDown = true + } + } +} + +// cursorCallback 鼠标移动:yaw/pitch 累积(渲染.md §2 主线程消费)。 +func (in *inputState) cursorCallback(_ *glfw.Window, x, y float64) { + if in.firstMouse { + in.lastX, in.lastY = x, y + in.firstMouse = false + return + } + dx, dy := x-in.lastX, y-in.lastY + in.lastX, in.lastY = x, y + const sens = 0.0025 + in.dyaw += dx * sens + in.dpitch -= dy * sens + if in.dpitch > math.Pi/2-0.01 { + in.dpitch = math.Pi/2 - 0.01 + } + if in.dpitch < -math.Pi/2+0.01 { + in.dpitch = -math.Pi/2 + 0.01 + } +} + +// scrollCallback 滚轮切热键栏(UI与输入.md §6)。 +func (in *inputState) scrollCallback(_ *glfw.Window, _, yoff float64) { + if yoff > 0 { + in.selectSlot = -2 // 上滚 + } else if yoff < 0 { + in.selectSlot = -3 // 下滚 + } +} + +// apply 每 tick 把输入状态转成玩家动作(game.Session 驱动)。 +func (in *inputState) apply(s *game.Session) { + cam := s.Cam + p := s.Player + + // 视角 + if in.dyaw != 0 || in.dpitch != 0 { + cam.Yaw += in.dyaw + cam.Pitch += in.dpitch + in.dyaw, in.dpitch = 0, 0 + } + + // 水平移动(物理与碰撞.md §3:走/冲刺/潜行) + f := cam.Forward() + r := cam.Right() + wish := [2]float64{} + if in.forward { + wish[0] += f[0] + wish[1] += f[2] + } + if in.back { + wish[0] -= f[0] + wish[1] -= f[2] + } + if in.right { + wish[0] += r[0] + wish[1] += r[2] + } + if in.left { + wish[0] -= r[0] + wish[1] -= r[2] + } + speed := 4.3 + if in.sprint { + speed *= 1.3 + } + if in.sneak { + speed *= 0.3 + } + l := math.Sqrt(wish[0]*wish[0] + wish[1]*wish[1]) + if l > 0 { + p.Vel[0] = wish[0] / l * speed + p.Vel[2] = wish[1] / l * speed + } else { + p.Vel[0] = 0 + p.Vel[2] = 0 + } + + // 跳跃 + if in.jump && p.OnGround { + p.Jump() + } + + // 热键栏切换 + switch in.selectSlot { + case -2: + s.Inv.Selected = (s.Inv.Selected + 1) % 9 + case -3: + s.Inv.Selected = (s.Inv.Selected + 8) % 9 + default: + if in.selectSlot >= 0 && in.selectSlot < 9 { + s.Inv.Selected = in.selectSlot + } + } + in.selectSlot = -1 + + // 破坏(按住累积,方块交互与动画.md §3) + if in.breakDown { + s.BeginBreak() + in.breakDown = false + } + if !in.breakHeld && s.BreakProgress() != 0 { + s.EndBreak() + } + + // 放置 + if in.placeDown { + s.PlaceSelected() + in.placeDown = false + } +} + +var _ = physics.Player{} // 保持引用(后续输入扩展用) diff --git a/cmd/client/main.go b/cmd/client/main.go index 0a5f542e..4b1c2920 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -1,4 +1,7 @@ // 年糕历险记 —— 客户端入口。 +// +// 装配顺序(架构.md §3.1 启动流程):配置 → 日志 → 内容包/图集 → 注册表 → 世界 +// → 游戏会话(玩家/背包/相机)→ 渲染器 → 主循环(tick 20Hz + render)。 package main import ( @@ -6,28 +9,105 @@ import ( "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 客户端应用骨架(窗口/渲染在 Todo 4 接入)。 +// clientApp 客户端应用:把会话与渲染粘合起来(engine.Ticker + engine.Renderer)。 type clientApp struct { - cfg *config.ClientConfig - log *logx.Logger + 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 } -// Tick 每 tick(20Hz)逻辑。 -func (a *clientApp) Tick(dt float64) {} +// worldMeshJob 待上传网格。 +type worldMeshJob struct { + cx, cz int32 + m *mesh.ChunkMesh +} -// Render 每帧渲染(占位)。 -func (a *clientApp) Render() {} +// 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) -// ShouldClose 窗口关闭判定(占位)。 -func (a *clientApp) ShouldClose() bool { return false } + // 脏区块网格重建(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() @@ -36,17 +116,102 @@ func main() { 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() - app := &clientApp{cfg: cfg, log: log} + log.Infof("进入游戏主循环") engine.Run(ctx, app, app) log.Infof("客户端已退出") } diff --git a/go.mod b/go.mod index 85f7544a..788689fd 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,9 @@ go 1.26.4 require gopkg.in/yaml.v3 v3.0.1 -require github.com/ojrac/opensimplex-go v1.0.2 // indirect +require ( + github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260802143932-8fa725040a18 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/ojrac/opensimplex-go v1.0.2 // indirect +) diff --git a/go.sum b/go.sum index 35545055..a8910767 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI= +github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260802143932-8fa725040a18 h1:c/72gzBO3eZPUVuHPnfyvt79XoIEi7KYGBY/8J/BpeI= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260802143932-8fa725040a18/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/ojrac/opensimplex-go v1.0.2 h1:l4vs0D+JCakcu5OV0kJ99oEaWJfggSc9jiLpxaWvSzs= github.com/ojrac/opensimplex-go v1.0.2/go.mod h1:NwbXFFbXcdGgIFdiA7/REME+7n/lOf1TuEbLiZYOWnM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/chunk/chunk.go b/internal/chunk/chunk.go index b1ce79af..c3d78cda 100644 --- a/internal/chunk/chunk.go +++ b/internal/chunk/chunk.go @@ -114,3 +114,23 @@ func (c *Chunk) Snapshot() []block.State { copy(out, c.blocks) return out } + +// LightBytes 复制天空光/方块光原始字节(存档序列化用)。 +func (c *Chunk) LightBytes() (sky, blockLt []byte) { + c.mu.RLock() + defer c.mu.RUnlock() + sky = make([]byte, len(c.sky)) + bl := make([]byte, len(c.blockLt)) + copy(sky, c.sky) + copy(bl, c.blockLt) + return sky, bl +} + +// SetAll 整体覆盖方块与光照数据(存档载入用;不标脏——载入即当前状态)。 +func (c *Chunk) SetAll(blocks []block.State, sky, blockLt []byte) { + c.mu.Lock() + copy(c.blocks, blocks) + copy(c.sky, sky) + copy(c.blockLt, blockLt) + c.mu.Unlock() +} diff --git a/internal/render/mesh_gl.go b/internal/render/mesh_gl.go new file mode 100644 index 00000000..756314b9 --- /dev/null +++ b/internal/render/mesh_gl.go @@ -0,0 +1,148 @@ +package render + +import ( + "unsafe" + + "mc/internal/mesh" + + "github.com/go-gl/gl/v3.3-core/gl" +) + +// chunkKey 区块坐标 → key(与 world 包一致)。 +func chunkKey(cx, cz int32) uint64 { return uint64(uint32(cx))<<32 | uint64(uint32(cz)) } + +// UploadChunk 上传区块网格到 GPU(dirty 网格重建后调用,渲染.md §3.3)。 +// 三类网格合并为单 VBO + 单 IBO:不透明 → cutout → 半透明 顺序连续存放。 +func (r *Renderer) UploadChunk(cx, cz int32, m *mesh.ChunkMesh) { + k := chunkKey(cx, cz) + if old, ok := r.chunks[k]; ok { + old.destroy() + } + cg := &chunkGL{} + gl.GenVertexArrays(1, &cg.vao) + gl.GenBuffers(1, &cg.vbo) + gl.GenBuffers(1, &cg.ibo) + gl.BindVertexArray(cg.vao) + + // 合并顶点 + all := make([]mesh.Vertex, 0, len(m.Opaque)+len(m.Cutout)+len(m.Translucent)) + all = append(all, m.Opaque...) + all = append(all, m.Cutout...) + all = append(all, m.Translucent...) + cg.translucent = len(m.Translucent) > 0 + + gl.BindBuffer(gl.ARRAY_BUFFER, cg.vbo) + gl.BufferData(gl.ARRAY_BUFFER, len(all)*int(unsafe.Sizeof(mesh.Vertex{})), gl.Ptr(all), gl.STATIC_DRAW) + + // 索引:每面 4 顶点四边形 → 2 三角形(0-1-2-2-3-0) + idx := make([]uint32, 0, len(all)/4*6) + for base := 0; base+3 < len(all); base += 4 { + b := uint32(base) + idx = append(idx, b, b+1, b+2, b+2, b+3, b) + } + gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, cg.ibo) + gl.BufferData(gl.ELEMENT_ARRAY_BUFFER, len(idx)*4, gl.Ptr(idx), gl.STATIC_DRAW) + + // 顶点属性布局(渲染.md §5 顶点格式) + stride := int32(unsafe.Sizeof(mesh.Vertex{})) + gl.EnableVertexAttribArray(0) + gl.VertexAttribPointer(0, 3, gl.FLOAT, false, stride, gl.PtrOffset(0)) + gl.EnableVertexAttribArray(1) + gl.VertexAttribPointer(1, 2, gl.FLOAT, false, stride, gl.PtrOffset(3*4)) + gl.EnableVertexAttribArray(2) + gl.VertexAttribPointer(2, 2, gl.UNSIGNED_BYTE, false, stride, gl.PtrOffset(5*4)) + + gl.BindVertexArray(0) + cg.idxCount = int32(len(m.Opaque) / 4 * 6) + cg.idxCut = int32(len(m.Cutout) / 4 * 6) + cg.idxTrans = int32(len(m.Translucent) / 4 * 6) + r.chunks[k] = cg +} + +// RemoveChunk 移除区块网格(区块卸载时调用)。 +func (r *Renderer) RemoveChunk(cx, cz int32) { + k := chunkKey(cx, cz) + if cg, ok := r.chunks[k]; ok { + cg.destroy() + delete(r.chunks, k) + } +} + +// destroy 释放 GPU 资源。 +func (cg *chunkGL) destroy() { + if cg.vao != 0 { + gl.DeleteVertexArrays(1, &cg.vao) + } + if cg.vbo != 0 { + gl.DeleteBuffers(1, &cg.vbo) + } + if cg.ibo != 0 { + gl.DeleteBuffers(1, &cg.ibo) + } + cg.vao, cg.vbo, cg.ibo = 0, 0, 0 +} + +// DrawChunks 绘制全部区块网格(三 pass,渲染.md §6)。 +// chunks:本帧要绘制的区块(已由视锥剔除过滤)。 +func (r *Renderer) DrawChunks(cx, cz func(i int) (int32, int32), n int) { + gl.UseProgram(r.prog) + gl.ActiveTexture(gl.TEXTURE0) + gl.BindTexture(gl.TEXTURE_2D, r.atlas) + + var vp [16]float32 + multiply4(&vp, &r.proj, &r.view) + vpLoc := gl.GetUniformLocation(r.prog, gl.Str("uViewProj\x00")) + cutLoc := gl.GetUniformLocation(r.prog, gl.Str("uCutout\x00")) + gl.UniformMatrix4fv(vpLoc, 1, false, &vp[0]) + + // pass 1+2:不透明与 cutout(alpha test,不排序) + gl.Uniform1f(cutLoc, 0) + drawPass(r, n, cx, cz, false, false) + gl.Uniform1f(cutLoc, 1) + drawPass(r, n, cx, cz, false, true) + // pass 3:半透明(关闭深度写入,远→近排序由调用方保证顺序) + gl.Uniform1f(cutLoc, 0) + gl.DepthMask(false) + drawPass(r, n, cx, cz, true, false) + gl.DepthMask(true) + gl.BindVertexArray(0) +} + +// drawPass 按通道绘制。 +func drawPass(r *Renderer, n int, cx, cz func(i int) (int32, int32), translucent, cutout bool) { + for i := 0; i < n; i++ { + x, z := cx(i), cz(i) + cg, ok := r.chunks[chunkKey(x, z)] + if !ok { + continue + } + base := int32(0) + count := cg.idxCount + if cutout { + base = cg.idxCount + count = cg.idxCut + } + if translucent { + base = cg.idxCount + cg.idxCut + count = cg.idxTrans + } + if count == 0 { + continue + } + gl.BindVertexArray(cg.vao) + gl.DrawElements(gl.TRIANGLES, count, gl.UNSIGNED_INT, gl.PtrOffset(int(base)*4)) + } +} + +// multiply4 列主序 4×4 矩阵乘法:out = a × b。 +func multiply4(out, a, b *[16]float32) { + for col := 0; col < 4; col++ { + for row := 0; row < 4; row++ { + var v float32 + for k := 0; k < 4; k++ { + v += a[k*4+row] * b[col*4+k] + } + out[col*4+row] = v + } + } +} diff --git a/internal/render/renderer.go b/internal/render/renderer.go new file mode 100644 index 00000000..ae881b62 --- /dev/null +++ b/internal/render/renderer.go @@ -0,0 +1,238 @@ +// Package render 渲染层:GLFW 窗口 + OpenGL 3.3 core 上下文 + 默认光影着色器(场景/渲染.md)。 +// +// 线程铁律(渲染.md §2): +// - 所有 GL 调用必须发生在同一 OS 线程(runtime.LockOSThread); +// - mesh 构建在 worker(纯 CPU),本包只负责上传与绘制; +// - 渲染 goroutine 不分配大块内存(顶点缓冲复用)。 +package render + +import ( + "fmt" + "image" + "runtime" + "strings" + + "github.com/go-gl/gl/v3.3-core/gl" + "github.com/go-gl/glfw/v3.3/glfw" +) + +// Renderer 渲染器:窗口 + 着色器 + 图集 + 区块网格缓存。 +type Renderer struct { + window *glfw.Window + prog uint32 // 主着色程序(chunk) + atlas uint32 // 纹理图集 + + // 区块网格缓存:key → VAO/VBO/索引数 + chunks map[uint64]*chunkGL + + proj [16]float32 + view [16]float32 +} + +// chunkGL 一个区块的 GPU 资源。 +type chunkGL struct { + vao uint32 + vbo uint32 + ibo uint32 + idxCount int32 // 不透明索引数 + idxCut int32 // cutout 索引数(连续存放:不透明在前) + idxTrans int32 // translucent 索引数 + translucent bool +} + +// New 初始化 GLFW 与 GL,创建窗口与着色器(必须在主线程调用)。 +func New(title string, width, height int) (*Renderer, error) { + runtime.LockOSThread() // GL 线程亲和(渲染.md §2 铁律) + + if err := glfw.Init(); err != nil { + return nil, fmt.Errorf("render.New glfw.Init: %w", err) + } + glfw.WindowHint(glfw.ContextVersionMajor, 3) + glfw.WindowHint(glfw.ContextVersionMinor, 3) + glfw.WindowHint(glfw.OpenGLProfile, glfw.OpenGLCoreProfile) + glfw.WindowHint(glfw.Resizable, glfw.False) + glfw.WindowHint(glfw.Samples, 0) + + win, err := glfw.CreateWindow(width, height, title, nil, nil) + if err != nil { + glfw.Terminate() + return nil, fmt.Errorf("render.New CreateWindow: %w", err) + } + win.MakeContextCurrent() + glfw.SwapInterval(1) // vsync + + if err := gl.Init(); err != nil { + glfw.Terminate() + return nil, fmt.Errorf("render.New gl.Init: %w", err) + } + + r := &Renderer{window: win, chunks: make(map[uint64]*chunkGL)} + if err := r.initShaders(); err != nil { + glfw.Terminate() + return nil, err + } + // 初始图集:1×1 白色占位(SetAtlas 后替换) + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, image.White) + r.SetAtlas(img) + gl.Enable(gl.DEPTH_TEST) + gl.Enable(gl.BLEND) + gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) + gl.ClearColor(0.53, 0.81, 0.92, 1) // 天空蓝 + return r, nil +} + +// ShouldClose 窗口是否请求关闭。 +func (r *Renderer) ShouldClose() bool { return r.window.ShouldClose() } + +// BeginFrame 处理窗口事件并清屏(每帧开始,渲染.md §3.2 主循环)。 +func (r *Renderer) BeginFrame() { + glfw.PollEvents() + gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) +} + +// EndFrame 交换缓冲(每帧结束)。 +func (r *Renderer) EndFrame() { r.window.SwapBuffers() } + +// Window 返回 GLFW 窗口(输入处理用)。 +func (r *Renderer) Window() *glfw.Window { return r.window } + +// SetCursorMode 鼠标捕获/释放(UI与输入.md §6)。 +func (r *Renderer) SetCursorMode(captured bool) { + if captured { + r.window.SetInputMode(glfw.CursorMode, glfw.CursorDisabled) + } else { + r.window.SetInputMode(glfw.CursorMode, glfw.CursorNormal) + } +} + +// initShaders 编译默认光影着色器(渲染.md §9.1:Forward + 顶点光照)。 +func (r *Renderer) initShaders() error { + vs, err := compileShader(gl.VERTEX_SHADER, chunkVertSrc) + if err != nil { + return fmt.Errorf("render.initShaders 顶点着色器: %w", err) + } + fs, err := compileShader(gl.FRAGMENT_SHADER, chunkFragSrc) + if err != nil { + return fmt.Errorf("render.initShaders 片元着色器: %w", err) + } + prog := gl.CreateProgram() + gl.AttachShader(prog, vs) + gl.AttachShader(prog, fs) + gl.LinkProgram(prog) + var ok int32 + gl.GetProgramiv(prog, gl.LINK_STATUS, &ok) + if ok == 0 { + var logLen int32 + gl.GetProgramiv(prog, gl.INFO_LOG_LENGTH, &logLen) + log := strings.Repeat(" ", int(logLen)) + gl.GetProgramInfoLog(prog, logLen, nil, gl.Str(log)) + return fmt.Errorf("着色器链接失败: %s", log) + } + gl.DeleteShader(vs) + gl.DeleteShader(fs) + r.prog = prog + return nil +} + +// compileShader 编译单个着色器阶段。 +func compileShader(kind uint32, src string) (uint32, error) { + s := gl.CreateShader(kind) + cs, free := gl.Strs(src + "\x00") + gl.ShaderSource(s, 1, cs, nil) + free() + gl.CompileShader(s) + var ok int32 + gl.GetShaderiv(s, gl.COMPILE_STATUS, &ok) + if ok == 0 { + var logLen int32 + gl.GetShaderiv(s, gl.INFO_LOG_LENGTH, &logLen) + log := strings.Repeat(" ", int(logLen)) + gl.GetShaderInfoLog(s, logLen, nil, gl.Str(log)) + return 0, fmt.Errorf("编译失败: %s", log) + } + return s, nil +} + +// SetAtlas 上传纹理图集(最近邻采样 + mipmap,渲染.md §4)。 +func (r *Renderer) SetAtlas(img image.Image) { + if r.atlas == 0 { + gl.GenTextures(1, &r.atlas) + } + gl.BindTexture(gl.TEXTURE_2D, r.atlas) + b := img.Bounds() + w, h := int32(b.Dx()), int32(b.Dy()) + gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, imageBytes(img)) + gl.GenerateMipmap(gl.TEXTURE_2D) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_LINEAR) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) +} + +// imageBytes 把 image.Image 转 RGBA 字节流(预乘无关紧要)。 +func imageBytes(img image.Image) []byte { + b := img.Bounds() + out := make([]byte, 0, b.Dx()*b.Dy()*4) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r8, g8, b8, a8 := img.At(x, y).RGBA() + out = append(out, byte(r8>>8), byte(g8>>8), byte(b8>>8), byte(a8>>8)) + } + } + return out +} + +// SetViewProj 设置视图投影矩阵(每帧调用)。 +func (r *Renderer) SetViewProj(view, proj [16]float32) { + r.view, r.proj = view, proj +} + +// Shutdown 清理 GL 资源并关闭窗口。 +func (r *Renderer) Shutdown() { + for _, c := range r.chunks { + c.destroy() + } + if r.atlas != 0 { + gl.DeleteTextures(1, &r.atlas) + } + if r.prog != 0 { + gl.DeleteProgram(r.prog) + } + r.window.Destroy() + glfw.Terminate() +} + +// chunkVertSrc 默认方块顶点着色器(GLSL 330,渲染.md §9.1)。 +const chunkVertSrc = ` +#version 330 core +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec2 aUV; +layout(location = 2) in vec2 aLight; // x=天空光 y=方块光(4-bit) +uniform mat4 uViewProj; +out vec2 vUV; +out vec2 vLight; +void main() { + gl_Position = uViewProj * vec4(aPos, 1.0); + vUV = aUV; + vLight = aLight; +} +` + +// chunkFragSrc 默认方块片元着色器:cutout 剔除 + 光照亮度混合。 +const chunkFragSrc = ` +#version 330 core +in vec2 vUV; +in vec2 vLight; +uniform sampler2D uTex; +uniform float uCutout; // 1=alpha test(树叶/玻璃),0=普通/半透明 +out vec4 fragColor; +void main() { + vec4 c = texture(uTex, vUV); + if (uCutout > 0.5 && c.a < 0.5) discard; + float sky = vLight.x / 15.0; + float blk = vLight.y / 15.0; + float b = max(sky, blk * 0.9 + 0.06); + fragColor = vec4(c.rgb * b, c.a); +} +` diff --git a/internal/save/region.go b/internal/save/region.go new file mode 100644 index 00000000..2c5708f4 --- /dev/null +++ b/internal/save/region.go @@ -0,0 +1,225 @@ +// Package save 世界存档:level.dat + region 文件 + zstd + CRC32(存档格式.md)。 +// +// 策略(存档与持久化.md):diff-only(只存玩家改动的区块)、写临时文件原子替换、 +// 卸载/定时写盘、先备份后迁移。 +package save + +import ( + "bytes" + "encoding/binary" + "fmt" + "hash/crc32" + "io" + "os" + "path/filepath" + "time" + + "github.com/klauspost/compress/zstd" +) + +// 区块序列化格式常量(存档格式.md §4–§5)。 +const ( + magic = "NYGC" // 年糕历险记 区块数据魔数 + chunkVer = 1 // 区块序列化版本 + sectorSize = 4096 // 扇区 4KB + regionSize = 32 // 每 region 32×32 区块(存档格式.md §3) +) + +// crcTable CRC32 表(IEEE)。 +var crcTable = crc32.MakeTable(crc32.IEEE) + +// ---- Region 文件(存档格式.md §3)---- + +// Region 一个区域文件(r...mcr)。 +type Region struct { + rx, rz int32 + path string + + // 头部:偏移表(1024×4B)与时间戳表(1024×4B),启动时读入内存 + offsets [1024]uint32 + timestamps [1024]uint32 +} + +// sectorIndex 区块局部坐标 → 头部索引。 +func sectorIndex(lx, lz int32) int { + lx &= regionSize - 1 + lz &= regionSize - 1 + return int(lz)*regionSize + int(lx) +} + +// OpenRegion 打开区域文件(不存在则视为空)。 +func OpenRegion(dir string, rx, rz int32) (*Region, error) { + r := &Region{rx: rx, rz: rz, path: filepath.Join(dir, fmt.Sprintf("r.%d.%d.mcr", rx, rz))} + f, err := os.Open(r.path) + if err != nil { + if os.IsNotExist(err) { + return r, nil // 空区域 + } + return nil, fmt.Errorf("save.OpenRegion: %w", err) + } + defer f.Close() + // 读头部:偏移表(4KB)+ 时间戳表(4KB) + var buf [8192]byte + if _, err := f.ReadAt(buf[:], 0); err != nil && err != io.EOF { + return nil, fmt.Errorf("save.OpenRegion 头部: %w", err) + } + for i := 0; i < 1024; i++ { + r.offsets[i] = binary.BigEndian.Uint32(buf[i*4:]) + r.timestamps[i] = binary.BigEndian.Uint32(buf[4096+i*4:]) + } + return r, nil +} + +// ReadChunk 读取区块压缩数据(含 CRC 校验,存档格式.md §5)。 +func (r *Region) ReadChunk(cx, cz int32) ([]byte, bool, error) { + idx := sectorIndex(cx-r.rx*regionSize, cz-r.rz*regionSize) + off := r.offsets[idx] + if off == 0 { + return nil, false, nil // 未保存过 + } + sector := off >> 8 + count := off & 0xFF + if sector == 0 || count == 0 { + return nil, false, nil + } + f, err := os.Open(r.path) + if err != nil { + return nil, false, fmt.Errorf("save.ReadChunk: %w", err) + } + defer f.Close() + raw := make([]byte, count*sectorSize) + if _, err := f.ReadAt(raw, int64(sector)*sectorSize); err != nil { + return nil, false, fmt.Errorf("save.ReadChunk 读取扇区: %w", err) + } + length := binary.BigEndian.Uint32(raw[0:4]) + compType := raw[4] + if 5+int(length) > len(raw) { + return nil, false, fmt.Errorf("save.ReadChunk: 记录长度越界") + } + data := raw[5 : 5+int(length)] + wantCRC := binary.BigEndian.Uint32(raw[5+int(length):]) + if crc32.Checksum(data, crcTable) != wantCRC { + return nil, false, fmt.Errorf("save.ReadChunk: CRC 校验失败(区块 %d,%d)", cx, cz) + } + if compType == 0 { + return data, true, nil + } + if compType != 2 { + return nil, false, fmt.Errorf("save.ReadChunk: 未知压缩类型 %d", compType) + } + out, err := zstdDecode(data) + if err != nil { + return nil, false, fmt.Errorf("save.ReadChunk 解压: %w", err) + } + return out, true, nil +} + +// WriteChunk 写入区块压缩数据(写完整区域文件到临时文件再原子替换,存档格式.md §6)。 +func (r *Region) WriteChunk(cx, cz int32, data []byte) error { + idx := sectorIndex(cx-r.rx*regionSize, cz-r.rz*regionSize) + comp, err := zstdEncode(data) + if err != nil { + return fmt.Errorf("save.WriteChunk 压缩: %w", err) + } + rec := make([]byte, 5+len(comp)+4) + binary.BigEndian.PutUint32(rec[0:4], uint32(len(comp))) + rec[4] = 2 // zstd + copy(rec[5:], comp) + binary.BigEndian.PutUint32(rec[5+len(comp):], crc32.Checksum(comp, crcTable)) + + sectorCount := (len(rec) + sectorSize - 1) / sectorSize + if sectorCount == 0 { + sectorCount = 1 + } + // 找空闲扇区(简单策略:追加到文件尾;头部 8KB 占 2 扇区,数据从扇区 2 起) + fileSize := int64(0) + if st, err := os.Stat(r.path); err == nil { + fileSize = st.Size() + } + startSector := int(fileSize+sectorSize-1) / sectorSize + if startSector < 2 { + startSector = 2 + } + + // 构建完整新文件:头部 + 既有数据 + 新记录 + tmp, err := os.CreateTemp(filepath.Dir(r.path), "region.tmp.*") + if err != nil { + return fmt.Errorf("save.WriteChunk 临时文件: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + var head [8192]byte + r.offsets[idx] = uint32(startSector<<8) | uint32(sectorCount) + r.timestamps[idx] = uint32(nowUnix()) + for i := 0; i < 1024; i++ { + binary.BigEndian.PutUint32(head[i*4:], r.offsets[i]) + binary.BigEndian.PutUint32(head[4096+i*4:], r.timestamps[i]) + } + if _, err := tmp.Write(head[:]); err != nil { + tmp.Close() + return err + } + // 复制旧数据区 + if old, err := os.Open(r.path); err == nil { + if _, err := io.Copy(tmp, old); err != nil { + old.Close() + tmp.Close() + return err + } + old.Close() + } + // 追加新记录(对齐扇区) + if _, err := tmp.Write(rec); err != nil { + tmp.Close() + return err + } + pad := sectorCount*sectorSize - len(rec) + if pad > 0 { + if _, err := tmp.Write(make([]byte, pad)); err != nil { + tmp.Close() + return err + } + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + // 原子替换(存档格式.md §6:临时文件 → rename) + if err := os.Rename(tmpPath, r.path); err != nil { + return fmt.Errorf("save.WriteChunk 原子替换: %w", err) + } + return nil +} + +// ---- 压缩工具 ---- + +func zstdEncode(b []byte) ([]byte, error) { + var buf bytes.Buffer + enc, err := zstd.NewWriter(&buf) + if err != nil { + return nil, err + } + if _, err := enc.Write(b); err != nil { + return nil, err + } + if err := enc.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func zstdDecode(b []byte) ([]byte, error) { + dec, err := zstd.NewReader(bytes.NewReader(b)) + if err != nil { + return nil, err + } + defer dec.Close() + return io.ReadAll(dec) +} + +// nowUnix 当前 Unix 秒(时间戳表)。 +func nowUnix() int64 { return time.Now().Unix() } diff --git a/internal/save/save.go b/internal/save/save.go new file mode 100644 index 00000000..22324863 --- /dev/null +++ b/internal/save/save.go @@ -0,0 +1,187 @@ +// 世界存档管理器(save.go):level.dat 元数据 + 脏区块写 region + 载入重建。 +// +// 流程(存档与持久化.md §2–§5): +// - 保存:遍历脏区块 → 序列化 → 写 region → 清除 SaveDirty → 写 level.dat; +// - 载入:读 level.dat 取 seed → 生成器重建 → 用已保存区块覆盖; +// - 全部写盘走临时文件 + 原子替换(崩溃恢复)。 +package save + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "mc/internal/block" + "mc/internal/chunk" + "mc/internal/coord" + "mc/internal/world" +) + +// LevelData 世界元数据(存档格式.md §2)。 +type LevelData struct { + Version int `json:"version"` + Seed int64 `json:"seed"` + Time int64 `json:"time"` + GameMode string `json:"game_mode"` +} + +// WriteLevel 写 level.dat(临时文件 + 原子替换)。 +func WriteLevel(dir, name string, lv LevelData) error { + b, err := json.MarshalIndent(lv, "", " ") + if err != nil { + return fmt.Errorf("save.WriteLevel: %w", err) + } + path := filepath.Join(dir, name, "level.dat") + tmp, err := os.CreateTemp(filepath.Dir(path), "level.tmp.*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if _, err := tmp.Write(b); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +// ReadLevel 读 level.dat;不存在返回零值。 +func ReadLevel(dir, name string) (LevelData, error) { + path := filepath.Join(dir, name, "level.dat") + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return LevelData{}, nil + } + return LevelData{}, fmt.Errorf("save.ReadLevel: %w", err) + } + var lv LevelData + if err := json.Unmarshal(b, &lv); err != nil { + return LevelData{}, fmt.Errorf("save.ReadLevel: %w", err) + } + return lv, nil +} + +// EncodeChunk 序列化区块:[魔数 4][版本 1][cx 4][cz 4][方块 65536×4][天空光 32768][方块光 32768]。 +func EncodeChunk(c *chunk.Chunk) []byte { + blocks := c.Snapshot() + sky, bl := c.LightBytes() + out := make([]byte, 0, 9+len(blocks)*4+len(sky)+len(bl)) + out = append(out, magic...) + out = append(out, chunkVer) + out = binary.BigEndian.AppendUint32(out, uint32(c.CX)) + out = binary.BigEndian.AppendUint32(out, uint32(c.CZ)) + for _, s := range blocks { + out = binary.BigEndian.AppendUint32(out, uint32(s)) + } + out = append(out, sky...) + out = append(out, bl...) + return out +} + +// DecodeChunk 反序列化区块。 +func DecodeChunk(data []byte) (*chunk.Chunk, error) { + if len(data) < 9 || string(data[0:4]) != magic { + return nil, fmt.Errorf("save.DecodeChunk: 魔数错误") + } + if data[4] != chunkVer { + return nil, fmt.Errorf("save.DecodeChunk: 版本 %d 不支持", data[4]) + } + cx := int32(binary.BigEndian.Uint32(data[5:9])) + cz := int32(binary.BigEndian.Uint32(data[9:13])) + off := 13 + n := chunk.SizeX * chunk.SizeZ * chunk.Height + blocks := make([]block.State, n) + for i := 0; i < n; i++ { + blocks[i] = block.State(binary.BigEndian.Uint32(data[off:])) + off += 4 + } + sky := make([]byte, n/2) + bl := make([]byte, n/2) + copy(sky, data[off:off+n/2]) + off += n / 2 + copy(bl, data[off:off+n/2]) + c := chunk.New(cx, cz) + c.SetAll(blocks, sky, bl) + return c, nil +} + +// SaveWorld 保存全部脏区块 + level.dat(存档与持久化.md §2 保存时机由调用方触发)。 +func SaveWorld(w *world.World, dir, name string, seed int64, tick int64) error { + regionDir := filepath.Join(dir, name, "region") + if err := os.MkdirAll(regionDir, 0o755); err != nil { + return fmt.Errorf("save.SaveWorld mkdir: %w", err) + } + for _, c := range w.ActiveChunks() { + if !c.SaveDirty.Load() { + continue + } + data := EncodeChunk(c) + rx, rz := coord.FloorDiv(c.CX, regionSize), coord.FloorDiv(c.CZ, regionSize) + region, err := OpenRegion(regionDir, rx, rz) + if err != nil { + return err + } + if err := region.WriteChunk(c.CX, c.CZ, data); err != nil { + return err + } + c.SaveDirty.Store(false) + } + return WriteLevel(dir, name, LevelData{Version: 1, Seed: seed, Time: tick, GameMode: "survival"}) +} + +// LoadWorld 载入世界:level.dat 取 seed 重建,再用 region 中已保存区块覆盖。 +func LoadWorld(w *world.World, dir, name string) (int64, error) { + lv, err := ReadLevel(dir, name) + if err != nil { + return 0, err + } + regionDir := filepath.Join(dir, name, "region") + entries, err := os.ReadDir(regionDir) + if err != nil { + if os.IsNotExist(err) { + return lv.Seed, nil // 新世界 + } + return 0, fmt.Errorf("save.LoadWorld: %w", err) + } + for _, e := range entries { + if e.IsDir() { + continue + } + var rx, rz int32 + if _, err := fmt.Sscanf(e.Name(), "r.%d.%d.mcr", &rx, &rz); err != nil { + continue + } + region, err := OpenRegion(regionDir, rx, rz) + if err != nil { + return 0, err + } + for lz := int32(0); lz < regionSize; lz++ { + for lx := int32(0); lx < regionSize; lx++ { + cx, cz := rx*regionSize+lx, rz*regionSize+lz + data, ok, err := region.ReadChunk(cx, cz) + if err != nil { + return 0, fmt.Errorf("save.LoadWorld %d,%d: %w", cx, cz, err) + } + if !ok { + continue + } + c, err := DecodeChunk(data) + if err != nil { + return 0, err + } + w.InstallChunk(c) + } + } + } + return lv.Seed, nil +} diff --git a/internal/save/save_test.go b/internal/save/save_test.go new file mode 100644 index 00000000..6df1f901 --- /dev/null +++ b/internal/save/save_test.go @@ -0,0 +1,145 @@ +package save + +import ( + "path/filepath" + "testing" + "time" + + "mc/internal/block" + "mc/internal/world" + "mc/internal/worldgen" +) + +// loadSaveReg 加载注册表。 +func loadSaveReg(t *testing.T) *block.Registry { + t.Helper() + r, err := block.Load(filepath.Join("..", "..", "assets", "config", "blocks.json")) + if err != nil { + t.Fatalf("加载注册表失败: %v", err) + } + return r +} + +// newGen 创建生成器。 +func newGen(t *testing.T, reg *block.Registry, seed int64) *worldgen.Generator { + t.Helper() + g, err := worldgen.New(reg, seed, 0.005, 0.01, 0.1) + if err != nil { + t.Fatalf("创建生成器失败: %v", err) + } + return g +} + +// TestChunkRoundtrip 区块序列化往返(存档格式.md §4)。 +func TestChunkRoundtrip(t *testing.T) { + reg := loadSaveReg(t) + g := newGen(t, reg, 777) + c := g.Generate(3, -5) + c.SetBlockLight(8, 70, 8, 13) + + dec, err := DecodeChunk(EncodeChunk(c)) + if err != nil { + t.Fatalf("反序列化失败: %v", err) + } + if dec.CX != 3 || dec.CZ != -5 { + t.Fatalf("坐标不一致: %d,%d", dec.CX, dec.CZ) + } + if dec.Block(8, 70, 8) != c.Block(8, 70, 8) { + t.Fatal("方块数据不一致") + } + if dec.BlockLight(8, 70, 8) != 13 { + t.Fatalf("方块光不一致: %d", dec.BlockLight(8, 70, 8)) + } + if dec.SkyLight(8, 100, 8) != c.SkyLight(8, 100, 8) { + t.Fatal("天空光不一致") + } +} + +// TestRegionWriteRead 区域文件写入/读取(含 CRC 与跨重启重开)。 +func TestRegionWriteRead(t *testing.T) { + reg := loadSaveReg(t) + g := newGen(t, reg, 99) + dir := t.TempDir() + + data := EncodeChunk(g.Generate(1, 1)) + region, err := OpenRegion(dir, 0, 0) + if err != nil { + t.Fatalf("打开区域失败: %v", err) + } + if err := region.WriteChunk(1, 1, data); err != nil { + t.Fatalf("写区块失败: %v", err) + } + // 模拟重启:重新打开区域 + region2, err := OpenRegion(dir, 0, 0) + if err != nil { + t.Fatalf("重开区域失败: %v", err) + } + got, ok, err := region2.ReadChunk(1, 1) + if err != nil || !ok { + t.Fatalf("读区块失败: ok=%v err=%v", ok, err) + } + if string(got) != string(data) { + t.Fatal("读取数据不一致") + } + // 未保存区块应返回 ok=false + if _, ok, _ := region2.ReadChunk(2, 2); ok { + t.Fatal("未保存区块应返回 false") + } +} + +// TestSaveLoadWorld 世界级存取:放火把 → 保存 → 载入 → 火把与光仍在。 +func TestSaveLoadWorld(t *testing.T) { + reg := loadSaveReg(t) + seed := int64(2024) + g := newGen(t, reg, seed) + w, err := world.New(reg, g, 3) + if err != nil { + t.Fatalf("创建世界失败: %v", err) + } + defer w.Close() + + // 生成并等待区块 + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + w.Update(8, 64, 8, 8*time.Millisecond) + if w.Block(8, 0, 8) != block.Air { + break + } + time.Sleep(2 * time.Millisecond) + } + // 放一个火把 + torch, _ := reg.ID("torch") + var surf int32 + for y := int32(255); y >= 0; y-- { + if w.Block(8, y, 8) != block.Air { + surf = y + break + } + } + w.SetBlock(8, surf+1, 8, block.NewState(torch, 0)) + + dir := t.TempDir() + if err := SaveWorld(w, dir, "TestWorld", seed, 1000); err != nil { + t.Fatalf("保存失败: %v", err) + } + + // 载入到新世界 + g2 := newGen(t, reg, seed) + w2, err := world.New(reg, g2, 3) + if err != nil { + t.Fatalf("创建世界失败: %v", err) + } + defer w2.Close() + if gotSeed, err := LoadWorld(w2, dir, "TestWorld"); err != nil { + t.Fatalf("载入失败: %v", err) + } else if gotSeed != seed { + t.Fatalf("种子不一致: %d", gotSeed) + } + // 火把及其光 + if s := w2.Block(8, surf+1, 8); s.ID() != torch { + t.Fatalf("火把丢失: %v", s) + } + if l := w2.BlockLight(7, surf+1, 8); l != 13 { + t.Fatalf("火把光丢失: %d", l) + } +} diff --git a/internal/world/world.go b/internal/world/world.go index 20e3fdac..ceb1aaaa 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -206,6 +206,16 @@ func (w *World) sourcesIn(minX, minY, minZ, maxX, maxY, maxZ int32) []light.Pos return seeds } +// InstallChunk 安装一个区块(存档载入/外部生成用):置为 active 并标 mesh 脏。 +func (w *World) InstallChunk(c *chunk.Chunk) { + w.mu.Lock() + k := coordKey(c.CX, c.CZ) + w.chunks[k] = c + w.states[k] = stateActive + w.mu.Unlock() + c.Dirty.Store(true) +} + // ActiveChunks 返回已激活区块列表(渲染遍历用;返回副本避免并发写)。 func (w *World) ActiveChunks() []*chunk.Chunk { w.mu.Lock() diff --git a/tools/zig.zip b/tools/zig.zip index e69de29b..ceda178c 100644 Binary files a/tools/zig.zip and b/tools/zig.zip differ