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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,3 +9,5 @@ tools/mingw64/
|
||||
# 本地构建缓存(沙箱环境下 GOCACHE/zig cache 重定向到工作区)
|
||||
.gocache/
|
||||
.zigcache/
|
||||
# 截图统一存储目录
|
||||
jietu/
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
123
internal/ui/options.go
Normal 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})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
1
文档/索引.md
1
文档/索引.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/ | — |
|
||||
|
||||
@@ -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/ | — |
|
||||
|
||||
Reference in New Issue
Block a user