feat(save): 存档系统——region 文件(zstd+CRC32+原子替换)、level.dat、区块序列化、世界存取(含测试)
This commit is contained in:
199
cmd/client/input.go
Normal file
199
cmd/client/input.go
Normal file
@@ -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{} // 保持引用(后续输入扩展用)
|
||||
@@ -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("客户端已退出")
|
||||
}
|
||||
|
||||
7
go.mod
7
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
|
||||
)
|
||||
|
||||
6
go.sum
6
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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
148
internal/render/mesh_gl.go
Normal file
148
internal/render/mesh_gl.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
238
internal/render/renderer.go
Normal file
238
internal/render/renderer.go
Normal file
@@ -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);
|
||||
}
|
||||
`
|
||||
225
internal/save/region.go
Normal file
225
internal/save/region.go
Normal file
@@ -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.<rx>.<rz>.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() }
|
||||
187
internal/save/save.go
Normal file
187
internal/save/save.go
Normal file
@@ -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
|
||||
}
|
||||
145
internal/save/save_test.go
Normal file
145
internal/save/save_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
BIN
tools/zig.zip
BIN
tools/zig.zip
Binary file not shown.
Reference in New Issue
Block a user