From f25ae15077f357465c196c383d82778ab9195afa Mon Sep 17 00:00:00 2001 From: NianGao Dev Date: Sun, 16 Aug 2026 20:18:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E9=9F=B3=E9=A2=91=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E3=80=81=E5=8E=9F=E7=89=88=E6=8E=89=E8=90=BD=E3=80=81?= =?UTF-8?q?=E5=8A=A8=E7=89=A9=E6=94=BB=E5=87=BB=E3=80=81=E6=9D=91=E5=BA=84?= =?UTF-8?q?=E6=9D=91=E6=B0=91=E3=80=81=E7=A7=8D=E5=AD=90/=E7=81=B5?= =?UTF-8?q?=E6=95=8F=E5=BA=A6=E9=85=8D=E7=BD=AE=E4=B8=8E=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 音频(音频.md §2):Session.Sound 事件回调,破坏/放置/攻击/村民环境音接入, 客户端 audio.Manager 3D 衰减 + 监听器跟随 + 音量可调 - 原版掉落:草方块→泥土、石头→圆石、树叶无掉落(no_drop);掉落物实体 用物品图标小广告牌渲染(物品纹理并入实体图集) - 动物攻击(动物体系.md §5):左键优先射线命中实体(半径 0.6 球体判定), 伤害/击退/死亡移除+肉类掉落;僵尸掉腐肉 - 村庄村民(村民系统.md §2):Generator.VillageAt 定位村庄中心,会话扫描 已加载区块在村庄生成 3 名村民 - 种子与灵敏度配置:client.yaml world.seed/name、mouse_sensitivity; 存档与种子联动;鼠标左右反转修复(右移应右转) - 设置界面(UI与输入.md §6):屏幕状态 4,灵敏度/音量滑条、世界种子显示、 按键教程多行列表(cjkfont 支持 \\n)、完成按钮;主菜单/暂停菜单入口 - 准星指向方块名:左下角 CJK 显示(lang 本地化,tick 光栅化渲染线程上传) - 截图统一存项目根目录 jietu/(paths.RootDir;dev 构建识别 bin/ 布局) --- .gitignore | 2 + assets/config/blocks.json | 6 +- cmd/client/client_gl.go | 206 +++++++++++++++++++++++++++++++--- cmd/client/devshot_gl.go | 12 ++ cmd/client/entities_gl.go | 21 +++- cmd/client/input.go | 24 +++- cmd/client/screens_gl.go | 40 ++++++- config/client.yaml | 4 + internal/block/registry.go | 1 + internal/camera/camera.go | 18 +-- internal/camera/pitch_test.go | 8 +- internal/cjkfont/cjkfont.go | 37 +++--- internal/config/config.go | 24 ++-- internal/entity/entity.go | 7 ++ internal/game/game.go | 125 +++++++++++++++++++++ internal/game/game_test.go | 68 +++++++++++ internal/item/item.go | 11 ++ internal/paths/paths.go | 10 ++ internal/render/entity_gl.go | 9 ++ internal/render/renderer.go | 4 +- internal/ui/hud.go | 14 +++ internal/ui/options.go | 123 ++++++++++++++++++++ internal/world/world.go | 3 + internal/worldgen/village.go | 20 ++++ 文档/索引.md | 1 + 文档/路线图.md | 7 +- 26 files changed, 734 insertions(+), 71 deletions(-) create mode 100644 internal/ui/options.go diff --git a/.gitignore b/.gitignore index 36df6a54..fd1401ae 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ tools/mingw64/ # 本地构建缓存(沙箱环境下 GOCACHE/zig cache 重定向到工作区) .gocache/ .zigcache/ +# 截图统一存储目录 +jietu/ diff --git a/assets/config/blocks.json b/assets/config/blocks.json index 986879e5..dd55ee66 100644 --- a/assets/config/blocks.json +++ b/assets/config/blocks.json @@ -1,10 +1,10 @@ { "blocks": { - "stone": { "id": 1, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/stone" } }, + "stone": { "id": 1, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/stone" }, "drops": "cobblestone" }, "granite": { "id": 2, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/granite" } }, "diorite": { "id": 3, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/diorite" } }, "andesite": { "id": 4, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/andesite" } }, - "grass_block": { "id": 5, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/grass_block_top", "down": "block/dirt", "side": "block/grass_block_side" }, "tint": "grass" }, + "grass_block": { "id": 5, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/grass_block_top", "down": "block/dirt", "side": "block/grass_block_side" }, "tint": "grass", "drops": "dirt" }, "dirt": { "id": 6, "hardness": 0.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/dirt" } }, "cobblestone": { "id": 7, "hardness": 2.0, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/cobblestone" } }, "oak_planks": { "id": 8, "hardness": 2.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/oak_planks" } }, @@ -12,7 +12,7 @@ "sand": { "id": 10, "hardness": 0.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/sand" } }, "gravel": { "id": 11, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/gravel" } }, "oak_log": { "id": 12, "hardness": 2.0, "resistance": 2.0, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/oak_log_top", "down": "block/oak_log_top", "side": "block/oak_log" } }, - "oak_leaves": { "id": 13, "hardness": 0.2, "resistance": 1.0, "light": 0, "transparent": true, "solid": true, "textures": { "all": "block/oak_leaves" }, "cutout": true, "tint": "foliage" }, + "oak_leaves": { "id": 13, "hardness": 0.2, "resistance": 1.0, "light": 0, "transparent": true, "solid": true, "textures": { "all": "block/oak_leaves" }, "cutout": true, "tint": "foliage", "no_drop": true }, "glass": { "id": 14, "hardness": 0.3, "resistance": 1.5, "light": 0, "transparent": true, "solid": true, "textures": { "all": "block/glass" }, "cutout": true }, "water": { "id": 15, "hardness": 100.0, "resistance": 500.0, "light": 0, "transparent": true, "solid": false, "textures": { "all": "block/water_still" }, "translucent": true, "fluid": true }, "lava": { "id": 16, "hardness": 100.0, "resistance": 500.0, "light": 15, "transparent": true, "solid": false, "textures": { "all": "block/lava_still" }, "translucent": true, "fluid": true }, diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go index 82defb9b..58973677 100644 --- a/cmd/client/client_gl.go +++ b/cmd/client/client_gl.go @@ -11,6 +11,7 @@ import ( "flag" "fmt" "image" + "image/color" "image/png" "math" "os" @@ -22,6 +23,7 @@ import ( "mc/internal/assets" "mc/internal/atlas" + "mc/internal/audio" "mc/internal/block" "mc/internal/camera" "mc/internal/chunk" @@ -60,6 +62,7 @@ var ( spawnTest = flag.String("spawntest", "", "dev 测试:截图前在视线前方 5 格生成一个实体(zombie/animal/villager)") escTest = flag.Bool("esctest", false, "dev 测试:截图前注入一次 ESC(验证暂停菜单与光标释放链路)") craftTest = flag.Bool("crafttest", false, "dev 测试:截图前打开工作台并预填 2×2 木板(验证合成界面)") + optTest = flag.Bool("optionstest", false, "dev 测试:截图前打开设置界面") ) // clientApp 客户端应用:把会话与渲染粘合起来(engine.Ticker + engine.Renderer)。 @@ -71,21 +74,32 @@ type clientApp struct { uiR *ui.Renderer hud *ui.HUD - // 屏幕状态机:0 主菜单 / 1 游戏中 / 2 暂停 / 3 工作台(UI还原.md §2) + // 屏幕状态机:0 主菜单 / 1 游戏中 / 2 暂停 / 3 工作台 / 4 设置(UI还原.md §2) screen int titleScreen *ui.TitleScreen pauseScreen *ui.PauseScreen craftScreen *ui.CraftingScreen + optionsScr *ui.OptionsScreen + optionsRet int // 设置界面的返回屏幕(0 主菜单 / 2 暂停) held item.Stack // 合成界面手持栈(跟随鼠标) + audio *audio.Manager + // 设置值(设置界面滑条读写) + sensVal float64 + volVal float64 // 联机(多人同步.md §2:无连接时为单机) - net *netclient.Client - log *logx.Logger + net *netclient.Client + log *logx.Logger + lang *assets.Lang // 其他玩家位置(多人同步.md §3;渲染插值后置) remoteMu sync.Mutex remotePlayers map[uint32][3]float64 + // 世界参数(client.yaml world.seed/name,存档与种子联动) + worldSeed int64 + worldName string + meshBuilder *mesh.Builder uv func(string) (u0, v0, u1, v1 float32, ok bool) @@ -104,6 +118,11 @@ type clientApp struct { handDirty bool handUVs [6][4]float32 + // 准星指向方块名(tick 光栅化 CJK → 渲染线程上传,UI还原.md §3.4) + targetName string + targetImg image.Image + targetDirty bool + winW, winH int tickCnt uint64 frames int // 渲染帧计数(截图时机用) @@ -115,6 +134,7 @@ type clientApp struct { escShot bool // dev 测试:-esctest 已注入 craftShot bool // dev 测试:-crafttest 已注入 craftClickShot bool // dev 测试:-crafttest 点击输出格已注入 + optShot bool // dev 测试:-optionstest 已注入 } // worldMeshJob 待上传网格。 @@ -139,6 +159,9 @@ func (a *clientApp) Tick(dt float64) { case "play": a.screen = 1 a.requestCursor(true) + case "options": + a.screen = 4 + a.optionsRet = 0 case "multi": // 联机:连接本机服务器(多人同步.md §2) if err := dialServer(a, "127.0.0.1:25565", "玩家"); err != nil { @@ -170,6 +193,9 @@ func (a *clientApp) Tick(dt float64) { case "resume": a.screen = 1 a.requestCursor(true) + case "options": + a.screen = 4 + a.optionsRet = 2 case "quit": a.rend.Window().SetShouldClose(true) } @@ -198,6 +224,32 @@ func (a *clientApp) Tick(dt float64) { a.in.dyaw, a.in.dpitch = 0, 0 // 界面打开时不转动视角 a.sess.Tick(dt) return + case 4: // 设置(灵敏度/音量滑条 + 按键教程,UI与输入.md §6) + if a.in.escPressed { + a.leaveOptions() + a.in.escPressed = false + return + } + if a.in.clicked { + switch a.optionsScr.HitTest(a.in.clickX, a.in.clickY) { + case "sens-": + a.sensVal = clampFloat(a.sensVal-0.0005, 0.0005, 0.01) + a.in.SetSensitivity(a.sensVal) + case "sens+": + a.sensVal = clampFloat(a.sensVal+0.0005, 0.0005, 0.01) + a.in.SetSensitivity(a.sensVal) + case "vol-": + a.volVal = clampFloat(a.volVal-0.1, 0, 1) + a.audio.SetVolumes(a.volVal, 1, 1, 1) + case "vol+": + a.volVal = clampFloat(a.volVal+0.1, 0, 1) + a.audio.SetVolumes(a.volVal, 1, 1, 1) + case "done": + a.leaveOptions() + } + a.in.clicked = false + } + return } // 游戏中 if a.in.escPressed { @@ -232,6 +284,15 @@ func (a *clientApp) Tick(dt float64) { } a.sess.Tick(dt) + // 音频监听器跟随玩家(音频.md §3 距离衰减基准) + if a.audio != nil { + a.audio.SetListener(a.sess.Player.Pos[0], a.sess.Player.Pos[1], a.sess.Player.Pos[2]) + } + // 准星指向方块名(每 15 tick 更新;CJK 光栅化在 tick 线程,上传在渲染线程) + if a.tickCnt%15 == 0 { + a.updateTargetName() + } + // 联机:上报移动(20Hz,多人同步.md §3) if a.net != nil { p := a.sess.Player @@ -245,7 +306,7 @@ func (a *clientApp) Tick(dt float64) { // 定时存档:30s(存档与持久化.md §2) a.tickCnt++ if a.tickCnt%(20*30) == 0 && a.net == nil { - if err := save.SaveWorld(a.sess.World, paths.Join("worlds"), "NewWorld", 123456789, int64(a.tickCnt)); err != nil { + if err := save.SaveWorld(a.sess.World, paths.Join("worlds"), a.worldName, a.worldSeed, int64(a.tickCnt)); err != nil { // 日志由调用方输出(避免每次 tick 刷屏) _ = err } @@ -322,6 +383,8 @@ func (a *clientApp) injectDevVisuals() { e.Data = &entity.Animal{} case "villager": a.sess.Entities.Spawn(entity.KindVillager, float64(ex)+0.5, ey, float64(ez)+0.5) + case "drop": + a.sess.Entities.SpawnDrop("cobblestone", 3, float64(ex)+0.5, ey, float64(ez)+0.5) } a.logf("spawntest 注入: %s 于 (%d,%d,%d)", *spawnTest, ex, gy+1, ez) } @@ -338,6 +401,13 @@ func (a *clientApp) injectDevVisuals() { a.requestCursor(false) a.logf("crafttest 注入: 打开工作台并预填 2×2 木板") } + if *optTest && !a.optShot { + a.optShot = true + a.screen = 4 + a.optionsRet = 1 + a.requestCursor(false) + a.logf("optionstest 注入: 打开设置界面") + } } // handleCraftClick 工作台界面点击:网格/热键栏槽与手持栈交换,输出格执行合成。 @@ -403,6 +473,55 @@ func (a *clientApp) closeCrafting() { } } +// updateTargetName 计算准星指向方块的显示名(lang 本地化 → CJK 光栅化,UI还原.md §3.4)。 +// 只在目标变化时重算;CPU 光栅化在 tick 线程,GL 上传由渲染线程消费 targetDirty。 +func (a *clientApp) updateTargetName() { + name := "" + if h, ok := a.sess.Cast(6); ok { + name = a.sess.Reg.Name(a.sess.World.Block(h.X, h.Y, h.Z).ID()) + } + if name == a.targetName { + return + } + a.targetName = name + a.mu.Lock() + defer a.mu.Unlock() + if name == "" { + a.targetImg = nil + a.targetDirty = true + return + } + label := name + if a.lang != nil { + if s := a.lang.Get("block.minecraft." + name); s != "block.minecraft."+name { + label = s + } + } + if r := loadCJK(14); r != nil { + a.targetImg = r.Draw(label, color.RGBA{255, 255, 255, 255}) + } + a.targetDirty = true +} + +// leaveOptions 离开设置界面:返回来源屏幕(暂停 → 重新捕获光标)。 +func (a *clientApp) leaveOptions() { + a.screen = a.optionsRet + if a.optionsRet == 1 || a.optionsRet == 2 { + a.requestCursor(true) + } +} + +// clampFloat 数值夹取。 +func clampFloat(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + // requestCursor 请求切换光标捕获模式(由 tick 写入,渲染线程消费—— // GLFW 调用必须发生在渲染/主线程,UI与输入.md §6)。 func (a *clientApp) requestCursor(captured bool) { @@ -468,8 +587,8 @@ func (a *clientApp) Render() { } a.cursorReq.Store(-1) } - // dev 测试:截图前注入破坏裂纹/实体/ESC/工作台(必须在 3D 绘制之前,截图帧才能拍到) - if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest || *craftTest) { + // dev 测试:截图前注入破坏裂纹/实体/ESC/工作台/设置(必须在 3D 绘制之前,截图帧才能拍到) + if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest || *craftTest || *optTest) { a.injectDevVisuals() } // 上传 tick 构建好的网格(GL 调用必须在渲染线程) @@ -492,17 +611,21 @@ func (a *clientApp) Render() { chunks := a.sess.World.ActiveChunks() a.rend.DrawChunks(func(i int) (int32, int32) { return chunks[i].CX, chunks[i].CZ }, len(chunks)) - // 实体广告牌(渲染.md §5.3:僵尸/村民/动物) + // 实体广告牌(渲染.md §5.3:僵尸/村民/动物/掉落物) for _, e := range a.sess.Entities.List() { if e.Dead { continue } - key, h := entityVisual(e) + key, h := entityVisual(e, a.sess.Items) if key == "" { continue } if uv, ok := a.rend.EntityUV(key); ok { - a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h*0.6, uv) + if e.Kind == entity.KindItemDrop { + a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h, uv) + } else { + a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h*0.6, uv) + } } } @@ -533,6 +656,20 @@ func (a *clientApp) Render() { a.rend.DrawHand() } + // 准星指向方块名上传(tick 光栅化 → 渲染线程上传,UI还原.md §3.4) + a.mu.Lock() + targetDirty, targetImg := a.targetDirty, a.targetImg + a.targetDirty = false + a.mu.Unlock() + if targetDirty { + if targetImg != nil { + tex := a.uiR.UploadImage(targetImg) + a.hud.SetTarget(tex, float32(targetImg.Bounds().Dx()), float32(targetImg.Bounds().Dy())) + } else { + a.hud.SetTarget(0, 0, 0) + } + } + // UI pass(UI与输入.md §2:3D 之后、状态隔离) a.uiR.Begin() mx, my := float32(a.in.cursorX), float32(a.in.cursorY) @@ -551,6 +688,8 @@ func (a *clientApp) Render() { } res, hasRes := a.sess.CraftingResult() a.craftScreen.Draw(a.uiR, grid, res, hasRes, a.sess.Inv.Slots[:inventory.HotbarSize], a.held, mx, my, 3) + case 4: // 设置界面 + a.optionsScr.Draw(a.uiR, mx, my, float32(a.sensVal/0.01), float32(a.volVal)) } a.uiR.Flush() a.rend.EndFrame() @@ -690,7 +829,7 @@ func main() { log.Warnf("裂纹图集构建失败:%v", err) } - // 实体贴图图集(渲染.md §5.3:僵尸/村民/动物的广告牌贴图) + // 实体贴图图集(渲染.md §5.3:僵尸/村民/动物 + 掉落物物品图标) entityKeys := []string{ "entity/zombie/zombie", "entity/villager/villager", @@ -699,13 +838,32 @@ func main() { "entity/sheep/sheep", "entity/chicken/chicken_temperate", } + // 掉落物图标:全部物品纹理并入实体图集(16px 最近邻放大到 64px tile) + seen := make(map[string]struct{}, len(entityKeys)+64) + for _, k := range entityKeys { + seen[k] = struct{}{} + } + for _, name := range items.Names() { + if d, ok := items.Get(name); ok { + if _, dup := seen[d.Texture]; !dup { + seen[d.Texture] = struct{}{} + entityKeys = append(entityKeys, d.Texture) + } + } + } entityImg, entityRectMap, err := atlas.Build(am.ResolveTexture, entityKeys, 64) if err != nil { log.Warnf("实体图集构建失败:%v", err) } - // 3) 世界与生成器(区块管理.md §4 异步流水线) - gen, err := worldgen.New(reg, 123456789, 0.005, 0.01, 0.1) + // 3) 世界与生成器(区块管理.md §4 异步流水线;种子来自 client.yaml world.seed) + worldSeed := cfg.Client.World.Seed + worldName := cfg.Client.World.Name + if worldName == "" { + worldName = "NewWorld" + } + log.Infof("世界种子 %d,世界名 %s", worldSeed, worldName) + gen, err := worldgen.New(reg, worldSeed, 0.005, 0.01, 0.1) if err != nil { panic(err) } @@ -716,11 +874,11 @@ func main() { defer w.Close() // 单机存档(存档与持久化.md §3:diff-only 载入,无存档则新建) - if _, err := os.Stat(paths.Join("worlds", "NewWorld", "level.dat")); err == nil { - if _, err := save.LoadWorld(w, paths.Join("worlds"), "NewWorld"); err != nil { + if _, err := os.Stat(paths.Join("worlds", worldName, "level.dat")); err == nil { + if _, err := save.LoadWorld(w, paths.Join("worlds"), worldName); err != nil { log.Warnf("存档载入失败: %v", err) } else { - log.Infof("已载入世界 NewWorld") + log.Infof("已载入世界 %s", worldName) } } @@ -753,6 +911,12 @@ func main() { } sess := game.NewSession(w, reg, items, recipes, p, inv, cam) + // 音频(音频.md §2:事件驱动 + 3D 衰减;无音频设备自动降级静默) + audioMgr := audio.New() + defer audioMgr.Close() + audioMgr.SetVolumes(cfg.Client.Audio.Volume, 1, 1, 1) + sess.Sound = func(key string, x, y, z float64) { audioMgr.Play(key, x, y, z) } + // 5) 渲染器(GL 线程亲和在其内部锁定) rend, err := render.New(cfg.Client.WindowTitle, cfg.Client.WindowWidth, cfg.Client.WindowHeight) if err != nil { @@ -869,8 +1033,13 @@ func main() { craftScreen.SetItemAtlas(itemTex, itemUV) craftScreen.SetBlockAtlas(blockUITex, blockUV) + // 设置界面(灵敏度/音量/种子/按键教程,UI与输入.md §6) + optionsScr := buildOptionsScreen(uiR, lang, + loadTex("gui/sprites/widget/button"), loadTex("gui/sprites/widget/button_highlighted"), worldSeed) + // 6) 输入(鼠标捕获 + 键鼠回调) in := newInputState(rend.Window()) + in.SetSensitivity(cfg.Client.MouseSensitivity) // 光标初始位置 = 窗口中心(捕获模式下 GLFW 会把光标重置到中心; // 合成界面手持栈跟随光标,初始化为中心避免出现在左上角) in.cursorX, in.cursorY = float64(cfg.Client.WindowWidth)/2, float64(cfg.Client.WindowHeight)/2 @@ -885,7 +1054,14 @@ func main() { titleScreen: titleScreen, pauseScreen: pauseScreen, craftScreen: craftScreen, + optionsScr: optionsScr, + audio: audioMgr, + worldSeed: worldSeed, + worldName: worldName, + sensVal: cfg.Client.MouseSensitivity, + volVal: cfg.Client.Audio.Volume, log: log, + lang: lang, remotePlayers: make(map[uint32][3]float64), meshBuilder: mesh.NewBuilder(reg, uvFn), uv: uvFn, diff --git a/cmd/client/devshot_gl.go b/cmd/client/devshot_gl.go index a3269272..edc30a5f 100644 --- a/cmd/client/devshot_gl.go +++ b/cmd/client/devshot_gl.go @@ -8,12 +8,24 @@ import ( "image" "image/png" "os" + "path/filepath" + + "mc/internal/paths" "github.com/go-gl/gl/v3.3-core/gl" ) // saveScreenshot 从默认帧缓冲读取像素并保存为 PNG(渲染线程调用)。 +// 截图统一存项目根目录 jietu/:裸文件名自动归入该目录, +// 带目录/绝对路径保持原样(打包产物 bin/ 不再混入截图)。 func (a *clientApp) saveScreenshot(path string) error { + if filepath.Base(path) == path { // 无目录成分 → 根目录 jietu/ + dir := filepath.Join(paths.RootDir(), "jietu") + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("创建截图目录: %w", err) + } + path = filepath.Join(dir, path) + } w, h := int32(a.winW), int32(a.winH) pix := make([]uint8, w*h*4) gl.ReadPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(pix)) diff --git a/cmd/client/entities_gl.go b/cmd/client/entities_gl.go index a4e01b46..733fc5d0 100644 --- a/cmd/client/entities_gl.go +++ b/cmd/client/entities_gl.go @@ -3,11 +3,14 @@ // 实体可视映射:实体类型 → 广告牌贴图键与高度(渲染.md §5.3)。 package main -import "mc/internal/entity" +import ( + "mc/internal/entity" + "mc/internal/item" +) // entityVisual 返回实体的贴图键与身高(米)。 -// 动物按 ID 轮换四种外观(cow/pig/sheep/chicken)。 -func entityVisual(e *entity.Entity) (key string, height float32) { +// 动物按 ID 轮换四种外观(cow/pig/sheep/chicken);掉落物用物品图标(0.3 米小广告牌)。 +func entityVisual(e *entity.Entity, items *item.Registry) (key string, height float32) { switch e.Kind { case entity.KindZombie: return "entity/zombie/zombie", 1.95 @@ -24,7 +27,17 @@ func entityVisual(e *entity.Entity) (key string, height float32) { default: return "entity/chicken/chicken_temperate", 0.7 } + case entity.KindItemDrop: + d, ok := e.Data.(*entity.Drop) + if !ok || items == nil { + return "", 0 + } + def, ok := items.Get(d.Item) + if !ok { + return "", 0 + } + return def.Texture, 0.3 default: - return "", 0 // 掉落物等后置接入 + return "", 0 } } diff --git a/cmd/client/input.go b/cmd/client/input.go index 6b6cdf37..2d90fd84 100644 --- a/cmd/client/input.go +++ b/cmd/client/input.go @@ -25,6 +25,7 @@ type inputState struct { lastX, lastY float64 firstMouse bool dyaw, dpitch float64 // 累计角度增量(弧度,tick 消费) + sens float64 // 灵敏度(设置界面可调,client.yaml mouse_sensitivity) // 按键事件(tick 消费) breakDown bool @@ -41,7 +42,7 @@ type inputState struct { // newInputState 创建输入状态并注册回调。 func newInputState(win *glfw.Window) *inputState { - in := &inputState{win: win, firstMouse: true, selectSlot: -1} + in := &inputState{win: win, firstMouse: true, selectSlot: -1, sens: 0.0025} win.SetKeyCallback(in.keyCallback) win.SetMouseButtonCallback(in.mouseButtonCallback) win.SetCursorPosCallback(in.cursorCallback) @@ -49,6 +50,16 @@ func newInputState(win *glfw.Window) *inputState { return in } +// SetSensitivity 设置鼠标灵敏度(弧度/像素,设置界面)。 +func (in *inputState) SetSensitivity(s float64) { + if s > 0 { + in.sens = s + } +} + +// Sensitivity 当前灵敏度。 +func (in *inputState) Sensitivity() float64 { return in.sens } + // keyCallback 键盘回调(动作映射:WASD/空格/Shift/Ctrl/Esc)。 func (in *inputState) keyCallback(_ *glfw.Window, key glfw.Key, _ int, action glfw.Action, _ glfw.ModifierKey) { down := action != glfw.Release @@ -108,9 +119,8 @@ func (in *inputState) cursorCallback(_ *glfw.Window, x, y float64) { } 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 + in.dyaw -= dx * in.sens + in.dpitch -= dy * in.sens } // scrollCallback 滚轮切热键栏(UI与输入.md §6)。 @@ -197,9 +207,11 @@ func (in *inputState) apply(s *game.Session) { } in.selectSlot = -1 - // 破坏(按住累积,方块交互与动画.md §3) + // 左键:优先攻击实体(动物体系.md §5),未命中再破坏方块(按住累积) if in.breakDown { - s.BeginBreak() + if !s.Attack() { + s.BeginBreak() + } in.breakDown = false } if !in.breakHeld && s.BreakProgress() != 0 { diff --git a/cmd/client/screens_gl.go b/cmd/client/screens_gl.go index b1ab72d6..e684f39c 100644 --- a/cmd/client/screens_gl.go +++ b/cmd/client/screens_gl.go @@ -5,6 +5,7 @@ package main import ( + "fmt" "image" "image/color" "image/draw" @@ -110,7 +111,7 @@ func buildScreens(r *ui.Renderer, am *assets.Manager, lang *assets.Lang) (*ui.Ti title.Buttons = []ui.Button{ mkBtn("menu.singleplayer", "单人游戏", "play", 390), mkBtn("menu.multiplayer", "多人游戏", "multi", 414), - mkBtn("menu.options", "设置…", "", 438), + mkBtn("menu.options", "设置…", "options", 438), mkBtn("menu.quit", "退出游戏", "quit", 462), } @@ -121,11 +122,46 @@ func buildScreens(r *ui.Renderer, am *assets.Manager, lang *assets.Lang) (*ui.Ti } pause.Buttons = []ui.Button{ mkBtn("gui.resume", "继续游戏", "resume", 300), - mkBtn("menu.quit", "保存并退出", "quit", 324), + mkBtn("options.title", "设置…", "options", 324), + mkBtn("menu.quit", "保存并退出", "quit", 348), } return title, pause } +// buildOptionsScreen 构建设置界面(灵敏度/音量滑条、种子显示、按键教程列表)。 +func buildOptionsScreen(r *ui.Renderer, lang *assets.Lang, btnTex, btnHover uint32, seed int64) *ui.OptionsScreen { + text := func(s string, size float64, c color.RGBA) (uint32, float32, float32) { + cjk := loadCJK(size) + if cjk == nil || s == "" { + return 0, 0, 0 + } + img := cjk.Draw(s, c) + return r.UploadImage(img), float32(img.Bounds().Dx()), float32(img.Bounds().Dy()) + } + tr := func(key, fallback string, size float64, c color.RGBA) (uint32, float32, float32) { + s := fallback + if lang != nil && lang.Get(key) != key { + s = lang.Get(key) + } + return text(s, size, c) + } + white := color.RGBA{255, 255, 255, 255} + s := &ui.OptionsScreen{BtnTex: btnTex, BtnHover: btnHover} + s.TitleTex, s.TitleW, s.TitleH = tr("options.title", "设置", 40, white) + s.SensLabelTex, s.SensLabelW, s.SensLabelH = text("鼠标灵敏度", 18, white) + s.VolLabelTex, s.VolLabelW, s.VolLabelH = text("声音音量", 18, white) + s.SeedLabelTex, s.SeedLabelW, s.SeedLabelH = text(fmt.Sprintf("世界种子: %d(config/client.yaml world.seed)", seed), 16, white) + s.ControlsTex, s.ControlsW, s.ControlsH = text( + "按键教程:\n"+ + "W/A/S/D 移动 空格 跳跃 Shift 潜行 Ctrl 冲刺\n"+ + "鼠标左键 攻击/破坏 鼠标右键 放置/使用\n"+ + "E 打开背包 Q 丢弃物品 1-9/滚轮 切换快捷栏\n"+ + "Esc 暂停/返回", + 16, white) + s.DoneLabelTex, s.DoneLabelW, s.DoneLabelH = tr("options.done", "完成", 16, white) + return s +} + // tileBackground 把 dirt 纹理平铺成全屏背景(像素风土墙)。 func tileBackground(r *ui.Renderer, am *assets.Manager) uint32 { p, ok := am.ResolveTexture("block/dirt") diff --git a/config/client.yaml b/config/client.yaml index d39ec158..61e2f991 100644 --- a/config/client.yaml +++ b/config/client.yaml @@ -6,6 +6,10 @@ client: fov: 70 render_distance: 12 language: "zh_cn" + mouse_sensitivity: 0.0025 + world: + seed: 123456789 + name: "NewWorld" graphics: smooth_lighting: true mipmap: true diff --git a/internal/block/registry.go b/internal/block/registry.go index 3c3d37da..7faf2353 100644 --- a/internal/block/registry.go +++ b/internal/block/registry.go @@ -41,6 +41,7 @@ type Def struct { Fluid bool `json:"fluid"` RequiredTier int `json:"required_tier"` Drops string `json:"drops"` + NoDrop bool `json:"no_drop"` // 破坏不掉落(如树叶) Textures map[string]string `json:"textures"` Tint string `json:"tint"` // 植被染色表:grass/foliage(渲染.md §4.1) } diff --git a/internal/camera/camera.go b/internal/camera/camera.go index ad9bcfcd..acdc36d6 100644 --- a/internal/camera/camera.go +++ b/internal/camera/camera.go @@ -42,15 +42,15 @@ func (c *Camera) View() [16]float32 { // V = RotX(-pitch)·RotY(-yaw),按列主序存储(列 0/1/2 为相机基在世界的分量) var m [16]float32 - m[0] = float32(cy) // col0.x - m[1] = float32(sp * sy) // col0.y - m[2] = float32(cp * sy) // col0.z - m[4] = 0 // col1.x - m[5] = float32(cp) // col1.y - m[6] = float32(-sp) // col1.z - m[8] = float32(-sy) // col2.x - m[9] = float32(sp * cy) // col2.y - m[10] = float32(cp*cy) // col2.z + m[0] = float32(cy) // col0.x + m[1] = float32(sp * sy) // col0.y + m[2] = float32(cp * sy) // col0.z + m[4] = 0 // col1.x + m[5] = float32(cp) // col1.y + m[6] = float32(-sp) // col1.z + m[8] = float32(-sy) // col2.x + m[9] = float32(sp * cy) // col2.y + m[10] = float32(cp * cy) // col2.z m[15] = 1 // 平移:t = -V·pos px, py, pz := float32(c.Pos[0]), float32(c.Pos[1]), float32(c.Pos[2]) diff --git a/internal/camera/pitch_test.go b/internal/camera/pitch_test.go index 4ee7b978..29ad32ef 100644 --- a/internal/camera/pitch_test.go +++ b/internal/camera/pitch_test.go @@ -22,13 +22,13 @@ func TestViewPitchSteep(t *testing.T) { c := New([3]float64{8.5, 66.66, 8.5}, 0, -60*math.Pi/180, 70) m := c.View() cases := []struct { - name string + name string wx, wy, wz float64 wantBelow bool // 期望屏幕中心下方 }{ - {"below", 8.5, 64.0, 8.5, true}, // 正下方地面 - {"ahead", 8.5, 64.0, 5.0, false}, // 前方地面 - {"far-ahead", 8.5, 64.0, 0.0, false}, // 远处地面 + {"below", 8.5, 64.0, 8.5, true}, // 正下方地面 + {"ahead", 8.5, 64.0, 5.0, false}, // 前方地面 + {"far-ahead", 8.5, 64.0, 0.0, false}, // 远处地面 } for _, tc := range cases { vx, vy, vz := apply(&m, tc.wx, tc.wy, tc.wz) diff --git a/internal/cjkfont/cjkfont.go b/internal/cjkfont/cjkfont.go index 9817d152..632f8b24 100644 --- a/internal/cjkfont/cjkfont.go +++ b/internal/cjkfont/cjkfont.go @@ -8,6 +8,7 @@ import ( "image" "image/color" "os" + "strings" "golang.org/x/image/font" "golang.org/x/image/font/opentype" @@ -55,22 +56,28 @@ func (r *Renderer) Width(s string) int { return d.MeasureString(s).Ceil() } -// Draw 渲染文本为 RGBA 图像(含 1px 阴影,UI还原.md §4)。 +// Draw 渲染文本为 RGBA 图像(含 1px 阴影,UI还原.md §4;支持 \n 多行)。 func (r *Renderer) Draw(s string, c color.RGBA) image.Image { - w := r.Width(s) + 1 - h := int(r.face.Metrics().Height.Ceil()) + 1 - img := image.NewRGBA(image.Rect(0, 0, w, h)) - d := &font.Drawer{ - Dst: img, - Src: image.NewUniform(c), - Face: r.face, + lineH := int(r.face.Metrics().Height.Ceil()) + 1 + ascent := int(r.face.Metrics().Ascent.Ceil()) + lines := strings.Split(s, "\n") + w := 0 + for _, ln := range lines { + if lw := r.Width(ln) + 1; lw > w { + w = lw + } + } + img := image.NewRGBA(image.Rect(0, 0, w, lineH*len(lines))) + for i, ln := range lines { + y0 := i * lineH + // 阴影 + sh := &font.Drawer{Dst: img, Src: image.NewUniform(color.RGBA{10, 10, 10, 220}), Face: r.face} + sh.Dot = fixed.P(1, y0+ascent+1) + sh.DrawString(ln) + // 正文 + d := &font.Drawer{Dst: img, Src: image.NewUniform(c), Face: r.face} + d.Dot = fixed.P(0, y0+ascent) + d.DrawString(ln) } - // 阴影 - sh := &font.Drawer{Dst: img, Src: image.NewUniform(color.RGBA{10, 10, 10, 220}), Face: r.face} - sh.Dot = fixed.P(1, int(r.face.Metrics().Ascent.Ceil())+1) - sh.DrawString(s) - // 正文 - d.Dot = fixed.P(0, int(r.face.Metrics().Ascent.Ceil())) - d.DrawString(s) return img } diff --git a/internal/config/config.go b/internal/config/config.go index d90d1b6b..edc4033b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,14 +11,19 @@ import ( // ClientConfig 客户端配置(设计.md §14.2)。 type ClientConfig struct { Client struct { - WindowWidth int `yaml:"window_width"` - WindowHeight int `yaml:"window_height"` - WindowTitle string `yaml:"window_title"` - VSync bool `yaml:"vsync"` - FOV float64 `yaml:"fov"` - RenderDistance int `yaml:"render_distance"` - Language string `yaml:"language"` - Graphics struct { + WindowWidth int `yaml:"window_width"` + WindowHeight int `yaml:"window_height"` + WindowTitle string `yaml:"window_title"` + VSync bool `yaml:"vsync"` + FOV float64 `yaml:"fov"` + RenderDistance int `yaml:"render_distance"` + Language string `yaml:"language"` + MouseSensitivity float64 `yaml:"mouse_sensitivity"` + World struct { + Seed int64 `yaml:"seed"` // 世界生成种子(世界生成.md §2 确定性) + Name string `yaml:"name"` + } `yaml:"world"` + Graphics struct { SmoothLighting bool `yaml:"smooth_lighting"` Mipmap bool `yaml:"mipmap"` Anisotropic int `yaml:"anisotropic"` @@ -40,6 +45,9 @@ func DefaultClient() *ClientConfig { c.Client.FOV = 70 c.Client.RenderDistance = 12 c.Client.Language = "zh_cn" + c.Client.MouseSensitivity = 0.0025 + c.Client.World.Seed = 123456789 + c.Client.World.Name = "NewWorld" c.Client.Graphics.SmoothLighting = true c.Client.Graphics.Mipmap = true c.Client.Graphics.Anisotropic = 4 diff --git a/internal/entity/entity.go b/internal/entity/entity.go index 2e4edd37..092d56f3 100644 --- a/internal/entity/entity.go +++ b/internal/entity/entity.go @@ -98,6 +98,13 @@ func (m *Manager) Remove(id ID) { m.mu.Unlock() } +// Get 按 ID 取实体(不存在返回 nil)。 +func (m *Manager) Get(id ID) *Entity { + m.mu.Lock() + defer m.mu.Unlock() + return m.entities[id] +} + // List 导出实体列表(副本)。 func (m *Manager) List() []*Entity { m.mu.Lock() diff --git a/internal/game/game.go b/internal/game/game.go index 497ca796..d6ab43ab 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -54,6 +54,13 @@ type Session struct { // 工作台状态(工作台与合成表.md §5:3×3 网格 + 输出格) craftOpen bool craftGrid [9]item.Stack + + // Sound 音效回调(客户端注入,音频.md §2 事件驱动;nil 则静默) + Sound func(key string, x, y, z float64) + + // 村民生成(村民系统.md §2:村庄中心 → 村民;已生成过的中心不再生成) + villagerTimer float64 + villageSpawn map[[2]int32]bool } // NewSession 创建会话。 @@ -63,6 +70,7 @@ func NewSession(w *world.World, reg *block.Registry, items *item.Registry, recip Health: 20, Entities: entity.NewManager(), BlockEntities: make(map[[3]int32]*blockentity.Entity), rng: rand.New(rand.NewSource(12345)), + villageSpawn: make(map[[2]int32]bool), } } @@ -91,6 +99,8 @@ func (s *Session) Tick(dt float64) { s.spawnZombies(dt) // 白天刷动物(动物体系.md §6:草面、亮度≥9、距玩家 16–32、限频) s.spawnAnimals(dt) + // 村庄村民生成(村民系统.md §2:已加载区块内村庄中心 → 3 名村民) + s.spawnVillagers(dt) // 僵尸近身伤害(僵尸与敌对生物.md §3:3 点/秒) s.applyZombieDamage(dt) // 相机跟随玩家眼睛 @@ -140,6 +150,112 @@ func (s *Session) SetBreakVisual(x, y, z int32, prog float64) { s.breakProg = prog } +// AttackInRange 从相机射线寻找可达实体(半径 0.6,动物体系.md §5 攻击判定)。 +func (s *Session) AttackInRange(maxDist float64) (entity.ID, bool) { + o := s.Cam.Pos + d := s.Cam.Forward() + var best entity.ID + bestT := maxDist + for _, e := range s.Entities.List() { + if e.Dead { + continue + } + // 实体中心(脚底 + 半高) + c := [3]float64{e.Pos[0], e.Pos[1] + 0.9, e.Pos[2]} + oc := [3]float64{c[0] - o[0], c[1] - o[1], c[2] - o[2]} + t := oc[0]*d[0] + oc[1]*d[1] + oc[2]*d[2] // 射线参数 + if t <= 0 || t > bestT { + continue + } + // 点到射线距离(实体半径 0.6,与碰撞体一致) + dx := oc[0] - t*d[0] + dy := oc[1] - t*d[1] + dz := oc[2] - t*d[2] + if dx*dx+dy*dy+dz*dz < 0.6*0.6 { + best, bestT = e.ID, t + } + } + return best, best != 0 +} + +// Attack 攻击视线内最近的实体:伤害 + 击退 + 死亡掉落(动物体系.md §5、僵尸与敌对生物.md §3)。 +// 命中返回 true(调用方不再开始破坏方块)。 +func (s *Session) Attack() bool { + id, ok := s.AttackInRange(5) + if !ok { + return false + } + e := s.Entities.Get(id) + if e == nil || e.Dead { + return false + } + e.Health -= 2 + // 击退(沿视线水平方向) + f := s.Cam.Forward() + e.Vel[0] += f[0] * 4 + e.Vel[2] += f[2] * 4 + if s.Sound != nil { + s.Sound("entity.hit", e.Pos[0], e.Pos[1]+1, e.Pos[2]) + } + if e.Health > 0 { + return true + } + e.Dead = true + // 死亡掉落(与 vanilla 一致:动物掉肉,僵尸掉腐肉) + loot := "" + switch e.Kind { + case entity.KindZombie: + loot = "rotten_flesh" + case entity.KindAnimal: + switch e.ID % 4 { + case 0: + loot = "beef" + case 1: + loot = "porkchop" + case 2: + loot = "mutton" + default: + loot = "chicken" + } + } + if loot != "" { + s.Entities.SpawnDrop(loot, 1, e.Pos[0], e.Pos[1], e.Pos[2]) + } + s.Entities.Remove(id) + return true +} + +// spawnVillagers 村庄村民生成(村民系统.md §2):扫描已加载区块,村庄中心处生成 3 名村民。 +func (s *Session) spawnVillagers(dt float64) { + s.villagerTimer -= dt + if s.villagerTimer > 0 { + return + } + s.villagerTimer = 4.0 + for _, c := range s.World.ActiveChunks() { + center, ok := s.World.VillageAt(c.CX*16+8, c.CZ*16+8) + if !ok || s.villageSpawn[center] { + continue + } + s.villageSpawn[center] = true + // 找村庄中心地表 + var y int32 + for y = 255; y > 0; y-- { + if s.World.Block(center[0], y, center[1]) != block.Air { + break + } + } + for i := 0; i < 3; i++ { + e := s.Entities.Spawn(entity.KindVillager, + float64(center[0])+float64(i%2)*2, float64(y+1), float64(center[1])+float64(i/2)*2) + e.Data = &entity.Villager{} + } + if s.Sound != nil { + s.Sound("entity.villager.ambient", float64(center[0]), float64(y+1), float64(center[1])) + } + } +} + // breakSpeed 每秒破坏进度(方块交互与动画.md §3、挖矿与矿物.md §3 同公式)。 func (s *Session) breakSpeed(def block.Def, tool item.Stack) float64 { speed := 1.0 // 空手 @@ -181,6 +297,12 @@ func (s *Session) destroy(h raycast.Hit) { s.World.SetBlock(h.X, h.Y, h.Z, block.Air) // 方块实体随之销毁(物品与背包.md §6) delete(s.BlockEntities, [3]int32{h.X, h.Y, h.Z}) + if s.Sound != nil { + s.Sound("block."+def.Name+".break", float64(h.X)+0.5, float64(h.Y)+0.5, float64(h.Z)+0.5) + } + if def.NoDrop { + return + } drop := def.Drops if drop == "" { drop = def.Name @@ -227,6 +349,9 @@ func (s *Session) PlaceSelected() { return } s.World.SetBlock(px, py, pz, block.NewState(bid, 0)) + if s.Sound != nil { + s.Sound("block.place", float64(px)+0.5, float64(py)+0.5, float64(pz)+0.5) + } // 方块实体创建(物品与背包.md §6:熔炉) if d.Place == "furnace" { s.BlockEntities[[3]int32{px, py, pz}] = blockentity.NewFurnace(px, py, pz) diff --git a/internal/game/game_test.go b/internal/game/game_test.go index dd2e60ff..99d4cb68 100644 --- a/internal/game/game_test.go +++ b/internal/game/game_test.go @@ -9,6 +9,7 @@ import ( "mc/internal/block" "mc/internal/camera" + "mc/internal/entity" "mc/internal/inventory" "mc/internal/item" "mc/internal/physics" @@ -273,3 +274,70 @@ func TestCraftingCloseReturn(t *testing.T) { t.Fatalf("材料应退回背包,实际 planks=%d stick=%d", havePlanks, haveStick) } } + +// TestAttackAnimal 攻击动物:伤害递减、死亡掉落牛肉并移除(动物体系.md §5)。 +func TestAttackAnimal(t *testing.T) { + s := newTestSession(t) + s.Cam.Pitch = -0.2 // 平视前方(测试会话默认向下看,攻击需改平视) + // 玩家正前方 3 格生成动物(与相机射线同向) + e := s.Entities.Spawn(entity.KindAnimal, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]-3) + e.Data = &entity.Animal{} + if !s.Attack() { + t.Fatal("正前方动物应可攻击") + } + if got := s.Entities.Get(e.ID).Health; got != 18 { + t.Fatalf("攻击后血量应为 18,实际 %v", got) + } + // 连续攻击致死(2 点 × 10 次) + for i := 0; i < 10; i++ { + cur := s.Entities.Get(e.ID) + if cur == nil || cur.Dead { + break + } + s.Attack() + } + if s.Entities.Get(e.ID) != nil { + t.Fatal("死亡后实体应被移除") + } + // 掉落物:肉类(按实体 ID 轮换 beef/porkchop/mutton/chicken) + found := false + for _, d := range s.Entities.List() { + if d.Kind == entity.KindItemDrop { + if drop, ok := d.Data.(*entity.Drop); ok { + switch drop.Item { + case "beef", "porkchop", "mutton", "chicken": + found = true + } + } + } + } + if !found { + t.Fatal("动物死亡应掉落肉类") + } +} + +// TestBlockDropVanilla 原版掉落:草方块掉泥土(挖矿与矿物.md §5)。 +// 注意:掉落紧邻玩家会被立即拾取进背包,故断言背包而非掉落实体。 +func TestBlockDropVanilla(t *testing.T) { + s := newTestSession(t) + before := 0 + for _, st := range s.Inv.Slots { + if st.Name == "dirt" { + before += int(st.Count) + } + } + // 破坏草方块(向下看,钻石镐) + s.BeginBreak() + for s.breaking { + s.Tick(1.0 / 20) + } + after := 0 + for _, st := range s.Inv.Slots { + if st.Name == "dirt" { + after += int(st.Count) + } + } + if after <= before { + t.Fatalf("草方块破坏应掉落泥土(原版行为),破坏前 %d 后 %d", before, after) + } +} diff --git a/internal/item/item.go b/internal/item/item.go index 127bdb2c..b0226938 100644 --- a/internal/item/item.go +++ b/internal/item/item.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "sort" ) // Def 物品定义(items.json 条目)。 @@ -54,6 +55,16 @@ func (r *Registry) Get(name string) (*Def, bool) { return d, ok } +// Names 返回全部物品名(排序,掉落物图集构建等遍历用)。 +func (r *Registry) Names() []string { + names := make([]string, 0, len(r.byName)) + for n := range r.byName { + names = append(names, n) + } + sort.Strings(names) + return names +} + // Stack 物品栈:空栈 = Name == "" 且 Count == 0(物品与背包.md §2:禁止 nil 栈)。 type Stack struct { Name string diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 927c9eab..935cca22 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -31,3 +31,13 @@ func Join(rel ...string) string { parts := append([]string{BaseDir()}, rel...) return filepath.Join(parts...) } + +// RootDir 项目根目录:可执行文件所在目录;若其位于 <根>/bin/(开发构建布局) +// 则取父目录——截图等「非打包产物」统一存根目录(如 jietu/)。 +func RootDir() string { + base := BaseDir() + if filepath.Base(base) == "bin" { + return filepath.Dir(base) + } + return base +} diff --git a/internal/render/entity_gl.go b/internal/render/entity_gl.go index fc9c20e5..f325582b 100644 --- a/internal/render/entity_gl.go +++ b/internal/render/entity_gl.go @@ -9,6 +9,7 @@ package render import ( "image" + "math" "unsafe" "mc/internal/mesh" @@ -102,6 +103,14 @@ func (r *Renderer) DrawBillboard(x, y, z float32, h, w float32, uv [4]float32) { gl.Disable(gl.CULL_FACE) // 广告牌永远朝向相机,无需剔除 gl.BindVertexArray(r.billVao) gl.DrawElements(gl.TRIANGLES, 6, gl.UNSIGNED_INT, gl.PtrOffset(0)) + // 第二张交叉广告牌:水平轴换为视线方向的水平分量(俯视呈 + 形, + // 侧面不再穿帮成纸片——渲染.md §5.3) + fx, fz := -r.view[8], -r.view[10] + if fl := fx*fx + fz*fz; fl > 1e-6 { + inv := 1.0 / float32(math.Sqrt(float64(fl))) + gl.Uniform3f(rightLoc, fx*inv, 0, fz*inv) + gl.DrawElements(gl.TRIANGLES, 6, gl.UNSIGNED_INT, gl.PtrOffset(0)) + } gl.BindVertexArray(0) gl.Enable(gl.CULL_FACE) diff --git a/internal/render/renderer.go b/internal/render/renderer.go index 1c69d948..e9aa4c36 100644 --- a/internal/render/renderer.go +++ b/internal/render/renderer.go @@ -36,8 +36,8 @@ type Renderer struct { handVao, handVbo, handIbo uint32 // 实体广告牌(渲染.md §5.3) - entityTex uint32 - entityUV map[string][4]float32 + entityTex uint32 + entityUV map[string][4]float32 billVao, billVbo, billIbo uint32 proj [16]float32 diff --git a/internal/ui/hud.go b/internal/ui/hud.go index f8d3e6a2..93b5bcf1 100644 --- a/internal/ui/hud.go +++ b/internal/ui/hud.go @@ -32,6 +32,11 @@ type HUD struct { foodHalf uint32 // food_half.png haveSprites bool + // 准星指向方块名(左下角展示,UI还原.md §3.4) + targetTex uint32 + targetW float32 + targetH float32 + // 逻辑分辨率与缩放(UI还原.md §3.1:427×240 基准 × 整数缩放) baseW, baseH float32 scale float32 @@ -66,6 +71,11 @@ func (h *HUD) SetSprites(cross, hotbar, hotbarSel, heartFull, heartHalf, heartCo h.haveSprites = cross != 0 } +// SetTarget 设置准星指向的方块名标签纹理(渲染线程注入;tex=0 清除)。 +func (h *HUD) SetTarget(tex uint32, w, hgt float32) { + h.targetTex, h.targetW, h.targetH = tex, w, hgt +} + // Draw 每帧绘制 HUD(调用方已 Begin)。 func (h *HUD) Draw(r *Renderer) { w, hgt := float32(r.w), float32(r.h) @@ -160,6 +170,10 @@ func (h *HUD) Draw(r *Renderer) { if h.OnlineCount != nil { r.DrawText(w/2, hgt-82*s, fmt.Sprintf("在线玩家: %d", h.OnlineCount()), 1*s, [4]float32{1, 1, 1, 1}) } + // 准星指向的方块名(左下角,生命行上方,UI还原.md §3.4) + if h.targetTex != 0 { + h.blit(r, h.targetTex, 2*s, hgt-96*s-h.targetH, h.targetW, h.targetH) + } } // blit 以纹理原比例绘制一个四边形(UI 精灵)。 diff --git a/internal/ui/options.go b/internal/ui/options.go new file mode 100644 index 00000000..d26f44dd --- /dev/null +++ b/internal/ui/options.go @@ -0,0 +1,123 @@ +//go:build gl + +// 设置界面(UI还原.md §2、UI与输入.md §6): +// 鼠标灵敏度 / 音量滑条 + 世界种子显示 + 按键教程列表 + 完成按钮。 +// 滑条点击左/右半区为减/加;值由调用方持有并注入。 +package ui + +import ( + "fmt" +) + +// OptionsScreen 设置界面。 +type OptionsScreen struct { + BtnTex uint32 // 原版按钮控件 + BtnHover uint32 + TitleTex uint32 // 「设置」标题 + TitleW float32 + TitleH float32 + + // CJK 标签与按键教程(构建时预光栅化) + SensLabelTex uint32 + SensLabelW float32 + SensLabelH float32 + VolLabelTex uint32 + VolLabelW float32 + VolLabelH float32 + ControlsTex uint32 + ControlsW float32 + ControlsH float32 + SeedLabelTex uint32 + SeedLabelW float32 + SeedLabelH float32 + DoneLabelTex uint32 + DoneLabelW float32 + DoneLabelH float32 +} + +// 滑条区域(屏幕像素,1280×720 布局)。 +var ( + optSensBar = [4]float32{540, 260, 200, 10} + optVolBar = [4]float32{540, 330, 200, 10} + optDone = [4]float32{540, 560, 200, 20} +) + +// HitTest 命中检测:返回 "sens-"/"sens+"/"vol-"/"vol+"/"done"/""。 +func (s *OptionsScreen) HitTest(mx, my float32) string { + hit := func(r [4]float32) bool { + return mx >= r[0] && mx < r[0]+r[2] && my >= r[1] && my < r[1]+r[3] + } + if hit(optSensBar) { + if mx < optSensBar[0]+optSensBar[2]/2 { + return "sens-" + } + return "sens+" + } + if hit(optVolBar) { + if mx < optVolBar[0]+optVolBar[2]/2 { + return "vol-" + } + return "vol+" + } + if hit(optDone) { + return "done" + } + return "" +} + +// Draw 绘制设置界面(调用方已 Begin)。 +func (s *OptionsScreen) Draw(r *Renderer, mx, my, sens, vol float32) { + w, h := float32(r.w), float32(r.h) + // 背景:土质暗色 + 标题 + r.DrawRect(0, 0, w, h, [4]float32{0.10, 0.12, 0.10, 0.92}) + if s.TitleTex != 0 { + r.pushQuad(quad{x: w/2 - s.TitleW/2, y: h * 0.10, w: s.TitleW, h: s.TitleH, + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.TitleTex}) + } + // 标签 + s.blitLabel(r, s.SensLabelTex, s.SensLabelW, s.SensLabelH, 380, 255) + s.blitLabel(r, s.VolLabelTex, s.VolLabelW, s.VolLabelH, 380, 325) + // 滑条(底 + 填充 + 值) + s.drawSlider(r, optSensBar, sens) + s.drawSlider(r, optVolBar, vol) + // 世界种子 + s.blitLabel(r, s.SeedLabelTex, s.SeedLabelW, s.SeedLabelH, 380, 395) + // 按键教程列表 + s.blitLabel(r, s.ControlsTex, s.ControlsW, s.ControlsH, 380, 435) + // 完成按钮 + hover := mx >= optDone[0] && mx < optDone[0]+optDone[2] && my >= optDone[1] && my < optDone[1]+optDone[3] + tex := s.BtnTex + if hover && s.BtnHover != 0 { + tex = s.BtnHover + } + if tex != 0 { + r.pushQuad(quad{x: optDone[0], y: optDone[1], w: optDone[2], h: optDone[3], + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: tex}) + } + if s.DoneLabelTex != 0 { + r.pushQuad(quad{x: optDone[0] + (optDone[2]-s.DoneLabelW)/2, y: optDone[1] + (optDone[3]-s.DoneLabelH)/2, + w: s.DoneLabelW, h: s.DoneLabelH, u0: 0, v0: 0, u1: 1, v1: 1, + r: 1, g: 1, b: 1, a: 1, tex: s.DoneLabelTex}) + } +} + +// drawSlider 绘制滑条:底轨 + 按比例填充 + 数值文本(0–1)。 +func (s *OptionsScreen) drawSlider(r *Renderer, bar [4]float32, v float32) { + if v < 0 { + v = 0 + } + if v > 1 { + v = 1 + } + r.DrawRect(bar[0], bar[1], bar[2], bar[3], [4]float32{0.2, 0.2, 0.2, 1}) + r.DrawRect(bar[0], bar[1], bar[2]*v, bar[3], [4]float32{0.4, 0.9, 0.4, 1}) + r.DrawText(bar[0]+bar[2]+12, bar[1]-6, fmt.Sprintf("%.0f%%", v*100), 1, [4]float32{1, 1, 1, 1}) +} + +func (s *OptionsScreen) blitLabel(r *Renderer, tex uint32, w, h, x, y float32) { + if tex == 0 { + return + } + r.pushQuad(quad{x: x, y: y, w: w, h: h, u0: 0, v0: 0, u1: 1, v1: 1, + r: 1, g: 1, b: 1, a: 1, tex: tex}) +} diff --git a/internal/world/world.go b/internal/world/world.go index 42ab4840..dab903d9 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -201,6 +201,9 @@ func (w *World) BlockUnlocked(x, y, z int32) block.State { return ch.Block(int(coord.LocalCoord(x)), int(y), int(coord.LocalCoord(z))) } +// VillageAt 返回 (x,z) 附近的村庄中心(村民生成定位,村民系统.md §2)。 +func (w *World) VillageAt(x, z int32) ([2]int32, bool) { return w.gen.VillageAt(x, z) } + // SetBlock 世界级写方块(含火把光源联动,方块交互与动画.md §9): // 放置光源 → 初始化亮度 + BFS;移除光源 → relight 重算。 func (w *World) SetBlock(x, y, z int32, s block.State) { diff --git a/internal/worldgen/village.go b/internal/worldgen/village.go index e43918b7..f82c8db2 100644 --- a/internal/worldgen/village.go +++ b/internal/worldgen/village.go @@ -55,6 +55,26 @@ func (g *Generator) placeVillage(c *chunk.Chunk, cx, cz int32) { } } +// VillageAt 返回 (x,z) 附近(村庄半径内、平原/森林群系)的村庄中心, +// 用于村民生成定位(村民系统.md §2)。 +func (g *Generator) VillageAt(x, z int32) ([2]int32, bool) { + gx := floorDiv32(x, villageSpacing) + gz := floorDiv32(z, villageSpacing) + for dgx := int32(-1); dgx <= 1; dgx++ { + for dgz := int32(-1); dgz <= 1; dgz++ { + vx, vz := g.villageCenter(gx+dgx, gz+dgz) + if vx < x-villageRadius || vx > x+villageRadius || vz < z-villageRadius || vz > z+villageRadius { + continue + } + b := g.biomeAt(vx, vz) + if b == biomePlains || b == biomeForest { + return [2]int32{vx, vz}, true + } + } + } + return [2]int32{}, false +} + // inChunk 判定绝对坐标是否属于区块 (cx, cz)。 func inChunk(x, z, cx, cz int32) bool { return floorDiv32(x, 16) == cx && floorDiv32(z, 16) == cz diff --git a/文档/索引.md b/文档/索引.md index eec8bab9..a6e19ee0 100644 --- a/文档/索引.md +++ b/文档/索引.md @@ -207,3 +207,4 @@ | (实现里程碑) | M0–M3、M5 完成,M3.5/M4/M6 进行中:GL 客户端构建运行、Mod 热更新、多人同步 MVP、中文 UI(详见 路线图.md 状态) | — | | (客户端渲染修复) | 渲染.md 新增 §4.1 植被染色(原版灰度草/叶图集染色)、§5.2 覆盖层渲染(破坏裂纹/手持方块)、§5.3 实体广告牌;修复相机视图矩阵俯仰符号翻转(视角不跟随鼠标根因);HUD 换原版心/鸡腿/热键栏精灵 | — | | (工作台界面) | 客户端工作台 3×3 合成界面落地(工作台与合成表.md §5):右键命中工作台打开、槽位交换/合并、输出格合成、关闭退回材料;UI还原.md §7 补物品图标双图集规则(block/* 取 blocks 图集) | — | +| (玩法与设置) | 渲染.md §5.3 补交叉广告牌与掉落物渲染;方格垂直面绕序修复、鼠标左右反转、音频接入(音频.md §2 事件驱动)、原版掉落与动物攻击(动物体系.md §5)、村庄村民生成(村民系统.md §2)、种子/灵敏度配置与设置界面(UI与输入.md §6)、准星方块名、截图归入 jietu/ | — | diff --git a/文档/路线图.md b/文档/路线图.md index 24097f54..92818a5c 100644 --- a/文档/路线图.md +++ b/文档/路线图.md @@ -10,11 +10,11 @@ | M0 骨架 | 目录、模块接口、配置、日志、主循环 | 已完成 | | M1 世界 | 区块、确定性生成、光照、存档 | 已完成 | | M2 渲染 | 网格、图集、透明排序、视锥剔除 | 已完成 | -| M3 游戏性 | 方块交互、物理、背包、配方、实体 | 已完成(破坏裂纹叠加与手持方块渲染已补齐;合成台/熔炉 GUI 后置) | -| M3.5 生态与文明 | 动物、村民(会建造)、僵尸敌对 | 进行中(实体 AI/僵尸/村民/工作台数据与 3×3 合成界面/客户端实体渲染已完成;村民交易与动物繁殖后置) | +| M3 游戏性 | 方块交互、物理、背包、配方、实体 | 已完成(破坏裂纹叠加、手持方块、原版掉落与实体攻击已补齐;背包/熔炉 GUI 后置) | +| M3.5 生态与文明 | 动物、村民(会建造)、僵尸敌对 | 进行中(实体 AI/僵尸/村民/工作台数据与 3×3 合成界面/客户端实体渲染/动物攻击与掉落/村庄村民生成已完成;村民交易与动物繁殖后置) | | M4 多人 | 网络协议、同步、防作弊 | 进行中(协议/服务器/客户端网络层/区块流/方块广播/实体位置广播/心跳已完成;插值渲染与完整防作弊后置) | | M5 Mod | 加载器、Lua API、沙箱、资源合并 | 已完成 | -| M6 打磨 | 音频、UI、性能、崩溃恢复、发布 | 进行中(音频/UI 精灵与主菜单/光影包/存档/安装脚本/合成台界面已完成;背包与熔炉界面、性能池化与安装包构建后置) | +| M6 打磨 | 音频、UI、性能、崩溃恢复、发布 | 进行中(音频接入/UI 精灵与主菜单/设置界面(灵敏度/音量/种子/按键教程)/准星方块名/光影包/存档/安装脚本/合成台界面已完成;小地图、背包与熔炉界面、性能池化与安装包构建后置) | ## M0 骨架 @@ -133,3 +133,4 @@ | --- | --- | --- | | (初始建立) | 初版:M0–M6 里程碑与验收标准;补充快速原型路径 | — | | (客户端打磨) | 客户端渲染修复与还原:相机视图矩阵(俯仰符号)、植被染色(草/树叶)、原版 HUD 精灵(心/鸡腿/热键栏/准星)、破坏裂纹 3D 叠加、第一人称手持方块、实体广告牌渲染(僵尸/村民/动物)、主菜单字号修复;M3 破坏动画与实体渲染相关条目更新 | — | +| (玩法与设置) | 方格垂直面剔除修复(五面绕序反向致立面透明)、鼠标左右反转修复、音频接入(破坏/放置/攻击)、原版掉落(草→泥土/石→圆石/树叶无掉落)、动物攻击与死亡掉落、村庄村民生成、世界种子与鼠标灵敏度配置、设置界面(滑条/种子/按键教程)、准星方块名展示、截图归入根目录 jietu/ | — |