diff --git a/internal/light/light.go b/internal/light/light.go new file mode 100644 index 00000000..08d11266 --- /dev/null +++ b/internal/light/light.go @@ -0,0 +1,118 @@ +// Package light 光照系统:天空光按列灌入 + 方块光 BFS + 光源移除重算(场景/光照.md)。 +// +// 双通道:sky(日光)与 block(火把等)各自 4-bit(0–15),存于区块(chunk 包)。 +// 传播规则:6 邻格 BFS,每格衰减 1,被不透明方块阻隔;光传播上限 15 格。 +package light + +// Pos 方块整数坐标。 +type Pos struct{ X, Y, Z int32 } + +// World 光照传播所需的最小世界访问接口(支持跨区块,光照.md §6)。 +type World interface { + // Opaque 完全遮光判定(与渲染共用单一来源:block.Registry.IsOpaque)。 + Opaque(x, y, z int32) bool + // BlockLight 读取方块光(4-bit,0–15)。 + BlockLight(x, y, z int32) uint8 + // SetBlockLight 写入方块光。 + SetBlockLight(x, y, z int32, v uint8) + // SourceLight 返回方块自身发光等级(火把 14、岩浆 15,光照.md §4)。 + SourceLight(x, y, z int32) uint8 + // SkyLight / SetSkyLight 天空光读写。 + SkyLight(x, y, z int32) uint8 + SetSkyLight(x, y, z int32, v uint8) +} + +// dirs 6 个相邻方向。 +var dirs = [6][3]int32{ + {1, 0, 0}, {-1, 0, 0}, + {0, 1, 0}, {0, -1, 0}, + {0, 0, 1}, {0, 0, -1}, +} + +// MaxLevel 光传播等级上限(0–15)。 +const MaxLevel = 15 + +// SkyFill 天空光按列初始化(光照.md §3):列顶注入 15,每下降 1 格减 1,穿过不透明方块后归零。 +func SkyFill(w World, minX, maxX, minZ, maxZ int32) { + for x := minX; x <= maxX; x++ { + for z := minZ; z <= maxZ; z++ { + lvl := uint8(MaxLevel) + for y := int32(255); y >= 0; y-- { + w.SetSkyLight(x, y, z, lvl) + if w.Opaque(x, y, z) { + // 光线不能穿过不透明方块:其下每格递减,直至 0 + if lvl > 0 { + lvl-- + } + } + } + } + } +} + +// Propagate 方块光 BFS 传播(光照.md §4):光沿 6 邻格扩散,每格衰减 1,不透明阻隔。 +// seeds 为初始光源位置(如放置的火把,light=14)。 +func Propagate(w World, seeds []Pos) { + q := make([]Pos, 0, 128) + q = append(q, seeds...) + for len(q) > 0 { + p := q[0] + q = q[1:] + lv := w.BlockLight(p.X, p.Y, p.Z) + if lv <= 1 { + continue // 亮度不足继续传播 + } + for _, d := range dirs { + n := Pos{p.X + d[0], p.Y + d[1], p.Z + d[2]} + if w.Opaque(n.X, n.Y, n.Z) { + continue + } + if w.BlockLight(n.X, n.Y, n.Z) < lv-1 { + w.SetBlockLight(n.X, n.Y, n.Z, lv-1) + q = append(q, n) + } + } + } +} + +// RemoveLight 光源移除后重算(光照.md §5 语义的简化等价实现): +// 以移除点为中心半径 MaxLevel 区域内方块光清零,再重灌区域内全部现存光源。 +// 依据:光传播上限 15 格,区域外不受影响,不会产生黑斑/亮斑。 +func RemoveLight(w World, removed []Pos, sources func(minX, minY, minZ, maxX, maxY, maxZ int32) []Pos) { + // 计算受影响包围盒(含 15 格传播半径) + var minX, minY, minZ = int32(1 << 30), int32(1 << 30), int32(1 << 30) + var maxX, maxY, maxZ int32 = -1 << 30, -1 << 30, -1 << 30 + for _, p := range removed { + minX, maxX = min32(minX, p.X-MaxLevel), max32(maxX, p.X+MaxLevel) + minY, maxY = min32(minY, p.Y-MaxLevel), max32(maxY, p.Y+MaxLevel) + minZ, maxZ = min32(minZ, p.Z-MaxLevel), max32(maxZ, p.Z+MaxLevel) + } + // 阶段 1:反向清除(区域内方块光清零) + for x := minX; x <= maxX; x++ { + for y := minY; y <= maxY; y++ { + for z := minZ; z <= maxZ; z++ { + w.SetBlockLight(x, y, z, 0) + } + } + } + // 阶段 2:重新灌入(区域内现存全部光源:先复位光源自身亮度,再 BFS) + seeds := sources(minX, minY, minZ, maxX, maxY, maxZ) + for _, s := range seeds { + w.SetBlockLight(s.X, s.Y, s.Z, w.SourceLight(s.X, s.Y, s.Z)) + } + Propagate(w, seeds) +} + +func min32(a, b int32) int32 { + if a < b { + return a + } + return b +} + +func max32(a, b int32) int32 { + if a > b { + return a + } + return b +} diff --git a/internal/light/light_test.go b/internal/light/light_test.go new file mode 100644 index 00000000..4bdbc2e1 --- /dev/null +++ b/internal/light/light_test.go @@ -0,0 +1,207 @@ +package light + +import ( + "path/filepath" + "testing" + + "mc/internal/block" + "mc/internal/chunk" + "mc/internal/coord" +) + +// chunkWorld 测试用 World 实现:多区块映射(验证跨区块光照溢出,光照.md §6)。 +type chunkWorld struct { + reg *block.Registry + chunks map[[2]int32]*chunk.Chunk +} + +// newChunkWorld 构造含注册表的多区块世界。 +func newChunkWorld(t *testing.T) *chunkWorld { + t.Helper() + reg, err := block.Load(filepath.Join("..", "..", "assets", "config", "blocks.json")) + if err != nil { + t.Fatalf("加载注册表失败: %v", err) + } + return &chunkWorld{reg: reg, chunks: map[[2]int32]*chunk.Chunk{}} +} + +// addChunk 添加一个区块(初始全空气)。 +func (w *chunkWorld) addChunk(cx, cz int32) *chunk.Chunk { + c := chunk.New(cx, cz) + w.chunks[[2]int32{cx, cz}] = c + return c +} + +// chunkAt 按世界坐标定位区块;未加载返回 nil。 +func (w *chunkWorld) chunkAt(x, z int32) *chunk.Chunk { + return w.chunks[[2]int32{coord.ChunkCoord(x), coord.ChunkCoord(z)}] +} + +// Opaque 遮光判定:未加载区块与世界边界视为阻隔(光照.md §6 延迟更新语义)。 +func (w *chunkWorld) Opaque(x, y, z int32) bool { + if y < 0 || y >= chunk.Height { + return true + } + c := w.chunkAt(x, z) + if c == nil { + return true + } + return w.reg.IsOpaque(c.Block(int(coord.LocalCoord(x)), int(y), int(coord.LocalCoord(z)))) +} + +// BlockLight 读方块光。 +func (w *chunkWorld) BlockLight(x, y, z int32) uint8 { + c := w.chunkAt(x, z) + if c == nil { + return 0 + } + return c.BlockLight(int(coord.LocalCoord(x)), int(y), int(coord.LocalCoord(z))) +} + +// SetBlockLight 写方块光。 +func (w *chunkWorld) SetBlockLight(x, y, z int32, v uint8) { + if c := w.chunkAt(x, z); c != nil { + c.SetBlockLight(int(coord.LocalCoord(x)), int(y), int(coord.LocalCoord(z)), v) + } +} + +// SourceLight 方块自身发光等级。 +func (w *chunkWorld) SourceLight(x, y, z int32) uint8 { + c := w.chunkAt(x, z) + if c == nil { + return 0 + } + return w.reg.Light(c.Block(int(coord.LocalCoord(x)), int(y), int(coord.LocalCoord(z)))) +} + +// SkyLight 读天空光。 +func (w *chunkWorld) SkyLight(x, y, z int32) uint8 { + c := w.chunkAt(x, z) + if c == nil { + return 15 + } + return c.SkyLight(int(coord.LocalCoord(x)), int(y), int(coord.LocalCoord(z))) +} + +// SetSkyLight 写天空光。 +func (w *chunkWorld) SetSkyLight(x, y, z int32, v uint8) { + if c := w.chunkAt(x, z); c != nil { + c.SetSkyLight(int(coord.LocalCoord(x)), int(y), int(coord.LocalCoord(z)), v) + } +} + +// placeTorch 放置火把并初始化其方块光(等效游戏内放火把流程)。 +func (w *chunkWorld) placeTorch(x, y, z int32) Pos { + torch, _ := w.reg.ID("torch") + c := w.chunkAt(x, z) + c.Fill(int(coord.LocalCoord(x)), int(y), int(coord.LocalCoord(z)), block.NewState(torch, 0)) + p := Pos{X: x, Y: y, Z: z} + w.SetBlockLight(x, y, z, w.SourceLight(x, y, z)) // 14 + return p +} + +// TestSkyFill 验证天空光按列灌入与衰减(光照.md §3)。 +// 语义:光从列顶 15 开始,每穿过一个方块减 1,空气不减;最低 0。 +func TestSkyFill(t *testing.T) { + w := newChunkWorld(t) + c := w.addChunk(0, 0) + stone, _ := w.reg.ID("stone") + // 造一个 y=30..70 的石柱(表面 y=70) + for y := 30; y <= 70; y++ { + c.Fill(8, y, 8, block.NewState(stone, 0)) + } + SkyFill(w, 0, 15, 0, 15) + if got := w.SkyLight(8, 80, 8); got != 15 { + t.Fatalf("表面上方天空光期望 15,实际 %d", got) + } + if got := w.SkyLight(8, 70, 8); got != 15 { + t.Fatalf("表面方块天空光期望 15,实际 %d", got) + } + if got := w.SkyLight(8, 69, 8); got != 14 { + t.Fatalf("表面下方 1 格期望 14,实际 %d", got) + } + if got := w.SkyLight(8, 60, 8); got != 5 { + t.Fatalf("表面下方 10 格期望 5,实际 %d", got) + } + if got := w.SkyLight(8, 55, 8); got != 0 { + t.Fatalf("表面下方 15 格期望 0,实际 %d", got) + } + if got := w.SkyLight(8, 40, 8); got != 0 { + t.Fatalf("更深处方块光期望 0,实际 %d", got) + } +} + +// TestPropagate 验证方块光 BFS 衰减与遮挡(光照.md §4)。 +func TestPropagate(t *testing.T) { + w := newChunkWorld(t) + c := w.addChunk(0, 0) + seed := w.placeTorch(8, 65, 8) + // 遮挡墙:火把东侧放石头 + stone, _ := w.reg.ID("stone") + c.Fill(9, 65, 8, block.NewState(stone, 0)) + + Propagate(w, []Pos{seed}) + if got := w.BlockLight(8, 64, 8); got != 13 { + t.Fatalf("火把下方期望 13,实际 %d", got) + } + if got := w.BlockLight(8, 66, 8); got != 13 { + t.Fatalf("火把上方期望 13,实际 %d", got) + } + if got := w.BlockLight(9, 65, 8); got != 0 { + t.Fatalf("石头遮挡处期望 0,实际 %d", got) + } + if got := w.BlockLight(7, 65, 8); got != 13 { + t.Fatalf("火把西侧期望 13,实际 %d", got) + } + if got := w.BlockLight(8, 51, 8); got != 0 { + t.Fatalf("14 格外期望 0,实际 %d", got) + } +} + +// TestCrossChunk 验证火把贴墙跨区块溢出(光照.md §6 踩坑)。 +func TestCrossChunk(t *testing.T) { + w := newChunkWorld(t) + w.addChunk(0, 0) + w.addChunk(1, 0) + seed := w.placeTorch(15, 65, 8) // 区块 (0,0) 最东侧 + + Propagate(w, []Pos{seed}) + // (16,65,8) 属于区块 (1,0) 局部 (0,65,8) + if got := w.BlockLight(16, 65, 8); got != 13 { + t.Fatalf("跨区块溢出期望 13,实际 %d", got) + } +} + +// TestRemoveLight 验证光源移除重算:另一光源的光仍保留(光照.md §5)。 +func TestRemoveLight(t *testing.T) { + w := newChunkWorld(t) + w.addChunk(0, 0) + w.addChunk(1, 0) + a := w.placeTorch(8, 65, 8) + b := w.placeTorch(20, 65, 8) // 第二个火把(区块 (1,0)) + Propagate(w, []Pos{a, b}) + + // 先移除火把 A 的方块(变回空气),再做 relight + w.chunkAt(8, 8).Fill(8, 65, 8, block.Air) + RemoveLight(w, []Pos{a}, func(minX, minY, minZ, maxX, maxY, maxZ int32) []Pos { + var seeds []Pos + for x := minX; x <= maxX; x++ { + for y := minY; y <= maxY; y++ { + for z := minZ; z <= maxZ; z++ { + if w.SourceLight(x, y, z) > 0 { + seeds = append(seeds, Pos{x, y, z}) + } + } + } + } + return seeds + }) + // (9,65,8) 距 B 11 格 → 光 14-11 = 3 + if got := w.BlockLight(9, 65, 8); got != 3 { + t.Fatalf("移除 A 后 (9,65,8) 期望 3(B 的光),实际 %d", got) + } + // A 原位置距 B 12 格 → 2 + if got := w.BlockLight(8, 65, 8); got != 2 { + t.Fatalf("A 原位置期望 2,实际 %d", got) + } +}