树苗生长系统#

从一棵小小的树苗到遮天蔽日的巨型丛林树,Minecraft 的树苗生长系统由两阶段状态机、随机 tick、骨粉催熟、2x2 巨型树检测、蜂巢概率、红树悬挂苗等多重机制组成。本文从 SaplingBlockSaplingGenerator 逐层剖析。

一、继承体系#

Block
 └── PlantBlock            植物基类(检查下方是否是泥土/耕地,不能放置就掉落)
       │
       ├── SaplingBlock   普通树苗(实现 Fertilizable 接口,可骨粉催熟)
       │      └── PropaguleBlock   红树胎生苗(还实现 Waterloggable,支持悬挂生长)
       │
       └── AzaleaBlock    杜鹃花丛(独立实现 Fertilizable,没有 STAGE 两阶段)

Fertilizable 接口是骨粉催熟的基础:

  • isFertilizable() — 还能生长吗(如树苗永远 true,小麦到最大就 false)
  • canGrow() — 这次骨粉成功的概率(树苗 45%)
  • grow() — 真正执行生长
  • getFertilizableType() — 默认 GROWER(自身变大),苔藓等是 NEIGHBOR_SPREADER

二、普通树苗:两阶段状态机#

SaplingBlock 有一个 STAGE 属性(IntProperty,值为 0 或 1),这构成了一个简单的状态机:

 STAGE=0 ──(触发)──→ STAGE=1 ──(触发)──→ 长成树(移除树苗,调用 Generator)
   小树苗              准备阶段                真正生成世界结构

触发来源 1:随机 tick#

@Override
protected void randomTick(BlockState state, ServerWorld world, BlockPos pos, Random random) {
    if (world.getLightLevel(pos.up()) >= 9    // 头顶光照 ≥ 9
        && random.nextInt(7) == 0) {          // 1/7 概率(平均 ~476 秒一次)
        this.generate(world, pos, state, random);
    }
}

触发来源 2:骨粉#

// Fertilizable 接口实现
public boolean isFertilizable(WorldView w, BlockPos p, BlockState s) { return true; }
public boolean canGrow(World w, Random r, BlockPos p, BlockState s) {
    return w.random.nextFloat() < 0.45;  // 45% 成功概率
}
public void grow(ServerWorld w, Random r, BlockPos p, BlockState s) {
    this.generate(w, p, s, r);
}

generate():两阶段切换核心#

public void generate(ServerWorld world, BlockPos pos, BlockState state, Random random) {
    if (state.get(STAGE) == 0) {
        // 阶段 0 → 阶段 1:只是改 BlockState,不生成树
        // 这一步让"一次骨粉直接长出一棵树"变成了概率问题
        world.setBlockState(pos, state.cycle(STAGE), SKIP_REDRAW_AND_BLOCK_ENTITY_REPLACED_CALLBACK);
    } else {
        // 阶段 1 → 树:调用 SaplingGenerator.generate 生成世界结构
        this.generator.generate(world,
            world.getChunkManager().getChunkGenerator(),
            pos, state, random);
    }
}

为什么两阶段? 如果没有 STAGE=0→1 的过渡,每次骨粉直接长树,那"树的大小(普通 vs 大型橡树)“无法与触发次数解耦。两阶段设计让骨粉的使用次数和树类型概率互相独立。

三、SaplingGenerator:树结构生成#

每种树对应一个 SaplingGenerator 实例。它持有:

  • regularVariant:普通树配置
  • rareRegularVariant:稀有变种树(有 rareChance 概率选中)
  • megaVariant:2x2 巨型树
  • rareMegaVariant:稀有巨型树
  • beesVariant / rareBeesVariant:附近有花时可能生成带蜂巢的版本

以橡树为例:

SaplingGenerator OAK = new SaplingGenerator(
    "oak",
    0.1F,                              // 10% 概率选中稀有变种
    Optional.empty(),                  // 无巨型树(橡树没有 2x2 巨型)
    Optional.empty(),                  // 无稀有巨型
    Optional.of(TreeConfiguredFeatures.OAK),              // 普通橡树
    Optional.of(TreeConfiguredFeatures.FANCY_OAK),        // 稀有:大型(弯)橡树
    Optional.of(TreeConfiguredFeatures.OAK_BEES_005),     // 蜜蜂橡树(5%概率有蜂巢)
    Optional.of(TreeConfiguredFeatures.FANCY_OAK_BEES_005)
);

特征选择逻辑#

// 普通小树特征(用于 1x1 树苗)
private RegistryKey<ConfiguredFeature<?,?>> getSmallTreeFeature(Random r, boolean flowers) {
    if (r.nextFloat() < rareChance) {
        // 命中稀有概率
        if (flowers && rareBeesVariant.isPresent()) return rareBeesVariant;
        if (rareRegularVariant.isPresent())      return rareRegularVariant;
    }
    // 普通概率:有花 + 有蜜蜂变种 → 蜜蜂橡树;否则普通
    return flowers && beesVariant.isPresent() ? beesVariant : regularVariant;
}

// 附近有花吗?搜索 pos.down().north(2).west(2) 到 pos.up().south(2).east(2)
private boolean areFlowersNearby(WorldAccess w, BlockPos p) {
    for (BlockPos n : BlockPos.iterate(p.down().north(2).west(2), p.up().south(2).east(2)))
        if (w.getBlockState(n).isIn(BlockTags.FLOWERS)) return true;
    return false;
}

树生成主流程:generate()#

// 第一步:尝试 2x2 巨型树
if (megaVariant != null) {
    // 检查 4 种"左上角偏移"对齐方式(0,0)(0,-1)(-1,0)(-1,-1)
    for (int i = 0; i >= -1; i--) {
        for (int j = 0; j >= -1; j--) {
            if (canGenerateLargeTree(state, world, pos, i, j)) {
                // 2x2 树苗确认无误,开始生成
                // 1. 先把 4 个树苗全部设为 AIR
                setBlock(add(i,0,j), AIR, ...)
                setBlock(add(i+1,0,j), AIR, ...)
                setBlock(add(i,0,j+1), AIR, ...)
                setBlock(add(i+1,0,j+1), AIR, ...)

                // 2. 调用 ConfiguredFeature.generate(左上角)
                if (configuredFeature.generate(world, chunkGen, rand, pos.add(i,0,j)))
                    return true;  // 成功

                // 3. 失败回滚:恢复 4 棵树苗
                setBlock(..., state) × 4
                return false;
            }
        }
    }
}

// 第二步:尝试 1x1 普通树
feature = getSmallTreeFeature(rand, areFlowersNearby(world, pos));
if (feature == null) return false;

// 同样先把自己去掉,腾出位置
BlockState fluidState = world.getFluidState(pos).getBlockState();
world.setBlockState(pos, fluidState, SKIP_REDRAW_AND_BLOCK_ENTITY_REPLACED_CALLBACK);

if (configuredFeature.generate(world, chunkGen, rand, pos)) {
    // 生成成功后如果位置还没被树覆盖,通知邻居
    if (world.getBlockState(pos) == fluidState)
        world.updateListeners(pos, state, fluidState, NOTIFY_LISTENERS);
    return true;
} else {
    // 失败:恢复树苗
    world.setBlockState(pos, state, SKIP_REDRAW_AND_BLOCK_ENTITY_REPLACED_CALLBACK);
    return false;
}

2x2 巨型树的对齐检测#

为什么有 4 种偏移?因为当玩家种下 2x2 树苗时,触发点可能是 4 个位置中的任意一个,所以要尝试全部对齐:

  j=-1 → 列向左对齐
  i=-1 → 行向上对齐

  位置 = (pos.x + i, pos.z + j) 作为 2x2 的左上角

canGenerateLargeTree(state, world, pos, i, j) 检查 (x+i,y,z+j), (x+i+1,y,z+j), (x+i,y,z+j+1), (x+i+1,y,z+j+1) 这四个位置的方块是否都与 state 同种。

四、各类树苗的 Generator 特性#

树苗 Generator 稀有概率 2x2 巨型 蜜蜂变种
橡树 OAK 10% → Fancy 大型橡树 ✅ 5%蜂巢
云杉 SPRUCE 50% → Mega Pine(巨型松树) ✅ Mega Spruce / Mega Pine
白桦 BIRCH
丛林 JUNGLE ✅ Mega Jungle Tree
金合欢 ACACIA
深色橡 DARK_OAK ✅(必须 2x2 才能长)
红树 MANGROVE 85% → Tall Mangrove
樱花 CHERRY
杜鹃 AZALEA
苍白橡 PALE_OAK ✅(必须 2x2)

为什么深色橡树 1x1 不长? 因为 DARK_OAKregularVariant = Optional.empty(),没有小树配置,getSmallTreeFeature 会返回 null,所以只能 2x2 生成巨型。

五、红树胎生苗:PropaguleBlock#

红树苗是最复杂的树苗,在 SaplingBlock 基础上又增加了 3 个属性:

  • AGE (0–4):悬挂时的长度阶段
  • HANGING:是否处于悬挂状态(挂在红树叶下)
  • WATERLOGGED:可含水(红树能在水下长出)

独特机制:悬挂生长#

红树胎生苗最初出现在红树林树叶下,是悬挂的:

         ▓▓▓▓  红树叶
           ▒    HANGING=true
           ▒    AGE 从 0 开始
           ▒    随机tick让 AGE 逐步增长
           ▒
           ▼    AGE=4 (完全长大) 后,若树叶消失 → 胎生苗掉下来
                落在地上 → HANGING=false,此时开始普通的两阶段生长
@Override
protected void randomTick(BlockState state, ServerWorld world, BlockPos pos, Random random) {
    if (!isHanging(state)) {
        // 着地状态:1/7 概率长成树(同普通树苗)
        if (random.nextInt(7) == 0) generate(world, pos, state, random);
    } else {
        // 悬挂状态:每次都长一岁,到 AGE=4 就停
        if (!isFullyGrown(state)) {
            world.setBlockState(pos, state.cycle(AGE), NOTIFY_LISTENERS);
        }
    }
}

AGE 对应的碰撞箱高度(MIN_Y_BY_AGE):

AGE=0 → MIN_Y=13  (只有顶部 3 像素的小芽)
AGE=1 → MIN_Y=10
AGE=2 → MIN_Y=7
AGE=3 → MIN_Y=3
AGE=4 → MIN_Y=0   (完整长度)

放置位置的不同#

// PropaguleBlock.canPlaceAt
protected boolean canPlaceAt(BlockState state, WorldView world, BlockPos pos) {
    if (isHanging(state)) {
        // 悬挂:上方必须是红树叶
        return world.getBlockState(pos.up()).isOf(Blocks.MANGROVE_LEAVES);
    } else {
        return super.canPlaceAt(state, world, pos);  // 继承 SaplingBlock.canPlaceAt
    }
}

// 父类 PlantBlock.canPlaceAt 检查 canPlantOnTop:
// PropaguleBlock 重写为:泥土/耕地 或 粘土
protected boolean canPlantOnTop(BlockState floor, BlockView w, BlockPos p) {
    return super.canPlantOnTop(floor, w, p)  // 泥土类 / 耕地
        || floor.isOf(Blocks.CLAY);          // 红树特有:粘土
}

骨粉催熟:悬挂时增长长度,着地时长成树#

public void grow(ServerWorld world, Random r, BlockPos pos, BlockState state) {
    if (isHanging(state) && !isFullyGrown(state)) {
        world.setBlockState(pos, state.cycle(AGE), NOTIFY_LISTENERS);
    } else {
        super.grow(world, r, pos, state);
    }
}

六、杜鹃花丛:AzaleaBlock#

它不继承 SaplingBlock,但实现 Fertilizable,也能骨粉催熟。区别:

  1. 没有 STAGE 属性,一次骨粉就直接调用 SaplingGenerator.AZALEA.generate()
  2. 上方有水(流体)时禁止生长
    public boolean isFertilizable(WorldView w, BlockPos p, BlockState s) {
        return w.getFluidState(p.up()).isEmpty();  // 上方不能有水
    }
  3. 也能种在粘土上(和红树苗一致)
  4. 外观是灌木而不是树苗

七、为什么树苗总是"随机消失”#

很多玩家有过"明明种了树苗,加载回来后没了"的经历。原因是 PlantBlockcanPlaceAt 检查:

// PlantBlock.getStateForNeighborUpdate
protected BlockState getStateForNeighborUpdate(...) {
    return !state.canPlaceAt(world, pos) ? Blocks.AIR : super....;
}

任何导致 canPlaceAt 返回 false 的变化(泥土被破坏、悬挂的树叶消失)都会让树苗直接变成空气。如果你在未加载的区块里把泥土挖掉,再回来加载区块时树苗就"消失"了。

八、性能与机制知识速记#

问题 答案
平均自然长树要多久? 1/7 × ~68秒(区块随机tick周期)× 2 阶段 ≈ 2-3 分钟
1 堆骨粉平均能出几棵树? 每堆骨粉 × 45% 成功率,阶段 0 → 1 → 树要成功两次,期望 2/0.45 ≈ 4.4 堆骨粉/棵
为什么蜜蜂树偶尔出现? 附近有花的位置种植,areFlowersNearby 返回 true,选 bees 配置
深色橡树 1x1 为什么不长? regularVariant 为空,只能 2x2
红树胎生苗在水里能长? 能,它实现 Waterloggable,也能种在粘土上
为什么粘液块农场里树苗长不出? randomTick 只在加载的区块里发生,农场通常是未加载的