feat(world): 方块注册表、坐标工具、区块数据结构、确定性世界生成(含测试)
This commit is contained in:
2
go.mod
2
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
142
internal/block/registry.go
Normal file
142
internal/block/registry.go
Normal file
@@ -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
|
||||
}
|
||||
73
internal/block/registry_test.go
Normal file
73
internal/block/registry_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
116
internal/chunk/chunk.go
Normal file
116
internal/chunk/chunk.go
Normal file
@@ -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
|
||||
}
|
||||
56
internal/chunk/chunk_test.go
Normal file
56
internal/chunk/chunk_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
25
internal/coord/coord.go
Normal file
25
internal/coord/coord.go
Normal file
@@ -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
|
||||
}
|
||||
36
internal/coord/coord_test.go
Normal file
36
internal/coord/coord_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
297
internal/worldgen/gen.go
Normal file
297
internal/worldgen/gen.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
103
internal/worldgen/gen_test.go
Normal file
103
internal/worldgen/gen_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user