From a89d087c55cee76afa22303c7f503b555a595d3b Mon Sep 17 00:00:00 2001 From: NianGao Dev Date: Sat, 15 Aug 2026 22:32:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(world):=20=E6=96=B9=E5=9D=97=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8=E3=80=81=E5=9D=90=E6=A0=87=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E3=80=81=E5=8C=BA=E5=9D=97=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E3=80=81=E7=A1=AE=E5=AE=9A=E6=80=A7=E4=B8=96=E7=95=8C=E7=94=9F?= =?UTF-8?q?=E6=88=90=EF=BC=88=E5=90=AB=E6=B5=8B=E8=AF=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 + go.sum | 2 + internal/block/registry.go | 142 +++++++++++++++ internal/block/registry_test.go | 73 ++++++++ internal/chunk/chunk.go | 116 +++++++++++++ internal/chunk/chunk_test.go | 56 ++++++ internal/coord/coord.go | 25 +++ internal/coord/coord_test.go | 36 ++++ internal/worldgen/gen.go | 297 ++++++++++++++++++++++++++++++++ internal/worldgen/gen_test.go | 103 +++++++++++ 10 files changed, 852 insertions(+) create mode 100644 internal/block/registry.go create mode 100644 internal/block/registry_test.go create mode 100644 internal/chunk/chunk.go create mode 100644 internal/chunk/chunk_test.go create mode 100644 internal/coord/coord.go create mode 100644 internal/coord/coord_test.go create mode 100644 internal/worldgen/gen.go create mode 100644 internal/worldgen/gen_test.go diff --git a/go.mod b/go.mod index 56300897..85f7544a 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module mc go 1.26.4 require gopkg.in/yaml.v3 v3.0.1 + +require github.com/ojrac/opensimplex-go v1.0.2 // indirect diff --git a/go.sum b/go.sum index a62c313c..35545055 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/ojrac/opensimplex-go v1.0.2 h1:l4vs0D+JCakcu5OV0kJ99oEaWJfggSc9jiLpxaWvSzs= +github.com/ojrac/opensimplex-go v1.0.2/go.mod h1:NwbXFFbXcdGgIFdiA7/REME+7n/lOf1TuEbLiZYOWnM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/block/registry.go b/internal/block/registry.go new file mode 100644 index 00000000..60a5dc84 --- /dev/null +++ b/internal/block/registry.go @@ -0,0 +1,142 @@ +// Package block 方块注册表:加载 blocks.json,提供方块定义与状态查询(设计.md §6.1)。 +// +// 设计要点: +// - 运行时状态 = 数字 ID(uint16)+ meta(uint8),打包为 State(uint32); +// - ID 一旦发布不可变(ID 稳定性铁律,热更新.md §4.4); +// - isSolid / isOpaque / light 等判定为「单一来源」,光照、物理、渲染共用。 +package block + +import ( + "encoding/json" + "fmt" + "os" +) + +// State 运行时方块状态:ID(低 16 位)+ meta(高 8 位)。 +type State uint32 + +// Air 空气(ID 0)。 +const Air State = 0 + +// NewState 打包状态。 +func NewState(id uint16, meta uint8) State { return State(uint32(id) | uint32(meta)<<16) } + +// ID 返回方块 ID。 +func (s State) ID() uint16 { return uint16(s & 0xFFFF) } + +// Meta 返回附加数据(朝向/含水/阶段等)。 +func (s State) Meta() uint8 { return uint8(s >> 16) } + +// Def 方块定义(blocks.json 条目,设计.md §2.3)。 +type Def struct { + ID uint16 `json:"id"` + Name string `json:"-"` + Hardness float64 `json:"hardness"` + Resistance float64 `json:"resistance"` + Light uint8 `json:"light"` + Transparent bool `json:"transparent"` + Solid bool `json:"solid"` + Cutout bool `json:"cutout"` + Translucent bool `json:"translucent"` + Fluid bool `json:"fluid"` + RequiredTier int `json:"required_tier"` + Drops string `json:"drops"` + Textures map[string]string `json:"textures"` +} + +// Registry 方块注册表。 +type Registry struct { + byID []Def + byName map[string]uint16 +} + +// Load 解析 blocks.json 构建注册表(按 id 索引;ID 0 保留给空气,其余不要求连续但必须有唯一)。 +func Load(path string) (*Registry, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("block.Load %s: %w", path, err) + } + var doc struct { + Blocks map[string]Def `json:"blocks"` + } + if err := json.Unmarshal(b, &doc); err != nil { + return nil, fmt.Errorf("block.Load %s: %w", path, err) + } + r := &Registry{byName: make(map[string]uint16, len(doc.Blocks))} + maxID := uint16(0) + for name, d := range doc.Blocks { + if d.ID == 0 { + return nil, fmt.Errorf("block.Load: 方块 %q 使用保留 ID 0(空气)", name) + } + if d.ID > maxID { + maxID = d.ID + } + if _, dup := r.byName[name]; dup { + return nil, fmt.Errorf("block.Load: 方块重名 %q", name) + } + r.byName[name] = d.ID + } + // 按 ID 建立索引(下标 0 = 空气零值) + r.byID = make([]Def, int(maxID)+1) + for name, d := range doc.Blocks { + d.Name = name + if r.byID[d.ID].ID != 0 { + return nil, fmt.Errorf("block.Load: ID %d 被重复使用", d.ID) + } + r.byID[d.ID] = d + } + return r, nil +} + +// Get 按 ID 取定义;越界返回零值(视为空气)。 +func (r *Registry) Get(id uint16) Def { + if int(id) < len(r.byID) { + return r.byID[id] + } + return Def{} +} + +// ID 按名称取 ID。 +func (r *Registry) ID(name string) (uint16, bool) { + id, ok := r.byName[name] + return id, ok +} + +// Name 按 ID 取名称。 +func (r *Registry) Name(id uint16) string { return r.Get(id).Name } + +// IsSolid 实体可站立/碰撞判定(场景/物理与碰撞.md §2)。 +func (r *Registry) IsSolid(s State) bool { return s != Air && r.Get(s.ID()).Solid } + +// IsOpaque 完全遮光判定(场景/光照.md §7 分类表)。 +func (r *Registry) IsOpaque(s State) bool { + if s == Air { + return false + } + d := r.Get(s.ID()) + return !d.Transparent +} + +// Light 光源亮度(0–15,场景/光照.md §4)。 +func (r *Registry) Light(s State) uint8 { return r.Get(s.ID()).Light } + +// IsCutout alpha test 类型(树叶/玻璃,渲染.md §6)。 +func (r *Registry) IsCutout(s State) bool { return r.Get(s.ID()).Cutout } + +// IsTranslucent 半透明类型(水/彩色玻璃,需要排序 pass)。 +func (r *Registry) IsTranslucent(s State) bool { return r.Get(s.ID()).Translucent } + +// IsFluid 液体判定(水/岩浆)。 +func (r *Registry) IsFluid(s State) bool { return r.Get(s.ID()).Fluid } + +// Texture 取方块某个面的纹理键(如 "up"/"side"/"all")。 +func (r *Registry) Texture(s State, face string) string { + d := r.Get(s.ID()) + if t, ok := d.Textures[face]; ok { + return t + } + if t, ok := d.Textures["all"]; ok { + return t + } + return "block/" + d.Name +} diff --git a/internal/block/registry_test.go b/internal/block/registry_test.go new file mode 100644 index 00000000..806a0ff8 --- /dev/null +++ b/internal/block/registry_test.go @@ -0,0 +1,73 @@ +package block + +import ( + "path/filepath" + "testing" +) + +// blocksPath 指向仓库 assets/config/blocks.json(测试运行目录为 internal/block)。 +var blocksPath = filepath.Join("..", "..", "assets", "config", "blocks.json") + +// TestLoad 验证注册表加载与 ID 解析。 +func TestLoad(t *testing.T) { + r, err := Load(blocksPath) + if err != nil { + t.Fatalf("加载注册表失败: %v", err) + } + id, ok := r.ID("stone") + if !ok || id != 1 { + t.Fatalf("stone ID 期望 1,实际 %d (ok=%v)", id, ok) + } + if r.Name(1) != "stone" { + t.Fatalf("ID 1 名称期望 stone,实际 %q", r.Name(1)) + } +} + +// TestStates 验证状态打包/解包与判定表(单一来源)。 +func TestStates(t *testing.T) { + r, err := Load(blocksPath) + if err != nil { + t.Fatalf("加载注册表失败: %v", err) + } + torch, _ := r.ID("torch") + water, _ := r.ID("water") + stone, _ := r.ID("stone") + + s := NewState(torch, 3) + if s.ID() != torch || s.Meta() != 3 { + t.Fatalf("状态打包/解包异常: %+v", s) + } + if r.Light(s) != 14 { + t.Fatalf("火把亮度期望 14,实际 %d", r.Light(s)) + } + if r.IsOpaque(s) { + t.Fatal("火把不应完全遮光") + } + if !r.IsOpaque(NewState(stone, 0)) { + t.Fatal("石头应完全遮光") + } + if r.IsSolid(NewState(water, 0)) { + t.Fatal("水不应为实体碰撞方块") + } + if !r.IsFluid(NewState(water, 0)) { + t.Fatal("水应为液体") + } + if Air.ID() != 0 { + t.Fatalf("空气 ID 期望 0") + } +} + +// TestTexture 验证纹理键解析(all 面回退)。 +func TestTexture(t *testing.T) { + r, err := Load(blocksPath) + if err != nil { + t.Fatalf("加载注册表失败: %v", err) + } + grass, _ := r.ID("grass_block") + if tex := r.Texture(NewState(grass, 0), "up"); tex != "block/grass_block_top" { + t.Fatalf("草方块顶面纹理期望 block/grass_block_top,实际 %q", tex) + } + if tex := r.Texture(NewState(grass, 0), "down"); tex != "block/dirt" { + t.Fatalf("草方块底面纹理期望 block/dirt,实际 %q", tex) + } +} diff --git a/internal/chunk/chunk.go b/internal/chunk/chunk.go new file mode 100644 index 00000000..b1ce79af --- /dev/null +++ b/internal/chunk/chunk.go @@ -0,0 +1,116 @@ +// Package chunk 区块数据结构:一维数组 + 每区块独立锁(场景/区块管理.md §2)。 +// +// 布局:16×16×256 一维 []block.State(索引 (y*16+z)*16+x), +// 天空光/方块光各 4-bit 打包存储(每字节 2 个方块)。 +package chunk + +import ( + "sync" + "sync/atomic" + + "mc/internal/block" +) + +// 区块尺寸常量(设计.md §4.1)。 +const ( + SizeX = 16 // 水平 X 大小 + SizeZ = 16 // 水平 Z 大小 + Height = 256 // 垂直高度 + SectionY = Height / 16 // section 数量(16×16×16) +) + +// Chunk 一个区块。锁粒度:每 Chunk 一个 RWMutex(禁止全局锁,性能与内存.md §3)。 +type Chunk struct { + CX, CZ int32 + + mu sync.RWMutex + blocks []block.State // 方块状态(一维数组,cache 友好) + sky []uint8 // 天空光 4-bit 打包 + blockLt []uint8 // 方块光 4-bit 打包 + + Dirty atomic.Bool // mesh 需重建(渲染.md §3.3) + LightDirty atomic.Bool // 光照需重算(光照.md) + SaveDirty atomic.Bool // 需写盘(存档与持久化.md §2) +} + +// New 创建全空气区块。 +func New(cx, cz int32) *Chunk { + return &Chunk{ + CX: cx, + CZ: cz, + blocks: make([]block.State, SizeX*SizeZ*Height), + sky: make([]uint8, SizeX*SizeZ*Height/2), + blockLt: make([]uint8, SizeX*SizeZ*Height/2), + } +} + +// Index 计算方块数组索引:(y*16+z)*16+x。 +func Index(x, y, z int) int { return (y*SizeZ+z)*SizeX + x } + +// Block 读取方块(读锁)。 +func (c *Chunk) Block(x, y, z int) block.State { + c.mu.RLock() + defer c.mu.RUnlock() + return c.blocks[Index(x, y, z)] +} + +// SetBlock 写入方块并标脏(mesh + 光照 + 存档)。 +func (c *Chunk) SetBlock(x, y, z int, s block.State) { + c.mu.Lock() + c.blocks[Index(x, y, z)] = s + c.mu.Unlock() + c.Dirty.Store(true) + c.LightDirty.Store(true) + c.SaveDirty.Store(true) +} + +// Fill 初始化填充(生成器专用,不标存档脏:生成结果属纯函数可重算)。 +func (c *Chunk) Fill(x, y, z int, s block.State) { + c.blocks[Index(x, y, z)] = s +} + +// FillAll 整块填充(生成器初始化)。 +func (c *Chunk) FillAll(s block.State) { + for i := range c.blocks { + c.blocks[i] = s + } +} + +// SkyLight 读取天空光(4-bit,0–15)。 +func (c *Chunk) SkyLight(x, y, z int) uint8 { 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) } + +// BlockLight 读取方块光(4-bit,0–15)。 +func (c *Chunk) BlockLight(x, y, z int) uint8 { 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) } + +// nibble 读取半字节(偶数索引 → 低 4 位,奇数索引 → 高 4 位)。 +func nibble(arr []uint8, idx int) uint8 { + if idx&1 == 0 { + return arr[idx/2] & 0xF + } + return arr[idx/2] >> 4 +} + +// setNibble 写入半字节。 +func setNibble(arr []uint8, idx int, v uint8) { + v &= 0xF + if idx&1 == 0 { + arr[idx/2] = (arr[idx/2] & 0xF0) | v + } else { + arr[idx/2] = (arr[idx/2] & 0x0F) | (v << 4) + } +} + +// Snapshot 无锁快照(mesh 构建 goroutine 读取用;调用方需保证期间无写入)。 +func (c *Chunk) Snapshot() []block.State { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]block.State, len(c.blocks)) + copy(out, c.blocks) + return out +} diff --git a/internal/chunk/chunk_test.go b/internal/chunk/chunk_test.go new file mode 100644 index 00000000..08638947 --- /dev/null +++ b/internal/chunk/chunk_test.go @@ -0,0 +1,56 @@ +package chunk + +import ( + "testing" + + "mc/internal/block" +) + +// TestIndex 验证一维索引换算。 +func TestIndex(t *testing.T) { + c := New(0, 0) + if got := Index(0, 0, 0); got != 0 { + t.Fatalf("Index(0,0,0) = %d", got) + } + if got := Index(1, 0, 0); got != 1 { + t.Fatalf("Index(1,0,0) = %d", got) + } + if got := Index(0, 0, 1); got != 16 { + t.Fatalf("Index(0,0,1) = %d", got) + } + if got := Index(0, 1, 0); got != 256 { + t.Fatalf("Index(0,1,0) = %d", got) + } + _ = c +} + +// TestSetGet 验证读写与脏标记。 +func TestSetGet(t *testing.T) { + c := New(3, -7) + s := block.NewState(5, 0) + c.SetBlock(2, 64, 3, s) + if got := c.Block(2, 64, 3); got != s { + t.Fatalf("读回 %v,期望 %v", got, s) + } + if !c.Dirty.Load() || !c.SaveDirty.Load() { + t.Fatal("SetBlock 应标记 dirty/saveDirty") + } +} + +// TestNibble 验证 4-bit 光照打包(每字节 2 方块)。 +func TestNibble(t *testing.T) { + c := New(0, 0) + // 相邻两个方块:一个偶数索引一个奇数索引,共用一个字节 + c.SetSkyLight(0, 10, 0, 15) + c.SetSkyLight(1, 10, 0, 7) + if got := c.SkyLight(0, 10, 0); got != 15 { + t.Fatalf("sky(0) = %d,期望 15", got) + } + if got := c.SkyLight(1, 10, 0); got != 7 { + t.Fatalf("sky(1) = %d,期望 7", got) + } + // 未设置的保持 0 + if got := c.BlockLight(0, 10, 0); got != 0 { + t.Fatalf("blockLight(0) = %d,期望 0", got) + } +} diff --git a/internal/coord/coord.go b/internal/coord/coord.go new file mode 100644 index 00000000..1ba72ddf --- /dev/null +++ b/internal/coord/coord.go @@ -0,0 +1,25 @@ +// Package coord 坐标工具:floorDiv 与区块坐标换算(设计.md §4.1)。 +// 关键:世界坐标转区块坐标必须使用 floorDiv,正确处理负数。 +package coord + +// FloorDiv 向下取整除法(负安全)。 +func FloorDiv(a, b int32) int32 { + q := a / b + r := a % b + if r < 0 { + q-- + } + return q +} + +// ChunkCoord 世界坐标 → 区块坐标(区块大小 16)。 +func ChunkCoord(worldX int32) int32 { return FloorDiv(worldX, 16) } + +// LocalCoord 世界坐标 → 区块内局部坐标(0..15)。 +func LocalCoord(worldX int32) int32 { + l := worldX - ChunkCoord(worldX)*16 + if l < 0 { + l += 16 + } + return l +} diff --git a/internal/coord/coord_test.go b/internal/coord/coord_test.go new file mode 100644 index 00000000..629f4985 --- /dev/null +++ b/internal/coord/coord_test.go @@ -0,0 +1,36 @@ +package coord + +import "testing" + +// TestFloorDiv 验证负坐标安全的向下取整除法(设计.md §4.1 踩坑)。 +func TestFloorDiv(t *testing.T) { + cases := []struct { + a, b, want int32 + }{ + {15, 16, 0}, + {16, 16, 1}, + {-1, 16, -1}, + {-16, 16, -1}, + {-17, 16, -2}, + {0, 16, 0}, + } + for _, c := range cases { + if got := FloorDiv(c.a, c.b); got != c.want { + t.Fatalf("FloorDiv(%d, %d) = %d,期望 %d", c.a, c.b, got, c.want) + } + } +} + +// TestChunkLocal 验证区块坐标与局部坐标互转(含负数)。 +func TestChunkLocal(t *testing.T) { + for _, wx := range []int32{-33, -17, -16, -1, 0, 15, 16, 31} { + cx := ChunkCoord(wx) + lx := LocalCoord(wx) + if lx < 0 || lx >= 16 { + t.Fatalf("LocalCoord(%d) = %d 越界", wx, lx) + } + if cx*16+lx != wx { + t.Fatalf("坐标还原失败: %d → cx=%d lx=%d", wx, cx, lx) + } + } +} diff --git a/internal/worldgen/gen.go b/internal/worldgen/gen.go new file mode 100644 index 00000000..77f63168 --- /dev/null +++ b/internal/worldgen/gen.go @@ -0,0 +1,297 @@ +// Package worldgen 确定性世界生成(场景/世界生成.md §3 管线)。 +// +// 管线(pass 顺序固定):高度图 → 生物群系地表 → 洞穴 → 矿物 → 树(后处理)。 +// 确定性铁律:同 seed 同参数 → 逐字节相同结果(噪声实例按 seed 派生、遍历顺序固定)。 +package worldgen + +import ( + "fmt" + + "mc/internal/block" + "mc/internal/chunk" + + "github.com/ojrac/opensimplex-go" +) + +// 生物群系(简化版:4 种,世界生态.md §2 双噪声判定)。 +type biome uint8 + +const ( + biomePlains biome = iota + biomeDesert + biomeForest + biomeSnow +) + +// 海平面与地形常量。 +const ( + seaLevel = 62 + dirtDepth = 3 // 地表下泥土厚度 +) + +// Generator 世界生成器。 +type Generator struct { + seed int64 + + height opensimplex.Noise // 高度图(2D,fBm 近似:双 octave) + temp opensimplex.Noise // 温度噪声(生物群系) + humid opensimplex.Noise // 湿度噪声(生物群系) + cave opensimplex.Noise // 洞穴 3D 密度场 + ore opensimplex.Noise // 矿物分布 3D + + biomeScale float64 + heightScale float64 + caveDensity float64 + + // 常用方块状态(启动时从注册表解析一次) + stone, grass, dirt, bedrock, sand, water, snowB, log, leaves block.State + coal, iron, gold, diamond, redstone block.State +} + +// New 创建生成器;必须的方块缺注册时报错。 +func New(reg *block.Registry, seed int64, biomeScale, heightScale, caveDensity float64) (*Generator, error) { + g := &Generator{ + seed: seed, + height: opensimplex.New(seed), + temp: opensimplex.New(seed + 101), + humid: opensimplex.New(seed + 202), + cave: opensimplex.New(seed + 303), + ore: opensimplex.New(seed + 404), + biomeScale: biomeScale, + heightScale: heightScale, + caveDensity: caveDensity, + } + // 解析必须方块 ID(缺失直接报错,避免生成到一半失败) + var err error + if g.stone, err = must(reg, "stone"); err != nil { + return nil, err + } + if g.grass, err = must(reg, "grass_block"); err != nil { + return nil, err + } + if g.dirt, err = must(reg, "dirt"); err != nil { + return nil, err + } + if g.bedrock, err = must(reg, "bedrock"); err != nil { + return nil, err + } + if g.sand, err = must(reg, "sand"); err != nil { + return nil, err + } + if g.water, err = must(reg, "water"); err != nil { + return nil, err + } + if g.snowB, err = must(reg, "snow_block"); err != nil { + return nil, err + } + if g.log, err = must(reg, "oak_log"); err != nil { + return nil, err + } + if g.leaves, err = must(reg, "oak_leaves"); err != nil { + return nil, err + } + if g.coal, err = must(reg, "coal_ore"); err != nil { + return nil, err + } + if g.iron, err = must(reg, "iron_ore"); err != nil { + return nil, err + } + if g.gold, err = must(reg, "gold_ore"); err != nil { + return nil, err + } + if g.diamond, err = must(reg, "diamond_ore"); err != nil { + return nil, err + } + if g.redstone, err = must(reg, "redstone_ore"); err != nil { + return nil, err + } + return g, nil +} + +// must 从注册表取方块状态,缺注册返回错误。 +func must(reg *block.Registry, name string) (block.State, error) { + id, ok := reg.ID(name) + if !ok { + return block.Air, fmt.Errorf("worldgen: 方块 %q 未注册", name) + } + return block.NewState(id, 0), nil +} + +// heightAt 返回世界列 (wx, wz) 的地形高度(y)。 +func (g *Generator) heightAt(wx, wz int32) int { + h := g.height.Eval2(float64(wx)*g.heightScale, float64(wz)*g.heightScale) + h += 0.35 * g.height.Eval2(float64(wx)*g.heightScale*2.5+100, float64(wz)*g.heightScale*2.5+100) + y := 64 + int(h*24) + if y < 8 { + y = 8 + } + if y > chunk.Height-16 { + y = chunk.Height - 16 + } + return y +} + +// biomeAt 温度 + 湿度双噪声 → 生物群系(世界生态.md §2.1)。 +func (g *Generator) biomeAt(wx, wz int32) biome { + t := g.temp.Eval2(float64(wx)*g.biomeScale, float64(wz)*g.biomeScale) + h := g.humid.Eval2(float64(wx)*g.biomeScale*0.9+50, float64(wz)*g.biomeScale*0.9+50) + switch { + case t > 0.35 && h < -0.1: + return biomeDesert + case t < -0.35: + return biomeSnow + case h > 0.3: + return biomeForest + default: + return biomePlains + } +} + +// surfaceID 群系地表方块。 +func (g *Generator) surfaceID(b biome) block.State { + switch b { + case biomeDesert: + return g.sand + case biomeSnow: + return g.snowB + default: + return g.grass + } +} + +// isCave 洞穴判定(3D 密度场阈值;基岩层与高空不挖,世界生成.md §6 踩坑)。 +func (g *Generator) isCave(wx, wy, wz int32) bool { + if wy > 56 || wy < 6 { + return false + } + n := g.cave.Eval3(float64(wx)*0.08, float64(wy)*0.12, float64(wz)*0.08) + n += 0.5 * g.cave.Eval3(float64(wx)*0.16+31, float64(wy)*0.24+17, float64(wz)*0.16+71) + return n > g.caveDensity +} + +// oreAt 矿物分布(按深度区间,挖矿与矿物.md §2)。 +func (g *Generator) oreAt(wx, wy, wz int32) block.State { + if wy > 128 { + return g.stone + } + n := g.ore.Eval3(float64(wx)*0.12, float64(wy)*0.12, float64(wz)*0.12) + switch { + case wy <= 16 && n > 0.86: + return g.diamond + case wy <= 16 && n > 0.78: + return g.redstone + case wy <= 32 && n > 0.74: + return g.gold + case wy <= 64 && n > 0.70: + return g.iron + case n > 0.64: + return g.coal + } + return g.stone +} + +// Generate 生成整个区块(确定性;结果不标存档脏——生成属纯函数可重算)。 +func (g *Generator) Generate(cx, cz int32) *chunk.Chunk { + c := chunk.New(cx, cz) + for lz := 0; lz < 16; lz++ { + for lx := 0; lx < 16; lx++ { + wx := int32(lx) + cx*16 + wz := int32(lz) + cz*16 + h := g.heightAt(wx, wz) + b := g.biomeAt(wx, wz) + surface := g.surfaceID(b) + + // 海平面以下但高于地形:灌水 + for y := h + 1; y < seaLevel; y++ { + c.Fill(lx, y, lz, g.water) + } + // 地形柱:基岩 → 石头/矿物 → 泥土 → 地表 + for y := 0; y <= h; y++ { + wy := int32(y) + var s block.State + switch { + case y == 0: + s = g.bedrock + case y <= 4: + s = g.stone + case y == h: + s = surface + case y >= h-dirtDepth: + s = g.dirt + default: + s = g.stone + } + // 洞穴掏空(海平面以下洞内灌水) + if y > 4 && g.isCave(wx, wy, wz) { + if y <= seaLevel { + s = g.water + } else { + s = block.Air + } + } else if s == g.stone { + s = g.oreAt(wx, wy, wz) + } + c.Fill(lx, y, lz, s) + } + } + } + // 树后处理 pass(世界生态.md §3.1:边缘留白避免跨区块,树冠不出界) + g.placeTrees(c) + return c +} + +// placeTrees 在森林/平原撒树;方块边缘 3 格内不撒(跨区块安全的最小方案)。 +func (g *Generator) placeTrees(c *chunk.Chunk) { + for lz := 3; lz <= 12; lz++ { + for lx := 3; lx <= 12; lx++ { + wx := int32(lx) + c.CX*16 + wz := int32(lz) + c.CZ*16 + h := g.heightAt(wx, wz) + if h+7 >= chunk.Height { + continue + } + b := g.biomeAt(wx, wz) + prob := 0.80 + if b == biomePlains { + prob = 0.95 + } + if b != biomeForest && b != biomePlains { + continue + } + n := g.ore.Eval2(float64(wx)*0.2+777, float64(wz)*0.2+777) + if n < prob { + continue + } + // 只长在草上 + if c.Block(lx, h, lz) != g.grass { + continue + } + g.growTree(c, lx, h, lz) + } + } +} + +// growTree 生成一棵橡树:树干 5 格 + 树冠 5×5×3。 +func (g *Generator) growTree(c *chunk.Chunk, lx, h, lz int) { + for y := h + 1; y <= h+5; y++ { + c.Fill(lx, y, lz, g.log) + } + for y := h + 3; y <= h+5; y++ { + for dx := -2; dx <= 2; dx++ { + for dz := -2; dz <= 2; dz++ { + x, z := lx+dx, lz+dz + if x < 0 || x >= 16 || z < 0 || z >= 16 { + continue // 树冠不出区块(边缘留白已保证不越界) + } + if dx == 0 && dz == 0 && y <= h+5 { + continue // 树干位置 + } + // 角部修剪,更接近原版树冠形状 + if y == h+5 && (dx*dx+dz*dz >= 5) { + continue + } + c.Fill(x, y, z, g.leaves) + } + } + } +} diff --git a/internal/worldgen/gen_test.go b/internal/worldgen/gen_test.go new file mode 100644 index 00000000..89bb77a9 --- /dev/null +++ b/internal/worldgen/gen_test.go @@ -0,0 +1,103 @@ +package worldgen + +import ( + "bytes" + "path/filepath" + "testing" + + "mc/internal/block" +) + +// loadReg 加载共享注册表(blocks.json)。 +func loadReg(t *testing.T) *block.Registry { + t.Helper() + r, err := block.Load(filepath.Join("..", "..", "assets", "config", "blocks.json")) + if err != nil { + t.Fatalf("加载方块注册表失败: %v", err) + } + return r +} + +// TestDeterminism 验证生成确定性:同 seed 两次生成逐字节一致(世界生成.md §2.2)。 +func TestDeterminism(t *testing.T) { + g, err := New(loadReg(t), 12345, 0.005, 0.01, 0.1) + if err != nil { + t.Fatalf("创建生成器失败: %v", err) + } + a := g.Generate(-1, 3) + b := g.Generate(-1, 3) + sa, sb := a.Snapshot(), b.Snapshot() + if !bytes.Equal(stateBytes(sa), stateBytes(sb)) { + t.Fatal("同 seed 生成结果不一致") + } +} + +// stateBytes 把方块状态切片序列化为字节(比较用)。 +func stateBytes(s []block.State) []byte { + out := make([]byte, len(s)*4) + for i, v := range s { + out[i*4] = byte(v >> 24) + out[i*4+1] = byte(v >> 16) + out[i*4+2] = byte(v >> 8) + out[i*4+3] = byte(v) + } + return out +} + +// TestTerrainStructure 验证地形结构:基岩层完整、地表为群系方块、海平面下灌水。 +func TestTerrainStructure(t *testing.T) { + reg := loadReg(t) + bedrock, _ := reg.ID("bedrock") + water, _ := reg.ID("water") + air := uint16(0) + + // caveDensity=1.0 关闭洞穴,便于验证灌水逻辑 + g, err := New(reg, 777, 0.005, 0.01, 1.0) + if err != nil { + t.Fatalf("创建生成器失败: %v", err) + } + + ch := g.Generate(0, 0) + // 基岩层:y=0 全部为基岩(世界生成.md §6:基岩层保留) + for lx := 0; lx < 16; lx++ { + for lz := 0; lz < 16; lz++ { + if id := ch.Block(lx, 0, lz).ID(); id != bedrock { + t.Fatalf("(%d,0,%d) 非基岩: ID=%d", lx, lz, id) + } + } + } + // 每个地形柱:海平面以下要么是水要么是地形方块,不允许空气 + for lx := 0; lx < 16; lx++ { + for lz := 0; lz < 16; lz++ { + h := g.heightAt(int32(lx), int32(lz)) + for y := 1; y < seaLevel; y++ { + id := ch.Block(lx, y, lz).ID() + if y > h { + if id != water { + t.Fatalf("(%d,%d,%d) 海平面下应为水,实际 ID=%d", lx, y, lz, id) + } + } else if id == air { + t.Fatalf("(%d,%d,%d) 地形柱内出现空气", lx, y, lz) + } + } + } + } +} + +// TestOres 验证矿物只出现在规定深度区间(挖矿与矿物.md §2:y=100 只允许石头/煤矿)。 +func TestOres(t *testing.T) { + reg := loadReg(t) + g, err := New(reg, 4242, 0.005, 0.01, 0.1) + if err != nil { + t.Fatalf("创建生成器失败: %v", err) + } + stone, _ := reg.ID("stone") + coal, _ := reg.ID("coal_ore") + for i := 0; i < 2000; i++ { + s := g.oreAt(int32(i), 100, int32(i)) // y=100:只允许石头或煤矿 + id := s.ID() + if id != stone && id != coal { + t.Fatalf("y=100 出现异常方块: ID=%d", id) + } + } +}