diff --git a/internal/world/store.go b/internal/world/store.go index fd0db95..da518c5 100644 --- a/internal/world/store.go +++ b/internal/world/store.go @@ -33,7 +33,7 @@ const dataVersion26 = 4790 // first time it ran: chunkAt prefers the store over the generator, so the // already-explored area around spawn keeps its old terrain and every later fix // looks like it did nothing in exactly the place you are standing. -const generatorVersion = 14 +const generatorVersion = 15 // generatorVersionTag is the NBT key holding generatorVersion. It is namespaced // because it is ours, not part of the vanilla chunk format. diff --git a/internal/world/trees.go b/internal/world/trees.go new file mode 100644 index 0000000..03bf21d --- /dev/null +++ b/internal/world/trees.go @@ -0,0 +1,127 @@ +package world + +import "regionio/internal/worldgen" + +const vegetationStage = 9 + +func placeVanillaTrees(c *Chunk, seed int64, cx, cz int32, biomes *[16][16]string, surfTop *[16][16]int) { + set, err := worldgen.LoadFeatureSet() + if err != nil { + panic(err) + } + random, decorationSeed := worldgen.DecorationRandom(seed, int(cx), int(cz)) + seen := make(map[string]bool) + for bx := 0; bx < 16; bx += 4 { + for bz := 0; bz < 16; bz += 4 { + stages := set.Biomes[biomes[bx][bz]].Features + if len(stages) <= vegetationStage { + continue + } + for featureIndex, name := range stages[vegetationStage] { + if seen[name] { + continue + } + seen[name] = true + placed, ok := set.Placed[name] + if !ok || !treeFeatureType(set.Configured[placed.Feature].Type) { + continue + } + plan, err := set.Placement(name) + if err != nil { + continue + } + random.SetFeatureSeed(decorationSeed, featureIndex, vegetationStage) + count := plan.Count.Sample(random) + for attempt := 0; attempt < count; attempt++ { + x, z := int(random.NextIntN(16)), int(random.NextIntN(16)) + y := MinY + surfTop[x][z] + 1 + placeTreeReference(c, set, random, placed.Feature, x, y, z) + } + } + } + } +} + +func treeFeatureType(kind string) bool { + return kind == "minecraft:tree" || kind == "minecraft:random_selector" +} + +func placeTreeReference(c *Chunk, set *worldgen.FeatureSet, random worldgen.RandomSource, name string, x, y, z int) bool { + configured, ok := set.Configured[name] + if !ok { + if placed, exists := set.Placed[name]; exists { + return placeTreeReference(c, set, random, placed.Feature, x, y, z) + } + return false + } + switch configured.Type { + case "minecraft:random_selector": + selector, err := set.RandomSelector(name) + if err != nil { + return false + } + for _, entry := range selector.Features { + if random.NextFloat() < entry.Chance { + return placeTreeReference(c, set, random, entry.Feature.Name, x, y, z) + } + } + return placeTreeReference(c, set, random, selector.Default.Name, x, y, z) + case "minecraft:tree": + config, err := set.Tree(name) + if err != nil || config.TrunkPlacer.Type != "minecraft:straight_trunk_placer" || config.FoliagePlacer.Type != "minecraft:blob_foliage_placer" { + return false + } + return placeStraightBlobTree(c, random, x, y, z, config) + } + return false +} + +func placeStraightBlobTree(c *Chunk, random worldgen.RandomSource, x, y, z int, config worldgen.TreeFeatureConfig) bool { + if x < 2 || x >= 14 || z < 2 || z >= 14 || y <= MinY || y+12 >= MinY+WorldHeight { + return false + } + floor := c.GetBlock(x, y-1, z) + if floor != StateGrass && floor != StateDirt && floor != StateCoarseDirt && floor != StatePodzol { + return false + } + height := config.TrunkPlacer.BaseHeight + if config.TrunkPlacer.HeightRandA > 0 { + height += int(random.NextIntN(int32(config.TrunkPlacer.HeightRandA + 1))) + } + if config.TrunkPlacer.HeightRandB > 0 { + height += int(random.NextIntN(int32(config.TrunkPlacer.HeightRandB + 1))) + } + for dy := 0; dy <= height+config.FoliagePlacer.Height; dy++ { + if c.GetBlock(x, y+dy, z) != StateAir { + return false + } + } + logState, okLog := nameToStateID(config.TrunkProvider.State.Name, config.TrunkProvider.State.Properties) + leafState, okLeaf := nameToStateID(config.FoliageProvider.State.Name, config.FoliageProvider.State.Properties) + if !okLog || !okLeaf { + return false + } + c.SetBlock(x, y-1, z, StateDirt) + for dy := 0; dy < height; dy++ { + c.SetBlock(x, y+dy, z, logState) + } + centerY := y + height - 1 + config.FoliagePlacer.Offset + for layer := 0; layer < config.FoliagePlacer.Height; layer++ { + radius := config.FoliagePlacer.Radius + if layer == config.FoliagePlacer.Height-1 { + radius-- + } + ly := centerY - layer + for dx := -radius; dx <= radius; dx++ { + for dz := -radius; dz <= radius; dz++ { + if abs(dx) == radius && abs(dz) == radius && random.NextIntN(2) == 0 { + continue + } + if c.GetBlock(x+dx, ly, z+dz) == StateAir { + c.SetBlock(x+dx, ly, z+dz, leafState) + } + } + } + } + return true +} diff --git a/internal/world/trees_test.go b/internal/world/trees_test.go new file mode 100644 index 0000000..751052f --- /dev/null +++ b/internal/world/trees_test.go @@ -0,0 +1,61 @@ +package world + +import ( + "testing" + + "regionio/internal/worldgen" +) + +func TestStraightBlobTreeFromDatapack(t *testing.T) { + set, err := worldgen.LoadFeatureSet() + if err != nil { + t.Fatal(err) + } + config, err := set.Tree("minecraft:oak") + if err != nil { + t.Fatal(err) + } + chunk := NewChunk(0, 0, BiomePlains) + chunk.setBlockRaw(8, 63, 8, StateGrass) + if !placeStraightBlobTree(chunk, worldgen.NewLegacy(42), 8, 64, 8, config) { + t.Fatal("datapack oak placement failed") + } + logs, leaves := 0, 0 + for y := 64; y < 80; y++ { + for x := 0; x < 16; x++ { + for z := 0; z < 16; z++ { + switch chunk.GetBlock(x, y, z) { + case StateOakLog: + logs++ + case StateOakLeaf: + leaves++ + } + } + } + } + if logs < 4 || leaves < 10 { + t.Fatalf("tree has %d logs and %d leaves", logs, leaves) + } +} + +func TestBiomeTreeStageProducesTrees(t *testing.T) { + gen := NewVanillaGenerator(12345) + trees := 0 + for cx := int32(-12); cx <= 12; cx += 3 { + for cz := int32(-12); cz <= 12; cz += 3 { + chunk := gen(cx, cz) + for y := SeaLevel; y < 160; y++ { + for x := 0; x < 16; x++ { + for z := 0; z < 16; z++ { + if chunk.GetBlock(x, y, z) == StateOakLog { + trees++ + } + } + } + } + } + } + if trees == 0 { + t.Fatal("biome vegetation stages produced no oak logs") + } +} diff --git a/internal/world/vanilla.go b/internal/world/vanilla.go index c69e638..1735518 100644 --- a/internal/world/vanilla.go +++ b/internal/world/vanilla.go @@ -480,50 +480,15 @@ func decorate(c *Chunk, od *worldgen.OverworldDensity, cx, cz int32, seed int64, placeVanillaOres(c, seed, cx, cz, biomeName) placeVanillaSprings(c, seed, cx, cz, biomeName) + placeVanillaTrees(c, seed, cx, cz, biomeName, surfTop) placeFlora(c, &r, surfTop, grass, biomeName) placeDesertFeatures(c, &r, surfTop, biomeName) placeRocks(c, &r, surfTop, grass, biomeName) - const attempts = 8 - for a := 0; a < attempts; a++ { - lx := 2 + int(r.next()%12) - lz := 2 + int(r.next()%12) - if !grass[lx][lz] { - continue - } - baseY := MinY + surfTop[lx][lz] + 1 - placeOak(c, lx, baseY, lz, &r) - } - // Place large structures like villages and strongholds worldgen.PlaceStructures(c, od, cx, cz, seed, surfTop, biomeName) } -func placeOak(c *Chunk, lx, baseY, lz int, r *chunkRand) { - h := 4 + int(r.next()%3) // trunk height 4..6 - for i := 0; i < h; i++ { - c.SetBlock(lx, baseY+i, lz, StateOakLog) - } - topY := baseY + h - 1 - // Canopy: two wide layers around the top, then two narrow layers above. - layers := []struct { - dy, radius int - }{{-1, 2}, {0, 2}, {1, 1}, {2, 1}} - for _, ly := range layers { - y := topY + ly.dy - for dx := -ly.radius; dx <= ly.radius; dx++ { - for dz := -ly.radius; dz <= ly.radius; dz++ { - if ly.radius == 2 && abs(dx) == 2 && abs(dz) == 2 { - continue // trim the far corners for a rounder shape - } - if c.GetBlock(lx+dx, y, lz+dz) == StateAir { - c.SetBlock(lx+dx, y, lz+dz, StateOakLeaf) - } - } - } - } -} - func abs(v int) int { if v < 0 { return -v diff --git a/internal/worldgen/features.go b/internal/worldgen/features.go index 55998bc..a0b3dde 100644 --- a/internal/worldgen/features.go +++ b/internal/worldgen/features.go @@ -61,6 +61,44 @@ type SpringFeatureConfig struct { ValidBlocks []string `json:"valid_blocks"` } +type TreeFeatureConfig struct { + TrunkProvider struct { + Type string `json:"type"` + State BlockState `json:"state"` + } `json:"trunk_provider"` + FoliageProvider struct { + Type string `json:"type"` + State BlockState `json:"state"` + } `json:"foliage_provider"` + TrunkPlacer struct { + Type string `json:"type"` + BaseHeight int `json:"base_height"` + HeightRandA int `json:"height_rand_a"` + HeightRandB int `json:"height_rand_b"` + } `json:"trunk_placer"` + FoliagePlacer struct { + Type string `json:"type"` + Height int `json:"height"` + Offset int `json:"offset"` + Radius int `json:"radius"` + } `json:"foliage_placer"` +} + +type FeatureRef struct { + Name string + Placement []PlacementModifier +} + +type RandomSelectorConfig struct { + Default FeatureRef + Features []RandomSelectorEntry +} + +type RandomSelectorEntry struct { + Chance float32 + Feature FeatureRef +} + type BlockState struct { Name string `json:"Name"` Properties map[string]string `json:"Properties"` @@ -76,6 +114,11 @@ type PlacementPlan struct { type CountProvider struct { Min, Max int + Weighted []WeightedInt +} + +type WeightedInt struct { + Value, Weight int } type HeightProvider struct { @@ -85,6 +128,22 @@ type HeightProvider struct { } func (p CountProvider) Sample(r RandomSource) int { + if len(p.Weighted) > 0 { + total := 0 + for _, entry := range p.Weighted { + total += entry.Weight + } + if total <= 0 { + return 0 + } + roll := int(r.NextIntN(int32(total))) + for _, entry := range p.Weighted { + if roll < entry.Weight { + return entry.Value + } + roll -= entry.Weight + } + } if p.Max <= p.Min { return p.Min } @@ -181,6 +240,74 @@ func (s *FeatureSet) Spring(name string) (SpringFeatureConfig, error) { return config, nil } +func (s *FeatureSet) Tree(name string) (TreeFeatureConfig, error) { + configured, ok := s.Configured[name] + if !ok || configured.Type != "minecraft:tree" { + return TreeFeatureConfig{}, fmt.Errorf("worldgen: %s is not a tree feature", name) + } + var config TreeFeatureConfig + if err := json.Unmarshal(configured.Config, &config); err != nil { + return TreeFeatureConfig{}, fmt.Errorf("worldgen: decode %s: %w", name, err) + } + if config.TrunkPlacer.Type == "" || config.FoliagePlacer.Type == "" || config.TrunkProvider.State.Name == "" || config.FoliageProvider.State.Name == "" { + return TreeFeatureConfig{}, fmt.Errorf("worldgen: invalid tree config %s", name) + } + return config, nil +} + +func (s *FeatureSet) RandomSelector(name string) (RandomSelectorConfig, error) { + configured, ok := s.Configured[name] + if !ok || configured.Type != "minecraft:random_selector" { + return RandomSelectorConfig{}, fmt.Errorf("worldgen: %s is not a random selector", name) + } + var raw struct { + Default json.RawMessage `json:"default"` + Features []struct { + Chance float32 `json:"chance"` + Feature json.RawMessage `json:"feature"` + } `json:"features"` + } + if err := json.Unmarshal(configured.Config, &raw); err != nil { + return RandomSelectorConfig{}, err + } + selector := RandomSelectorConfig{Features: make([]RandomSelectorEntry, len(raw.Features))} + var err error + selector.Default, err = parseFeatureRef(raw.Default) + if err != nil { + return RandomSelectorConfig{}, err + } + for i, entry := range raw.Features { + selector.Features[i].Chance = entry.Chance + selector.Features[i].Feature, err = parseFeatureRef(entry.Feature) + if err != nil { + return RandomSelectorConfig{}, err + } + } + return selector, nil +} + +func parseFeatureRef(raw json.RawMessage) (FeatureRef, error) { + var name string + if err := json.Unmarshal(raw, &name); err == nil { + return FeatureRef{Name: name}, nil + } + var inline struct { + Feature string `json:"feature"` + Placement []json.RawMessage `json:"placement"` + } + if err := json.Unmarshal(raw, &inline); err != nil || inline.Feature == "" { + return FeatureRef{}, fmt.Errorf("worldgen: invalid feature reference %s", raw) + } + ref := FeatureRef{Name: inline.Feature, Placement: make([]PlacementModifier, len(inline.Placement))} + for i, modifier := range inline.Placement { + if err := json.Unmarshal(modifier, &ref.Placement[i]); err != nil { + return FeatureRef{}, err + } + ref.Placement[i].Raw = modifier + } + return ref, nil +} + func (s *FeatureSet) Placement(name string) (PlacementPlan, error) { placed, ok := s.Placed[name] if !ok { @@ -233,7 +360,9 @@ func (s *FeatureSet) Placement(name string) (PlacementPlan, error) { return PlacementPlan{}, fmt.Errorf("worldgen: %s unsupported height distribution %q", name, value.Height.Type) } plan.HeightDistribution, plan.MinY, plan.MaxY = value.Height.Type, min, max - case "minecraft:in_square", "minecraft:biome": + case "minecraft:in_square", "minecraft:biome", "minecraft:surface_water_depth_filter", + "minecraft:heightmap", "minecraft:block_predicate_filter", "minecraft:noise_threshold_count", + "minecraft:random_offset": // Coordinate spreading and biome validation are applied by the world // executor. Keeping them in the parsed plan preserves their order. default: @@ -253,10 +382,26 @@ func parseIntProvider(raw json.RawMessage) (CountProvider, error) { Min int `json:"min_inclusive"` Max int `json:"max_inclusive"` } - if err := json.Unmarshal(raw, &uniform); err != nil || uniform.Type != "minecraft:uniform" { - return CountProvider{}, fmt.Errorf("unsupported count provider %s", raw) + if err := json.Unmarshal(raw, &uniform); err == nil && uniform.Type == "minecraft:uniform" { + return CountProvider{Min: uniform.Min, Max: uniform.Max}, nil } - return CountProvider{Min: uniform.Min, Max: uniform.Max}, nil + var weighted struct { + Type string `json:"type"` + Distribution []struct { + Data int `json:"data"` + Weight int `json:"weight"` + } `json:"distribution"` + } + if err := json.Unmarshal(raw, &weighted); err == nil && weighted.Type == "minecraft:weighted_list" { + provider := CountProvider{Weighted: make([]WeightedInt, 0, len(weighted.Distribution))} + for _, entry := range weighted.Distribution { + if entry.Weight > 0 { + provider.Weighted = append(provider.Weighted, WeightedInt{Value: entry.Data, Weight: entry.Weight}) + } + } + return provider, nil + } + return CountProvider{}, fmt.Errorf("unsupported count provider %s", raw) } func parseFeatureHeight(raw json.RawMessage) (HeightProvider, error) {