feat(game): 音频接入、原版掉落、动物攻击、村庄村民、种子/灵敏度配置与设置界面

- 音频(音频.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/ 布局)
This commit is contained in:
NianGao Dev
2026-08-16 20:18:52 +08:00
parent ea97eaa616
commit f25ae15077
26 changed files with 734 additions and 71 deletions

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@ tools/mingw64/
# 本地构建缓存(沙箱环境下 GOCACHE/zig cache 重定向到工作区)
.gocache/
.zigcache/
# 截图统一存储目录
jietu/

View File

@@ -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 },

View File

@@ -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,

View File

@@ -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))

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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])

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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 精灵)。

123
internal/ui/options.go Normal file
View File

@@ -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})
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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/ | — |

View File

@@ -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/ | — |