世界中的流体

世界中的流体

在世界中放置流体时,使用的是 FluidState 而非 Fluid,这与 BlockState 和 Block 的关系相似。可以调用 Level#getFluidState() 查询指定位置的流体状态,通过 Fluid#defaultFluidState() 获取默认状态。

不过,流体状态与方块状态也有差别。最明显的是,不同流体状态并非通过方块状态那样的属性定义来运作,而是由世界根据流体扩散机制计算出具体状态。多数情况下不需要关注精确的 FluidState;需要时,可以查询其中的一些属性,例如 isSource()。

遗憾的是,当前世界中 FluidState 的实现并不完善,而且 NeoForge 无法在保持原版世界兼容性的前提下修复它。几乎所有流体状态逻辑都以某种方式绑定到 BlockState,尽管理论上没有必要。例如,系统没有 Level#setFluidState() 方法;当前实现中,Level#getFluidState() 最终会深入区块存储,归结为 BlockState#getFluidState()。预计 Mojang 最终会重做这一系统,但目前仍需沿用现有实现。

含水方块

另见:方块和方块状态。

含水机制集中体现了当前 FluidState 系统的局限:某些非完整方块,例如台阶,可以同时包含一个水源。当前通过 WATERLOGGED 方块状态属性实现:

// Implementing SimpleWaterloggedBlock automatically enables bucket pickup
// and makes some helper methods available.
public class MyBlock extends Block implements SimpleWaterloggedBlock {
    // Add the WATERLOGGED property to our class for easy access.
    public static final BooleanProperty WATERLOGGED = BlockStateProperties.WATERLOGGED;

    // Set WATERLOGGED to false by default.
    public MyBlock(Properties properties) {
        super(properties);
        registerDefaultState(getStateDefinition().any().setValue(WATERLOGGED, false));
    }

    // Add WATERLOGGED to the block state definition.
    @Override
    protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
        super.createBlockStateDefinition(builder);
        builder.add(WATERLOGGED);
    }

    // The important part: Query the WATERLOGGED property when asked for the fluid state.
    // The `false` parameter in Fluids.WATER.getSource(false) means "falling" and is set to false
    // for all vanilla waterlogging implementations.
    @Override
    public FluidState getFluidState(BlockState state) {
        return state.getValue(WATERLOGGED) ? Fluids.WATER.getSource(false) : super.getFluidState(state);
    }

    // When placing this block in water, place the waterlogged state.
    @Override
    public BlockState getStateForPlacement(BlockPlaceContext context) {
        return defaultBlockState().setValue(
                WATERLOGGED,
                context.getLevel().getFluidState(context.getClickedPos()).is(Fluids.WATER)
        );
    }
    
    // When this block receives a block update (because e.g. a neighbor changed),
    // a tick should be scheduled.
    @Override
    protected BlockState updateShape(
            BlockState state,
            LevelReader level,
            ScheduledTickAccess ticks,
            BlockPos pos,
            Direction directionToNeighbour,
            BlockPos neighbourPos,
            BlockState neighbourState,
            RandomSource random
    ) {
        if (state.getValue(WATERLOGGED)) {
            ticks.scheduleTick(pos, Fluids.WATER, Fluids.WATER.getTickDelay(level));
        }
        return super.updateShape(state,
                level,
                ticks,
                pos,
                directionToNeighbour,
                neighbourPos,
                neighbourState,
                random);
    }
}

说明 类似的“含熔岩”或含其他流体机制也很容易实现:创建新的 BooleanProperty,按常规方式添加到方块中,并在属性为真时让 Block#getFluidState() 返回目标流体。

不过需要注意,世界生成、活塞移动等部分逻辑对含水行为进行了硬编码。

流体方块

要将流体放入世界,需要为它创建 LiquidBlock:

// Assuming a DeferredRegister.Blocks named BLOCKS, and assuming the fluid stuff
// is in another class named ModFluids.
public static final DeferredBlock<LiquidBlock> MOLTEN_IRON = BLOCKS.registerBlock(
        // The block registry name.
        "molten_iron",
        // The liquid block factory.
        properties -> new LiquidBlock(ModFluids.MOLTEN_IRON.get(), properties),
        // The block properties.
        () -> BlockBehaviour.Properties.of()
                // Standard properties for both vanilla fluids. Strength 100 disables vanilla TNT
                // from having effects while allowing modded explosives to still work.
                .liquid()
                .noLootTable()
                .noCollision()
                .replaceable()
                .pushReaction(PushReaction.DESTROY)
                .sound(SoundType.EMPTY)
                .strength(100)
                // You may define additional properties depending on what your fluid does.
                // For example, like before, we make our molten iron fluid glow slightly:
                .lightLevel(_ -> 5)
);

然后将该方块加入流体属性:

public static final BaseFlowingFluid.Properties MOLTEN_IRON_PROPERTIES =
        new BaseFlowingFluid.Properties(MOLTEN_IRON_TYPE, MOLTEN_IRON, FLOWING_MOLTEN_IRON)
                // Set the block, assuming it is located in the `ModBlocks` class.
                // Make sure that `ModBlocks` is classloaded before `ModFluids`!
                .block(ModBlocks.MOLTEN_IRON);

最后,方块需要一个模型,可以很方便地通过数据生成创建:

@Override
protected void registerModels(BlockModelGenerators blockModels, ItemModelGenerators itemModels) {
    blockModels.createNonTemplateModelBlock(ModBlocks.MOLTEN_IRON.get());
}

桶

流体通常可以用桶收集。自定义流体桶可以这样添加:

// Assuming a DeferredRegister.Items named ITEMS, and assuming the fluid stuff
// is in another class named ModFluids.
public static final DeferredItem<BucketItem> MOLTEN_IRON_BUCKET = ITEMS.registerItem(
        // The registry name.
        "molten_iron_bucket",
        // The bucket item factory.
        properties -> new BucketItem(ModFluids.MOLTEN_IRON.get(), properties),
        // The properties supplier. Buckets stack to 1 and return a bucket when used in crafting.
        () -> new Item.Properties().stacksTo(1).craftRemainder(Items.BUCKET)
);

然后将桶加入流体属性:

public static final BaseFlowingFluid.Properties MOLTEN_IRON_PROPERTIES =
        new BaseFlowingFluid.Properties(MOLTEN_IRON_TYPE, MOLTEN_IRON, FLOWING_MOLTEN_IRON)
                .block(ModBlocks.MOLTEN_IRON)
                // Set the bucket, assuming it is located in the `ModItems` class.
                // Make sure that `ModItems` is classloaded before `ModFluids`!
                .bucket(ModItems.MOLTEN_IRON_BUCKET);

接着,建议为桶添加发射器行为,但这不是必需的:

@SubscribeEvent // on the mod event bus
private static void commonSetup(FMLCommonSetupEvent event) {
    // `DispenserBlock#registerBehavior` is not thread-safe so we wrap it in a lambda.
    event.enqueueWork(() -> DispenserBlock.registerBehavior(ModItems.MOLTEN_IRON_BUCKET, DispenseFluidContainer.getInstance()));
}

建议 如果需要自定义发射器行为,也可以创建自己的 DispenseItemBehavior。具体实现可参考 DispenseFluidContainer 源代码。

最后只需添加翻译和模型:

// In the language provider
@Override
protected void addTranslations() {
    add(ModFluids.MOLTEN_IRON_TYPE.get().getDescriptionId(), "Molten Iron");
    addItem(ModItems.MOLTEN_IRON_BUCKET, "Molten Iron Bucket");
}

// In the model provider
@Override
protected void registerModels(BlockModelGenerators blockModels, ItemModelGenerators itemModels) {
    blockModels.createNonTemplateModelBlock(ModBlocks.MOLTEN_IRON.get());
    // We use NeoForge's `DynamicFluidContainerModel`.
    itemModels.itemModelOutput.accept(ModItems.MOLTEN_IRON_BUCKET.get(), new DynamicFluidContainerModel.Unbaked(
            // The model's textures. The model is rendered in the order of base, fluid, cover (lowest to highest).
            new DynamicFluidContainerModel.Textures(
                    // The particle texture.
                    Optional.of(new Material(Identifier.withDefaultNamespace("item/bucket"))),
                    // The base texture.
                    Optional.of(new Material(Identifier.withDefaultNamespace("item/bucket"))),
                    // The fluid texture, i.e. the part that actually contains the fluid.
                    Optional.of(new Material(Identifier.fromNamespaceAndPath("neoforge", "item/mask/bucket_fluid"))),
                    // The cover texture. This is rendered last and can be a mask (see below).
                    Optional.empty()
            ),
            // The fluid to use.
            ModFluids.MOLTEN_IRON.get(),
            // Whether the bucket model should be flipped, commonly used for "gaseous" fluids.
            false,
            // If true, the cover texture is a mask. If false, the cover texture is rendered normally.
            // See below for more info.
            true,
            // If this is true, if the fluid emits light, the fluid element of the model becomes emissive.
            true));
}

桶的遮罩纹理

当布尔值 coverIsMask 为真时,覆盖纹理会改作遮罩。NeoForge 为桶提供了四种默认遮罩纹理。遮罩的每个像素要么完全透明(下图为便于与网站背景区分,以纯黑显示),要么不是完全透明,作用类似模板。查看这些纹理即可理解:

桶装流体遮罩纹理

图片来源:NeoForged/NeoForge 26.1.x。 桶装流体滴液遮罩纹理

图片来源:NeoForged/NeoForge 26.1.x。 桶装流体覆盖遮罩纹理

图片来源:NeoForged/NeoForge 26.1.x。 桶装流体覆盖滴液遮罩纹理

图片来源:NeoForged/NeoForge 26.1.x。

只有遮罩中非完全透明的像素区域会参与渲染,与完全透明区域重叠的像素会被丢弃。

建议 为便于区分两类像素,建议透明像素统一使用透明黑色 0x00000000,不透明像素使用纯白色 0xffffffff。

炼药锅

除桶外,流体也常存放在炼药锅中。为此需要单独的炼药锅方块:

public class MoltenIronCauldronBlock extends AbstractCauldronBlock {
    // Block codec boilerplate.
    private static final MapCodec<MoltenIronCauldronBlock> CODEC =
            simpleCodec(MoltenIronCauldronBlock::new);

    @Override
    protected MapCodec<? extends AbstractCauldronBlock> codec() {
        return CODEC;
    }

    // The cauldron interaction dispatcher and its id. See below for more info.
    public static final CauldronInteraction.Dispatcher CAULDRON_INTERACTIONS =
            new CauldronInteraction.Dispatcher();
    public static final Identifier CAULDRON_INTERACTIONS_ID =
            Identifier.fromNamespaceAndPath(ExampleMod.MOD_ID, "molten_iron_cauldron");

    // Pass our `CauldronInteraction.Dispatcher` to super.
    public MoltenIronCauldronBlock(Properties properties) {
        super(properties, CAULDRON_INTERACTIONS);
    }

    // We assume that our cauldron can only ever be completely full, i.e. that we don't have "bottles"
    // or a similar intermediary unit present.
    @Override
    public boolean isFull(BlockState state) {
        return true;
    }

    // Vanilla water cauldrons output 1-3 based on the fill level,
    // we are always full and therefore output 3.
    @Override
    protected int getAnalogOutputSignal(BlockState state, Level level, BlockPos pos, Direction direction) {
        return 3;
    }

    // A full cauldron has its visual height at 0.9375 (= 15/16).
    @Override
    protected double getContentHeight(BlockState state) {
        return 0.9375;
    }
}

然后使用这个炼药锅类注册方块:

// Assuming a DeferredRegister.Blocks named BLOCKS.
public static final DeferredBlock<MoltenIronCauldronBlock> MOLTEN_IRON_CAULDRON = BLOCKS.registerBlock(
        // The registry name.
        "molten_iron_cauldron",
        // The cauldron constructor reference.
        MoltenIronCauldronBlock::new,
        // The properties to use. We generally copy the vanilla cauldron.
        // Since we gave molten iron a glow, we also apply that to the cauldron.
        () -> BlockBehaviour.Properties.ofFullCopy(Blocks.CAULDRON).lightLevel(_ -> 5)
);

接着,需要在 RegisterCauldronFluidContentEvent 中关联流体和炼药锅:

@SubscribeEvent // on the mod event bus
private static void registerCauldronFluidContent(RegisterCauldronFluidContentEvent event) {
    event.register(
            // The cauldron block.
            ModBlocks.MOLTEN_IRON_CAULDRON.get(),
            // The fluid.
            ModFluids.MOLTEN_IRON.get(),
            // The amount.
            FluidType.BUCKET_VOLUME,
            // The "level" block state property. Since we don't have one, we pass null.
            null);
}

最后,流体炼药锅也是普通方块,因此需要相应的数据生成配置,包括翻译、方块模型、战利品表和标签:

// In the language provider
@Override
protected void addTranslations() {
    add(ModFluids.MOLTEN_IRON_TYPE.get().getDescriptionId(), "Molten Iron");
    addItem(ModItems.MOLTEN_IRON_BUCKET, "Molten Iron Bucket");
    addBlock(ModBlocks.MOLTEN_IRON_CAULDRON, "Molten Iron Cauldron");
}

// In the model provider
@Override
protected void registerModels(BlockModelGenerators blockModels, ItemModelGenerators itemModels) {
    blockModels.createNonTemplateModelBlock(ModBlocks.MOLTEN_IRON.get());
    itemModels.itemModelOutput.accept(...);
    blockModels.blockStateOutput.accept(BlockModelGenerators.createSimpleBlock(
            // Our cauldron block.
            ModBlocks.MOLTEN_IRON_CAULDRON.get(),
            // We use the `CAULDRON_FULL` model template.
            BlockModelGenerators.plainVariant(ModelTemplates.CAULDRON_FULL.create(
                    // Our cauldron block.
                    ModBlocks.MOLTEN_IRON_CAULDRON.get(),
                    // The cauldron fluid texture mapping.
                    TextureMapping.cauldron(TextureMapping.getBlockTexture(ModBlocks.MOLTEN_IRON.get(), "_still")),
                    blockModels.modelOutput))));
}

// In the block loot sub provider
@Override
protected void generate() {
    // Drop an empty cauldron when mined.
    dropOther(ModBlocks.MOLTEN_IRON_CAULDRON.get(), Items.CAULDRON);
}

// In the block tags provider
@Override
protected void addTags(HolderLookup.Provider provider) {
    tag(BlockTags.CAULDRONS).add(ModBlocks.MOLTEN_IRON_CAULDRON.get());
}

炼药锅交互

现在已有炼药锅,但还无法与它交互,也无法在生存模式中获得。需要注册炼药锅交互。前面的炼药锅类中有一个 CauldronInteraction.Dispatcher,现在会用到它。

炼药锅交互涉及两个事件。首先注册 CauldronInteraction.Dispatcher:

@SubscribeEvent // on the mod event bus
private static void registerCauldronInteractionDispatchers(RegisterCauldronInteractionEvent.Dispatcher event) {
    event.register(
            // A unique identifier.
            MoltenIronCauldronBlock.CAULDRON_INTERACTIONS_ID,
            // Our `CauldronInteraction.Dispatcher`.
            MoltenIronCauldronBlock.CAULDRON_INTERACTIONS);
}

然后注册实际交互:

@SubscribeEvent
private static void registerCauldronInteractions(RegisterCauldronInteractionEvent.Interaction event) {
    // Empty our cauldron when it is right-clicked with an empty bucket.
    event.register(
            // The id of our cauldron interactions.
            MoltenIronCauldronBlock.CAULDRON_INTERACTIONS_ID,
            // The item we're right-clicking with.
            Items.BUCKET,
            // A callback called when right-clicking. Input parameters are the cauldron blockstate,
            // the level, the position, the player, the used hand, and the used item stack.
            (state, level, pos, player, hand, stack) -> CauldronInteractions.fillBucket(
                    // Pass along the input parameters.
                    state, level, pos, player, hand, stack,
                    // The resulting item stack.
                    ModItems.MOLTEN_IRON_BUCKET.toStack(),
                    // A predicate for additional checks if the bucket can be filled.
                    // We have no additional checks, so we just always return true.
                    _ -> true,
                    // The sound event to play when emptying the cauldron.
                    SoundEvents.BUCKET_FILL_LAVA));
    
    // For compat with vanilla, we need to add handling for when our cauldron is right-clicked
    // with water, lava and powder snow buckets. Compat with other mods is handled
    // by the bucket fill handler method, see below.
    event.register(
            MoltenIronCauldronBlock.CAULDRON_INTERACTIONS_ID,
            Items.LAVA_BUCKET,
            CauldronInteractions::fillLavaInteraction);
    event.register(
            MoltenIronCauldronBlock.CAULDRON_INTERACTIONS_ID,
            Items.WATER_BUCKET,
            CauldronInteractions::fillWaterInteraction);
    event.register(
            MoltenIronCauldronBlock.CAULDRON_INTERACTIONS_ID,
            Items.POWDER_SNOW_BUCKET,
            CauldronInteractions::fillPowderSnowInteraction);

    // When **any** cauldron is right-clicked with our bucket, replace with our cauldron.
    // To do so, we use `event#registerToAll()` instead of `event#register()`.
    event.registerToAll(ModItems.MOLTEN_IRON_BUCKET.get(),
            // A callback called when right-clicking. Input parameters are the cauldron blockstate,
            // the level, the position, the player, the used hand, and the used item stack.
            (state, level, pos, player, hand, stack) -> CauldronInteractions.emptyBucket(
                    // Pass along the input parameters, except the state.
                    level, pos, player, hand, stack,
                    // The resulting block state.
                    ModBlocks.MOLTEN_IRON_CAULDRON.get().defaultBlockState(),
                    // The sound event to play when filling the cauldron.
                    SoundEvents.BUCKET_EMPTY_LAVA));
}

炼药锅交互不只用于桶。原版还有一些其他配方,大多用于洗去染色物品的颜色,它们采用的机制基本相同。更多信息请查看 CauldronInteractions 类,其中也可以找到原版炼药锅交互分发器。

滴水石锥滴落

利用滴水石锥和炼药锅,可以再生水和熔岩。对 FluidType.Properties 调用 addDripstoneDripping(),即可为 Mod 的炼药锅扩展这项功能:

FluidType.Properties.create()
        .addDripstoneDripping(
                // Probability per attempt to fill the cauldron. Vanilla values can be found
                // at PointedDripstoneBlock.WATER_TRANSFER_PROBABILITY_PER_RANDOM_TICK
                // and PointedDripstoneBlock.LAVA_TRANSFER_PROBABILITY_PER_RANDOM_TICK
                // and are ~17.5% and 5.9%, respectively. Water is 3 times more likely to drip
                // as it fills the cauldron in 3 levels, while lava cauldrons fill all at once.
                // With our molten iron fluid, we follow the lava mechanics.
                PointedDripstoneBlock.LAVA_TRANSFER_PROBABILITY_PER_RANDOM_TICK,
                // The dripping particle to use. May be null, in which case the dimension's
                // default dripping particle - lava dripping in the nether, water dripping
                // in all others - will be used instead. Let's assume that we made a particle
                // at ModParticles.DRIPPING_DRIPSTONE_MOLTEN_IRON, and use it here.
                ModParticles.DRIPPING_DRIPSTONE_MOLTEN_IRON.get(),
                // The filled cauldron block to turn the empty cauldron into upon dripping.
                ModBlocks.MOLTEN_IRON_CAULDRON.get(),
                // The sound to play when filling the cauldron. May be null, in which case no sound
                // will be played. As with the particle, let's assume that we made a sound event
                // at ModSounds.POINTED_DRIPSTONE_DRIP_MOLTEN_IRON_INTO_CAULDRON, and use it here.
                ModSounds.POINTED_DRIPSTONE_DRIP_MOLTEN_IRON_INTO_CAULDRON.get()
        );

建议 若要为滴水石锥滴落创建自定义粒子或声音事件,请分别参阅粒子和声音文档。


来源: NeoForged/Documentation(26.1)。中文翻译依据提交 4a680cdd4d15。