From 9e2f5a6186d14afe0b4e637f4d6dd918c960fce9 Mon Sep 17 00:00:00 2001 From: NianGao Dev Date: Sun, 16 Aug 2026 20:59:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(game):=20=E5=B4=A9=E6=BA=83=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=EF=BC=88=E5=8D=B8=E8=BD=BD=E5=8C=BA=E5=9D=97=E5=AE=9E?= =?UTF-8?q?=E4=BD=93=E5=86=BB=E7=BB=93=EF=BC=89+=20=E7=AB=8B=E9=9D=A2?= =?UTF-8?q?=E5=85=89=E7=85=A7=E3=80=81=E6=8E=89=E8=90=BD=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E3=80=81=E6=89=93=E5=87=BB=E6=84=9F=E3=80=81=E6=B0=B4=E4=B8=8B?= =?UTF-8?q?=E6=95=88=E6=9E=9C=E3=80=81=E7=A9=BA=E6=89=8B=E6=8B=B3=E5=A4=B4?= =?UTF-8?q?=E3=80=81=E8=A1=80=E6=9D=A1=E5=B7=A6/=E9=A5=A5=E9=A5=BF?= =?UTF-8?q?=E5=8F=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 崩溃(index out of range):实体所在区块卸载后失去碰撞支撑无限下坠到 负坐标,光查询越界 panic——WorldView 增 ChunkLoaded,未加载区块实体冻结; chunk 光查询加越界防御(双保险) - 立面光照:mesh 每面取**邻格**光照(原版光照模型)——此前用方块自身光照 (sky=0)导致悬崖/矿洞立面全黑(泥土竖向黑色根因) - 掉落可见:生成后 1 秒拾取延迟 + 图标加大到 0.45 - 打击感:攻击命中音(block.hit)+ 实体受击白闪(uTint)+ 手持挥动动画 - 水下效果:相机入水蓝色叠加(渲染.md §8 水体) - 空手显示肤色拳头(白羊毛 × 肤色 tint),持方块正常显示 - HUD 布局:血条左下、饥饿右下(经典布局) --- cmd/client/client_gl.go | 88 +++++++++++++++++++++++++++------- cmd/client/entities_gl.go | 2 +- internal/chunk/chunk.go | 37 ++++++++++---- internal/entity/drops.go | 11 ++++- internal/entity/entity.go | 15 +++++- internal/entity/entity_test.go | 3 ++ internal/game/game.go | 4 ++ internal/game/game_test.go | 5 ++ internal/mesh/mesh.go | 21 ++++++-- internal/render/entity_gl.go | 9 +++- internal/render/mesh_gl.go | 2 + internal/render/overlay_gl.go | 8 +++- internal/render/renderer.go | 4 +- internal/ui/hud.go | 16 ++++++- internal/world/world.go | 33 +++++++++++++ 文档/场景/实体系统.md | 1 + 16 files changed, 223 insertions(+), 36 deletions(-) diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go index bb12caff..22a7faa0 100644 --- a/cmd/client/client_gl.go +++ b/cmd/client/client_gl.go @@ -65,6 +65,7 @@ var ( optTest = flag.Bool("optionstest", false, "dev 测试:截图前打开设置界面") invTest = flag.Bool("invtest", false, "dev 测试:截图前打开物品栏(E 键界面)并放入若干物品") miniDump = flag.String("minimapdump", "", "dev 诊断:保存一次小地图图像到该路径") + waterTest = flag.Bool("underwatertest", false, "dev 测试:截图前置水下渲染状态(验证蓝色叠加)") ) // clientApp 客户端应用:把会话与渲染粘合起来(engine.Ticker + engine.Renderer)。 @@ -120,6 +121,11 @@ type clientApp struct { handHas bool handDirty bool handUVs [6][4]float32 + handTint [3]float32 // 手持颜色乘数(空手肤色拳 / 持方块 1,1,1) + swing float64 // 挥动动画剩余量(左/右键触发,渲染线程衰减) + + // 水下状态(渲染线程读;tick 更新) + underwater bool // 准星指向方块名(tick 光栅化 CJK → 渲染线程上传,UI还原.md §3.4) targetName string @@ -145,6 +151,7 @@ type clientApp struct { optShot bool // dev 测试:-optionstest 已注入 invShot bool // dev 测试:-invtest 已注入 miniDumped bool // dev 诊断:-minimapdump 已保存 + waterShot bool // dev 测试:-underwatertest 已注入 } // worldMeshJob 待上传网格。 @@ -291,7 +298,14 @@ func (a *clientApp) Tick(dt float64) { a.requestCursor(false) return } + // 挥动动画触发(打击感:左/右键按下时手部挥动) + if a.in.breakDown || a.in.placeDown { + a.swing = 1.0 + } a.in.apply(a.sess) + // 水下检测(相机所在方块为水 → 水下渲染效果) + camB := a.sess.World.Block(int32(a.sess.Cam.Pos[0]), int32(a.sess.Cam.Pos[1]), int32(a.sess.Cam.Pos[2])) + a.underwater = a.sess.Reg.IsFluid(camB) || *waterTest // 右键命中工作台 → 打开合成界面(apply 内 TryOpenCrafting 已置状态) if a.sess.CraftingOpen() && a.screen == 1 { a.screen = 3 @@ -457,6 +471,11 @@ func (a *clientApp) injectDevVisuals() { a.requestCursor(false) a.logf("invtest 注入: 打开物品栏") } + if *waterTest && !a.waterShot { + a.waterShot = true + a.underwater = true + a.logf("underwatertest 注入: 水下渲染状态") + } } // handleCraftClick 工作台界面点击:网格/热键栏槽与手持栈交换,输出格执行合成。 @@ -708,25 +727,20 @@ func (a *clientApp) requestCursor(captured bool) { func (a *clientApp) computeHand() { a.handInit = true a.handSlot = a.sess.Inv.Selected - setEmpty := func() { - a.mu.Lock() - a.handHas = false - a.handDirty = true - a.mu.Unlock() - } sel := a.sess.Inv.SelectedStack() if sel.Empty() { - setEmpty() + a.computeFistHand() return } d, ok := a.sess.Items.Get(sel.Name) if !ok || d.Place == "" { - setEmpty() + // 非可放置物品:同样显示肤色拳(原版按物品模型渲染,后置) + a.computeFistHand() return } bid, ok := a.reg.ID(d.Place) if !ok { - setEmpty() + a.computeFistHand() return } st := block.NewState(bid, 0) @@ -742,6 +756,32 @@ func (a *clientApp) computeHand() { a.mu.Lock() a.handUVs = uvs a.handHas = true + a.handTint = [3]float32{1, 1, 1} + a.handDirty = true + a.mu.Unlock() +} + +// newMeshBuilder 创建网格构建器:面光照取邻格光照(原版光照模型, +// 光照.md 顶点光照——方块自身 sky=0,用自身光照会让悬崖/矿洞立面全黑)。 +func newMeshBuilder(reg *block.Registry, uvFn func(string) (u0, v0, u1, v1 float32, ok bool), sess *game.Session) *mesh.Builder { + b := mesh.NewBuilder(reg, uvFn) + b.SetLight(func(x, y, z int32) (uint8, uint8) { + return sess.World.SkyLightUnlocked(x, y, z), sess.World.BlockLightUnlocked(x, y, z) + }) + return b +} + +// computeFistHand 空手/非方块物品:肤色拳头(白羊毛纹理 × 肤色 tint,渲染.md §5.2)。 +func (a *clientApp) computeFistHand() { + u0, v0, u1, v1, found := a.uv("block/white_wool") + if !found { + u0, v0, u1, v1 = 0, 0, 1, 1 + } + uvs := [4]float32{u0, v0, u1, v1} + a.mu.Lock() + a.handUVs = [6][4]float32{uvs, uvs, uvs, uvs, uvs, uvs} + a.handHas = true + a.handTint = [3]float32{0.85, 0.65, 0.5} // 肤色 a.handDirty = true a.mu.Unlock() } @@ -758,8 +798,8 @@ func (a *clientApp) Render() { } a.cursorReq.Store(-1) } - // dev 测试:截图前注入破坏裂纹/实体/ESC/工作台/设置/物品栏(必须在 3D 绘制之前,截图帧才能拍到) - if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest || *craftTest || *optTest || *invTest) { + // dev 测试:截图前注入破坏裂纹/实体/ESC/工作台/设置/物品栏/水下(必须在 3D 绘制之前,截图帧才能拍到) + if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest || *craftTest || *optTest || *invTest || *waterTest) { a.injectDevVisuals() } // 上传 tick 构建好的网格(GL 调用必须在渲染线程) @@ -793,9 +833,9 @@ func (a *clientApp) Render() { } if uv, ok := a.rend.EntityUV(key); ok { if e.Kind == entity.KindItemDrop { - a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h, uv) + a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h, uv, false) } else { - a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h*0.6, uv) + a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h*0.6, uv, e.HitFlash > 0) } } } @@ -808,23 +848,38 @@ func (a *clientApp) Render() { } } // 手持方块上传与绘制(渲染.md §5.2;上传必须是渲染线程) + // 挥动动画:左/右键后手部斜向下摆,逐帧重传立方体 + if a.swing > 0 { + a.swing -= 0.12 + if a.swing < 0 { + a.swing = 0 + } + } a.mu.Lock() handDirty, handHas := a.handDirty, a.handHas a.handDirty = false var handUVs [6][4]float32 + var handTint [3]float32 if handHas { handUVs = a.handUVs + handTint = a.handTint } a.mu.Unlock() + swingPos := [3]float32{0.46, -0.44, -0.66} + if a.swing > 0 { + swingPos[0] -= float32(a.swing) * 0.22 // 挥动:右移 + 下压 + swingPos[1] -= float32(a.swing) * 0.18 + handDirty = true // 挥动期间逐帧重传位置 + } if handDirty { if handHas { - a.rend.SetHandCube(handUVs, [3]float32{0.46, -0.44, -0.66}, 0.16) + a.rend.SetHandCube(handUVs, swingPos, 0.16) } else { a.rend.ClearHand() } } if a.screen == 1 { - a.rend.DrawHand() + a.rend.DrawHand(handTint) } // 准星指向方块名上传(tick 光栅化 → 渲染线程上传,UI还原.md §3.4) @@ -852,6 +907,7 @@ func (a *clientApp) Render() { // UI pass(UI与输入.md §2:3D 之后、状态隔离) a.uiR.Begin() + a.hud.SetUnderwater(a.underwater) mx, my := float32(a.in.cursorX), float32(a.in.cursorY) switch a.screen { case 0: @@ -1255,7 +1311,7 @@ func main() { log: log, lang: lang, remotePlayers: make(map[uint32][3]float64), - meshBuilder: mesh.NewBuilder(reg, uvFn), + meshBuilder: newMeshBuilder(reg, uvFn, sess), uv: uvFn, handSlot: -1, winW: cfg.Client.WindowWidth, diff --git a/cmd/client/entities_gl.go b/cmd/client/entities_gl.go index 733fc5d0..df6aba65 100644 --- a/cmd/client/entities_gl.go +++ b/cmd/client/entities_gl.go @@ -36,7 +36,7 @@ func entityVisual(e *entity.Entity, items *item.Registry) (key string, height fl if !ok { return "", 0 } - return def.Texture, 0.3 + return def.Texture, 0.45 default: return "", 0 } diff --git a/internal/chunk/chunk.go b/internal/chunk/chunk.go index 341ac25a..95dbc24b 100644 --- a/internal/chunk/chunk.go +++ b/internal/chunk/chunk.go @@ -79,17 +79,38 @@ func (c *Chunk) FillAll(s block.State) { } } -// SkyLight 读取天空光(4-bit,0–15)。 -func (c *Chunk) SkyLight(x, y, z int) uint8 { return nibble(c.sky, Index(x, y, z)) } +// SkyLight 读取天空光(4-bit,0–15;越界返回 0——实体在未加载区块 +// 下落时查询负数坐标不应崩溃,区块管理.md §6 防御)。 +func (c *Chunk) SkyLight(x, y, z int) uint8 { + if x < 0 || x >= SizeX || y < 0 || y >= Height || z < 0 || z >= SizeZ { + return 0 + } + return nibble(c.sky, Index(x, y, z)) +} -// SetSkyLight 写入天空光。 -func (c *Chunk) SetSkyLight(x, y, z int, v uint8) { setNibble(c.sky, Index(x, y, z), v) } +// SetSkyLight 写入天空光(越界忽略)。 +func (c *Chunk) SetSkyLight(x, y, z int, v uint8) { + if x < 0 || x >= SizeX || y < 0 || y >= Height || z < 0 || z >= SizeZ { + return + } + setNibble(c.sky, Index(x, y, z), v) +} -// BlockLight 读取方块光(4-bit,0–15)。 -func (c *Chunk) BlockLight(x, y, z int) uint8 { return nibble(c.blockLt, Index(x, y, z)) } +// BlockLight 读取方块光(4-bit,0–15;越界返回 0)。 +func (c *Chunk) BlockLight(x, y, z int) uint8 { + if x < 0 || x >= SizeX || y < 0 || y >= Height || z < 0 || z >= SizeZ { + return 0 + } + return nibble(c.blockLt, Index(x, y, z)) +} -// SetBlockLight 写入方块光。 -func (c *Chunk) SetBlockLight(x, y, z int, v uint8) { setNibble(c.blockLt, Index(x, y, z), v) } +// SetBlockLight 写入方块光(越界忽略)。 +func (c *Chunk) SetBlockLight(x, y, z int, v uint8) { + if x < 0 || x >= SizeX || y < 0 || y >= Height || z < 0 || z >= SizeZ { + return + } + setNibble(c.blockLt, Index(x, y, z), v) +} // nibble 读取半字节(偶数索引 → 低 4 位,奇数索引 → 高 4 位)。 func nibble(arr []uint8, idx int) uint8 { diff --git a/internal/entity/drops.go b/internal/entity/drops.go index 2d644dae..180510e9 100644 --- a/internal/entity/drops.go +++ b/internal/entity/drops.go @@ -3,6 +3,7 @@ package entity import ( "math" + "time" ) // 掉落物参数(实体系统.md §5)。 @@ -13,10 +14,14 @@ const ( dropPerChunk = 32 // 每区块掉落物上限 ) +// dropPickupDelay 生成后可拾取延迟(测试可置 0)。 +var dropPickupDelay = time.Second + // Drop 掉落物实体数据。 type Drop struct { Item string Count int + Born time.Time // 生成时刻(1 秒内不可拾取,掉落可见期) } // SpawnDrop 生成掉落物:同物品近距合并(上限 dropStackMax);超上限丢弃。 @@ -48,7 +53,7 @@ func (m *Manager) SpawnDrop(itemName string, count int, x, y, z float64) { } m.next++ e := &Entity{ID: m.next, Kind: KindItemDrop, Pos: [3]float64{x, y, z}, Health: 1, - Data: &Drop{Item: itemName, Count: count}} + Data: &Drop{Item: itemName, Count: count, Born: time.Now()}} e.Vel = [3]float64{(rand01(e.ID) - 0.5) * 2, 4, (rand01(e.ID>>16) - 0.5) * 2} // 弹出初速度 m.entities[e.ID] = e m.drops[itemName] = e @@ -62,6 +67,7 @@ func rand01(seed ID) float64 { } // PickupNearby 拾取玩家附近掉落物,返回拾取的物品列表(实体系统.md §5 拾取)。 +// 生成后 1 秒内不可拾取(掉落可见期,与原版一致)。 func (m *Manager) PickupNearby(px, py, pz float64) []Drop { var out []Drop for _, e := range m.List() { @@ -72,6 +78,9 @@ func (m *Manager) PickupNearby(px, py, pz float64) []Drop { if !ok { continue } + if time.Since(d.Born) < dropPickupDelay { + continue // 新鲜掉落:可拾取延迟(掉落可见) + } dx, dy, dz := e.Pos[0]-px, e.Pos[1]-py, e.Pos[2]-pz if dx*dx+dy*dy+dz*dz < dropPickupDist*dropPickupDist { e.Dead = true diff --git a/internal/entity/entity.go b/internal/entity/entity.go index 092d56f3..cfa721df 100644 --- a/internal/entity/entity.go +++ b/internal/entity/entity.go @@ -33,6 +33,9 @@ type WorldView interface { Block(x, y, z int32) block.State SkyLight(x, y, z int32) uint8 BlockLight(x, y, z int32) uint8 + // ChunkLoaded 实体所在区块是否已加载(卸载区块冻结实体—— + // 否则实体失去碰撞支撑会无限下坠到负数坐标,见 实体系统.md §2) + ChunkLoaded(x, z int32) bool } // Entity 实体。 @@ -45,6 +48,9 @@ type Entity struct { Health float64 Dead bool + // HitFlash 受击白闪剩余秒数(客户端渲染用,打击反馈) + HitFlash float64 + // AI 状态(实体系统.md §3 状态机) state uint8 decisionIn float64 // 下次决策倒计时(限频) @@ -116,12 +122,19 @@ func (m *Manager) List() []*Entity { return out } -// Tick 每 tick 更新全部实体(激活区块内;冻结区由调用方过滤)。 +// Tick 每 tick 更新全部实体(激活区块内;冻结区跳过,实体系统.md §2 +// ——未加载区块无碰撞支撑,实体继续 tick 会无限下坠并越界崩溃)。 func (m *Manager) Tick(dt float64, w WorldView, playerPos [3]float64) { for _, e := range m.List() { if e.Dead { continue } + if !w.ChunkLoaded(int32(e.Pos[0]), int32(e.Pos[2])) { + continue // 区块卸载:冻结 + } + if e.HitFlash > 0 { + e.HitFlash -= dt // 受击白闪衰减 + } switch e.Kind { case KindZombie: e.tickZombie(dt, w, playerPos) diff --git a/internal/entity/entity_test.go b/internal/entity/entity_test.go index bc171bda..79489136 100644 --- a/internal/entity/entity_test.go +++ b/internal/entity/entity_test.go @@ -17,6 +17,7 @@ func (flatWorld) Solid(x, y, z int32) bool { return y < 63 } func (flatWorld) Block(x, y, z int32) block.State { return block.Air } func (flatWorld) SkyLight(x, y, z int32) uint8 { return 15 } func (flatWorld) BlockLight(x, y, z int32) uint8 { return 0 } +func (flatWorld) ChunkLoaded(x, z int32) bool { return true } // TestZombieChase 僵尸追玩家(僵尸与敌对生物.md §2)。 func TestZombieChase(t *testing.T) { @@ -64,6 +65,8 @@ func TestAnimalWander(t *testing.T) { // TestDropMerge 掉落物合并(实体系统.md §5)。 func TestDropMerge(t *testing.T) { + dropPickupDelay = 0 // 测试跳过拾取延迟 + defer func() { dropPickupDelay = time.Second }() m := NewManager() m.SpawnDrop("cobblestone", 5, 0.5, 64, 0.5) m.SpawnDrop("cobblestone", 5, 0.6, 64, 0.5) // 距离 <1 → 合并 diff --git a/internal/game/game.go b/internal/game/game.go index 698077ad..dff7dc7d 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -125,6 +125,9 @@ func (s *Session) BeginBreak() { s.breakTarget = h s.breaking = true s.breakProg = 0 + if s.Sound != nil { + s.Sound("block.hit", float64(h.X)+0.5, float64(h.Y)+0.5, float64(h.Z)+0.5) + } if s.Creative { s.BreakNow() } @@ -190,6 +193,7 @@ func (s *Session) Attack() bool { return false } e.Health -= 2 + e.HitFlash = 0.15 // 受击白闪(打击反馈,客户端渲染) // 击退(沿视线水平方向) f := s.Cam.Forward() e.Vel[0] += f[0] * 4 diff --git a/internal/game/game_test.go b/internal/game/game_test.go index 99d4cb68..070020a6 100644 --- a/internal/game/game_test.go +++ b/internal/game/game_test.go @@ -331,6 +331,11 @@ func TestBlockDropVanilla(t *testing.T) { for s.breaking { s.Tick(1.0 / 20) } + // 掉落有 1 秒真实时间拾取延迟(Born 为墙钟时间):等待后 tick 触发拾取 + time.Sleep(1100 * time.Millisecond) + for i := 0; i < 2; i++ { + s.Tick(1.0 / 20) + } after := 0 for _, st := range s.Inv.Slots { if st.Name == "dirt" { diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go index 2f5dafcb..90c7cd45 100644 --- a/internal/mesh/mesh.go +++ b/internal/mesh/mesh.go @@ -48,13 +48,18 @@ type ChunkMesh struct { // Neighbor 邻居方块查询(跨区块面剔除用,区块管理.md §6)。 type Neighbor func(x, y, z int32) block.State +// Light 邻居光照查询(面光照用:每个面取**邻格**的光照等级—— +// 方块自身 sky=0,用自身光照会让所有立面全黑(悬崖/矿洞立面黑色根因)。 +type Light func(x, y, z int32) (sky, blk uint8) + // UVFunc 纹理键 → 图集 UV(归一化坐标,含 inset)。 type UVFunc func(texKey string) (u0, v0, u1, v1 float32, ok bool) // Builder 网格构建器。 type Builder struct { - reg *block.Registry - uv UVFunc + reg *block.Registry + uv UVFunc + light Light // 面光照查询(邻格光照;nil 回退自身光照) } // NewBuilder 创建构建器。 @@ -62,6 +67,9 @@ func NewBuilder(reg *block.Registry, uv UVFunc) *Builder { return &Builder{reg: reg, uv: uv} } +// SetLight 设置面光照查询(每个面取邻格光照,光照.md 顶点光照)。 +func (b *Builder) SetLight(fn Light) { b.light = fn } + // face 一个面的 4 个角偏移与朝向。 type faceDef struct { dir [3]int32 @@ -114,7 +122,6 @@ func (b *Builder) Build(c *chunk.Chunk, nb Neighbor) *ChunkMesh { wx := int32(x) + c.CX*16 wy := int32(y) wz := int32(z) + c.CZ*16 - sky, blk := c.SkyLight(x, y, z), c.BlockLight(x, y, z) for fi, f := range faces { nx := wx + f.dir[0] @@ -129,6 +136,14 @@ func (b *Builder) Build(c *chunk.Chunk, nb Neighbor) *ChunkMesh { b.reg.IsTranslucent(neighbor) == b.reg.IsTranslucent(s) && b.reg.IsTranslucent(s) { continue } + // 面光照:取**邻格**的光照(原版光照模型)——方块自身 sky=0, + // 用自身光照会让悬崖/矿洞立面全黑 + sky, blk := uint8(0), uint8(0) + if b.light != nil { + sky, blk = b.light(nx, ny, nz) + } else { + sky, blk = c.SkyLight(x, y, z), c.BlockLight(x, y, z) + } texKey := b.reg.Texture(s, faceTexture[fi]) u0, v0, u1, v1, ok := b.uv(texKey) if !ok { diff --git a/internal/render/entity_gl.go b/internal/render/entity_gl.go index f325582b..20f6d92b 100644 --- a/internal/render/entity_gl.go +++ b/internal/render/entity_gl.go @@ -71,7 +71,8 @@ func (r *Renderer) ensureBillQuad() { } // DrawBillboard 在 (x,y,z)(脚底)绘制 h 高 × w 宽的实体广告牌,贴图 uv。 -func (r *Renderer) DrawBillboard(x, y, z float32, h, w float32, uv [4]float32) { +// flash=true 时整体白闪(受击反馈,打击感)。 +func (r *Renderer) DrawBillboard(x, y, z float32, h, w float32, uv [4]float32, flash bool) { if r.entityTex == 0 { return } @@ -88,6 +89,12 @@ func (r *Renderer) DrawBillboard(x, y, z float32, h, w float32, uv [4]float32) { gl.Uniform3f(offLoc, x, y, z) cutLoc := gl.GetUniformLocation(r.prog, gl.Str("uCutout\x00")) gl.Uniform1f(cutLoc, 0) + tintLoc := gl.GetUniformLocation(r.prog, gl.Str("uTint\x00")) + if flash { + gl.Uniform4f(tintLoc, 2.5, 2.5, 2.5, 1) // 受击白闪 + } else { + gl.Uniform4f(tintLoc, 1, 1, 1, 1) + } // 相机基向量(视图矩阵列 0/1 即世界系下的 right/up) rightLoc := gl.GetUniformLocation(r.prog, gl.Str("uCamRight\x00")) upLoc := gl.GetUniformLocation(r.prog, gl.Str("uCamUp\x00")) diff --git a/internal/render/mesh_gl.go b/internal/render/mesh_gl.go index b6a42d22..65d71b2a 100644 --- a/internal/render/mesh_gl.go +++ b/internal/render/mesh_gl.go @@ -110,6 +110,8 @@ func (r *Renderer) DrawChunks(get func(i int) (int32, int32), n int) { uvMaxLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00")) gl.Uniform2f(uvMinLoc, 0, 0) // 区块顶点 UV 直接使用 gl.Uniform2f(uvMaxLoc, 1, 1) + tintLoc := gl.GetUniformLocation(r.prog, gl.Str("uTint\x00")) + gl.Uniform4f(tintLoc, 1, 1, 1, 1) // 区块无颜色乘数 // pass 1+2:不透明与 cutout(alpha test,不排序) gl.Uniform1f(cutLoc, 0) diff --git a/internal/render/overlay_gl.go b/internal/render/overlay_gl.go index 6293bf6d..0a02dee1 100644 --- a/internal/render/overlay_gl.go +++ b/internal/render/overlay_gl.go @@ -150,6 +150,8 @@ func (r *Renderer) DrawCrackOverlay(x, y, z float32, stage int) { uvMaxLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00")) gl.Uniform2f(uvMinLoc, 0, 0) gl.Uniform2f(uvMaxLoc, 1, 1) + tintLoc := gl.GetUniformLocation(r.prog, gl.Str("uTint\x00")) + gl.Uniform4f(tintLoc, 1, 1, 1, 1) gl.Enable(gl.POLYGON_OFFSET_FILL) gl.PolygonOffset(-1, -1) @@ -206,8 +208,8 @@ func (r *Renderer) ClearHand() { } } -// DrawHand 绘制手持方块(顶点已是视图空间,仅乘投影矩阵)。 -func (r *Renderer) DrawHand() { +// DrawHand 绘制手持方块(顶点已是视图空间,仅乘投影矩阵;tint 肤色乘数)。 +func (r *Renderer) DrawHand(tint [3]float32) { if r.handVao == 0 { return } @@ -226,6 +228,8 @@ func (r *Renderer) DrawHand() { uvMaxLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00")) gl.Uniform2f(uvMinLoc, 0, 0) gl.Uniform2f(uvMaxLoc, 1, 1) + tintLoc := gl.GetUniformLocation(r.prog, gl.Str("uTint\x00")) + gl.Uniform4f(tintLoc, tint[0], tint[1], tint[2], 1) gl.BindVertexArray(r.handVao) gl.DrawElements(gl.TRIANGLES, 36, gl.UNSIGNED_INT, gl.PtrOffset(0)) gl.BindVertexArray(0) diff --git a/internal/render/renderer.go b/internal/render/renderer.go index e9aa4c36..2b54fee0 100644 --- a/internal/render/renderer.go +++ b/internal/render/renderer.go @@ -250,12 +250,14 @@ void main() { ` // chunkFragSrc 默认方块片元着色器:cutout 剔除 + 光照亮度混合。 +// uTint 颜色乘数(实体受击白闪/手持肤色,默认 1,1,1,1)。 const chunkFragSrc = ` #version 330 core in vec2 vUV; in vec2 vLight; uniform sampler2D uTex; uniform float uCutout; // 1=alpha test(树叶/玻璃),0=普通/半透明 +uniform vec4 uTint; out vec4 fragColor; void main() { vec4 c = texture(uTex, vUV); @@ -263,6 +265,6 @@ void main() { float sky = vLight.x / 15.0; float blk = vLight.y / 15.0; float b = max(sky, blk * 0.9 + 0.06); - fragColor = vec4(c.rgb * b, c.a); + fragColor = vec4(c.rgb * b * uTint.rgb, c.a); } ` diff --git a/internal/ui/hud.go b/internal/ui/hud.go index 7d336e05..0790a539 100644 --- a/internal/ui/hud.go +++ b/internal/ui/hud.go @@ -40,6 +40,9 @@ type HUD struct { // 小地图(左上角,UI还原.md §3.5) minimapTex uint32 + // 水下渲染(渲染.md §8 水体效果:蓝色叠加) + underwater bool + // 逻辑分辨率与缩放(UI还原.md §3.1:427×240 基准 × 整数缩放) baseW, baseH float32 scale float32 @@ -82,11 +85,19 @@ func (h *HUD) SetTarget(tex uint32, w, hgt float32) { // SetMinimap 设置小地图纹理(渲染线程注入;tex=0 清除)。 func (h *HUD) SetMinimap(tex uint32) { h.minimapTex = tex } +// SetUnderwater 设置水下状态(蓝色叠加与雾效)。 +func (h *HUD) SetUnderwater(b bool) { h.underwater = b } + // Draw 每帧绘制 HUD(调用方已 Begin)。 func (h *HUD) Draw(r *Renderer) { w, hgt := float32(r.w), float32(r.h) s := h.scale + // 水下效果:蓝色叠加(渲染.md §8 水体) + if h.underwater { + r.DrawRect(0, 0, w, hgt, [4]float32{0.05, 0.2, 0.45, 0.42}) + } + // 准星(原版 crosshair.png 15×15,UI还原.md §3.4) cx, cy := w/2, hgt/2 if h.haveSprites { @@ -147,11 +158,12 @@ func (h *HUD) Draw(r *Renderer) { } } - // 饥饿:10 鸡腿(原版 food_full,生命行下方) + // 饥饿:10 鸡腿(原版 food_full,屏幕**右下**——血条左、饥饿右的经典布局) food := 20 // 饥饿值(饥饿系统后置,恒定 20) fy := hgt - 39*s + fx := w/2 + 11*s // 与左侧生命行镜像对称(w/2+11 .. w/2+91) for i := 0; i < 10; i++ { - x := hx + float32(i)*8*s + x := fx + float32(i)*8*s switch { case food >= (i+1)*2: if h.haveSprites { diff --git a/internal/world/world.go b/internal/world/world.go index dab903d9..6d36f539 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -204,6 +204,39 @@ func (w *World) BlockUnlocked(x, y, z int32) block.State { // VillageAt 返回 (x,z) 附近的村庄中心(村民生成定位,村民系统.md §2)。 func (w *World) VillageAt(x, z int32) ([2]int32, bool) { return w.gen.VillageAt(x, z) } +// ChunkLoaded 区块是否已加载(entity.WorldView 接口:卸载区块冻结实体)。 +func (w *World) ChunkLoaded(x, z int32) bool { + w.mu.RLock() + _, ok := w.chunks[coordKey(coord.ChunkCoord(x), coord.ChunkCoord(z))] + w.mu.RUnlock() + return ok +} + +// SkyLightUnlocked 无锁读天空光(仅在 WithReadLock 回调内调用,mesh 构建面光照用; +// 未加载区块返回 15——面朝向未加载区时按天空亮度处理)。 +func (w *World) SkyLightUnlocked(x, y, z int32) uint8 { + if y < 0 || y >= chunk.Height { + return 15 + } + ch := w.chunks[coordKey(coord.ChunkCoord(x), coord.ChunkCoord(z))] + if ch == nil { + return 15 + } + return ch.SkyLight(int(coord.LocalCoord(x)), int(y), int(coord.LocalCoord(z))) +} + +// BlockLightUnlocked 无锁读方块光(仅在 WithReadLock 回调内调用;未加载返回 0)。 +func (w *World) BlockLightUnlocked(x, y, z int32) uint8 { + if y < 0 || y >= chunk.Height { + return 0 + } + ch := w.chunks[coordKey(coord.ChunkCoord(x), coord.ChunkCoord(z))] + if ch == nil { + return 0 + } + return ch.BlockLight(int(coord.LocalCoord(x)), int(y), int(coord.LocalCoord(z))) +} + // SetBlock 世界级写方块(含火把光源联动,方块交互与动画.md §9): // 放置光源 → 初始化亮度 + BFS;移除光源 → relight 重算。 func (w *World) SetBlock(x, y, z int32, s block.State) { diff --git a/文档/场景/实体系统.md b/文档/场景/实体系统.md index 5a48213e..fd804dfa 100644 --- a/文档/场景/实体系统.md +++ b/文档/场景/实体系统.md @@ -83,3 +83,4 @@ spawn → tick(激活区块内)→ damage → death → 掉落物 → despaw | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:生命周期、AI 状态机与限频寻路、自然生成、掉落物、玩家状态、踩坑清单 | — | +| (稳定性) | 卸载区块实体冻结(WorldView.ChunkLoaded):实体所在区块卸载后跳过 tick——否则失去碰撞支撑无限下坠到负坐标,光查询越界崩溃(chunk 光查询同步加越界防御);掉落物生成后 1 秒拾取延迟(掉落可见);实体受击白闪 HitFlash(打击反馈) | — |