fix(game): 崩溃修复(卸载区块实体冻结)+ 立面光照、掉落可见、打击感、水下效果、空手拳头、血条左/饥饿右

- 崩溃(index out of range):实体所在区块卸载后失去碰撞支撑无限下坠到
  负坐标,光查询越界 panic——WorldView 增 ChunkLoaded,未加载区块实体冻结;
  chunk 光查询加越界防御(双保险)
- 立面光照:mesh 每面取**邻格**光照(原版光照模型)——此前用方块自身光照
  (sky=0)导致悬崖/矿洞立面全黑(泥土竖向黑色根因)
- 掉落可见:生成后 1 秒拾取延迟 + 图标加大到 0.45
- 打击感:攻击命中音(block.hit)+ 实体受击白闪(uTint)+ 手持挥动动画
- 水下效果:相机入水蓝色叠加(渲染.md §8 水体)
- 空手显示肤色拳头(白羊毛 × 肤色 tint),持方块正常显示
- HUD 布局:血条左下、饥饿右下(经典布局)
This commit is contained in:
NianGao Dev
2026-08-16 20:59:28 +08:00
parent 5da460c9a2
commit 9e2f5a6186
16 changed files with 223 additions and 36 deletions

View File

@@ -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 passUI与输入.md §23D 之后、状态隔离)
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,

View File

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

View File

@@ -79,17 +79,38 @@ func (c *Chunk) FillAll(s block.State) {
}
}
// SkyLight 读取天空光4-bit015)。
func (c *Chunk) SkyLight(x, y, z int) uint8 { return nibble(c.sky, Index(x, y, z)) }
// SkyLight 读取天空光4-bit015;越界返回 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-bit015
func (c *Chunk) BlockLight(x, y, z int) uint8 { return nibble(c.blockLt, Index(x, y, z)) }
// BlockLight 读取方块光4-bit015;越界返回 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 {

View File

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

View File

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

View File

@@ -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 → 合并

View File

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

View File

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

View File

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

View File

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

View File

@@ -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不透明与 cutoutalpha test不排序
gl.Uniform1f(cutLoc, 0)

View File

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

View File

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

View File

@@ -40,6 +40,9 @@ type HUD struct {
// 小地图左上角UI还原.md §3.5
minimapTex uint32
// 水下渲染(渲染.md §8 水体效果:蓝色叠加)
underwater bool
// 逻辑分辨率与缩放UI还原.md §3.1427×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×15UI还原.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 {

View File

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

View File

@@ -83,3 +83,4 @@ spawn → tick激活区块内→ damage → death → 掉落物 → despaw
| 日期 | 变更 | 作者 |
| --- | --- | --- |
| (初始建立) | 初版生命周期、AI 状态机与限频寻路、自然生成、掉落物、玩家状态、踩坑清单 | — |
| (稳定性) | 卸载区块实体冻结WorldView.ChunkLoaded实体所在区块卸载后跳过 tick——否则失去碰撞支撑无限下坠到负坐标光查询越界崩溃chunk 光查询同步加越界防御);掉落物生成后 1 秒拾取延迟(掉落可见);实体受击白闪 HitFlash打击反馈 | — |