feat(save): 存档系统——region 文件(zstd+CRC32+原子替换)、level.dat、区块序列化、世界存取(含测试)

This commit is contained in:
NianGao Dev
2026-08-15 22:59:31 +08:00
parent ba187f6f85
commit 509c6f228c
12 changed files with 1360 additions and 12 deletions

199
cmd/client/input.go Normal file
View 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{} // 保持引用(后续输入扩展用)

View File

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