本篇教程的视频
本篇教程的源代码
GitHub地址:
本篇教程目标
- 理清流体三件套:
Fluid、LiquidBlock、BucketItem 各管什么
- 学会写一个仿水的流体
- 学会给流体加「碰到就受伤」的逻辑
- 学会 26.1 的流体渲染注册
介绍
流体在工业模组里很常见,石油、酸液、蒸汽,都是流体
这期的案例是污水,并且生物实体待在里面会掉血
三件套
流体要分成三个部分来写,缺一个都不完整
Fluid 决定「它怎么流」,流动速度、衰减、能不能造源,都在这里
LiquidBlock 决定「它怎么作为方块存在」,也就是你世界里看到的那一格液体
BucketItem 决定「它怎么被搬运」,也就是桶
另外还有一个渲染,流体是半透明的,贴图和颜色得单独注册,不然游戏画不出来
查看源代码
水桶
先看水桶,毕竟流体最直观的用法就是拿桶舀
1 2 3
| public static final Item WATER_BUCKET = registerItem( "water_bucket", p -> new BucketItem(Fluids.WATER, p), new Item.Properties().craftRemainder(BUCKET).stacksTo(1) );
|
BucketItem 就两个参数:装的是什么流体、物品属性
craftRemainder(Items.BUCKET) 是「用完还你一个空桶」(配方合成中),stacksTo(1) 是只能叠一个
Fluids 类
1 2
| public static final FlowingFluid FLOWING_WATER = register("flowing_water", new WaterFluid.Flowing()); public static final FlowingFluid WATER = register("water", new WaterFluid.Source());
|
两个字段,一个流动的水,一个水本身,都是 FlowingFluid 的子类实例
另外这个类里还有一段静态块,很关键
1 2 3 4 5 6 7
| static { for (Fluid fluid : BuiltInRegistries.FLUID) { for (FluidState state : fluid.getStateDefinition().getPossibleStates()) { Fluid.FLUID_STATE_REGISTRY.add(state); } } }
|
它把每个流体状态的实例登记进一张 id 表,这张表负责客户端和服务端之间的同步
WaterFluid
现在我们再来看看水的流体类
内部类 Flowing 和 Source
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| public static class Flowing extends WaterFluid { @Override protected void createFluidStateDefinition(final StateDefinition.Builder<Fluid, FluidState> builder) { super.createFluidStateDefinition(builder); builder.add(LEVEL); }
@Override public int getAmount(final FluidState fluidState) { return fluidState.getValue(LEVEL); }
@Override public boolean isSource(final FluidState fluidState) { return false; } }
public static class Source extends WaterFluid { @Override public int getAmount(final FluidState fluidState) { return 8; }
@Override public boolean isSource(final FluidState fluidState) { return true; } }
|
Source是源头,液面高度是恒定的8(不过它并不是和其他方块一样的满方块,会比一般的方块低一点)
Flowing则是流动的流体,随着流动的距离增加,LEVEL属性的值也会逐渐降低,这里还有一个createFluidStateDefinition把LEVEL加进去
isSource指当前流体是否为源,getAmount指当前流体的液面高度,范围是 0 到 8
WaterFluid 剩下的方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| @Override public int getSlopeFindDistance(final LevelReader level) { return 4; }
@Override public int getDropOff(final LevelReader level) { return 1; }
@Override public int getTickDelay(final LevelReader level) { return 5; }
@Override protected float getExplosionResistance() { return 100.0F; }
@Override public BlockState createLegacyBlock(final FluidState fluidState) { return Blocks.WATER.defaultBlockState().setValue(LiquidBlock.LEVEL, getLegacyLevel(fluidState)); }
|
getSlopeFindDistance:找斜坡的间隔,数字越大流得越远才下降
getDropOff:每流一格,液面掉多少
getTickDelay:多久更新一次,水是5 tick;岩浆还会根据维度不同而变化
getExplosionResistance:抗爆属性,水和岩浆都是100,原版的TNT并不能破坏它
createLegacyBlock:把流体状态换成对应的方块状态,注意那句 getLegacyLevel(fluidState),它是父类给的现成方法,负责把「几成满」换算成 LiquidBlock 的 LEVEL
动手写
SewageFluid
新建一个抽象类,继承 FlowingFluid
1 2
| public abstract class SewageFluid extends FlowingFluid { }
|
先写三个常规的方法,返回两个不同类型的流体和桶装流体(当然我们这里还没注册它们)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Override public Fluid getFlowing() { return ModFluids.FLOWING_SEWAGE; }
@Override public Fluid getSource() { return ModFluids.SEWAGE; }
@Override public Item getBucket() { return ModItems.SEWAGE_BUCKET; }
|
接着是流动参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Override public int getSlopeFindDistance(LevelReader level) { return 2; }
@Override public int getDropOff(LevelReader level) { return 1; }
@Override public int getTickDelay(LevelReader level) { return 10; }
|
如果你想要稠得像沥青,把getTickDelay调到 30 都行
然后几个必写的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| @Override protected boolean canConvertToSource(ServerLevel level) { return false; }
@Override protected void beforeDestroyingBlock(LevelAccessor level, BlockPos pos, BlockState state) { BlockEntity blockEntity = state.hasBlockEntity() ? level.getBlockEntity(pos) : null; Block.dropResources(state, level, pos, blockEntity); }
@Override protected float getExplosionResistance() { return 100.0F; }
@Override public boolean isSame(Fluid other) { return other == ModFluids.SEWAGE || other == ModFluids.FLOWING_SEWAGE; }
@Override public BlockState createLegacyBlock(FluidState fluidState) { return ModBlocks.SEWAGE_BLOCK.defaultBlockState() .setValue(LiquidBlock.LEVEL, getLegacyLevel(fluidState)); }
@Override protected boolean canBeReplacedWith(FluidState state, BlockGetter level, BlockPos pos, Fluid other, Direction direction) { return direction == Direction.DOWN && !other.isSame(this); }
@Override public Optional<SoundEvent> getPickupSound() { return Optional.of(SoundEvents.BUCKET_FILL); }
|
canConvertToSource方法是流动的流体是否可以转换为源流体,更简单来说就是流体是否无限;原版的水就是无限的;
beforeDestroyingBlock是流体冲掉草这类可被破坏方块时的掉落逻辑,抄原版就行
createLegacyBlock把流体状态映射到我们自己的方块,这是最关键的一句,漏了流体就没法存在
最后是两个内部类,和原版水一样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| public static class Source extends SewageFluid { @Override public int getAmount(FluidState fluidState) { return 8; }
@Override public boolean isSource(FluidState fluidState) { return true; } }
public static class Flowing extends SewageFluid { @Override protected void createFluidStateDefinition(StateDefinition.Builder<Fluid, FluidState> builder) { super.createFluidStateDefinition(builder); builder.add(LEVEL); }
@Override public int getAmount(FluidState fluidState) { return fluidState.getValue(LEVEL); }
@Override public boolean isSource(FluidState fluidState) { return false; } }
|
注册流体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public class ModFluids { public static final FlowingFluid SEWAGE = register("sewage", new SewageFluid.Source()); public static final FlowingFluid FLOWING_SEWAGE = register("flowing_sewage", new SewageFluid.Flowing());
private static <T extends Fluid> T register(String name, T fluid) { Identifier id = Identifier.fromNamespaceAndPath(TutorialMod.MOD_ID, name); Registry.register(BuiltInRegistries.FLUID, id, fluid); for (FluidState state : fluid.getStateDefinition().getPossibleStates()) { Fluid.FLUID_STATE_REGISTRY.add(state); } return fluid; }
public static void register() { TutorialMod.LOGGER.info("Registering Mod Fluids for " + TutorialMod.MOD_ID); } }
|
前面说的那段「登记流体状态」,我直接塞进register方法里了
注册完立刻把自己那些状态登记进去,一个方法搞定
流体多了怎么办
一个案例还好,如果你一个模组里有十种流体,二十行字段摆在那里,也还是有点啰嗦
那就再往上抽一层,把「源 + 流动」两个流体打包注册
1 2 3 4 5 6 7 8 9
| public record FluidPair(FlowingFluid source, FlowingFluid flowing) { }
public static final FluidPair SEWAGE = registerPair("sewage", new SewageFluid.Source(), new SewageFluid.Flowing());
private static FluidPair registerPair(String name, FlowingFluid source, FlowingFluid flowing) { return new FluidPair(register(name, source), register("flowing_" + name, flowing)); }
|
这样一来,一种流体只占一行,流动那个的注册名也统一成了 flowing_xxx,不会写漏
用的时候就是 ModFluids.SEWAGE.source() 和 ModFluids.SEWAGE.flowing()
getSource() 和 getFlowing() 里跟着改一下就行
这期只有一个流体,我没上这层封装,多一层 record 反而让新手看不明白
你自己写模组的时候,流体一多就用得上
最后在主类里调一下 ModFluids.register()
流体方块
普通的流体方块直接实例化LiquidBlock注册就行,但我们要加伤害,所以得写个子类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public class SewageLiquidBlock extends LiquidBlock { public SewageLiquidBlock(FlowingFluid fluid, Properties properties) { super(fluid, properties); }
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity, InsideBlockEffectApplier effectApplier, boolean isPrecise) { super.entityInside(state, level, pos, entity, effectApplier, isPrecise); if (level instanceof ServerLevel serverLevel && entity instanceof LivingEntity living && living.tickCount % 20 == 0) { living.hurtServer(serverLevel, level.damageSources().magic(), 2.0F); } } }
|
entityInside方法的名字显而易见,就是实体在该方块内时调用
tickCount % 20 == 0是每秒扣一次血,tickCount是实体自带的总计时器,掉落物隔一段时间自动消失同样也是靠这个计时器
伤害用的是hurtServer
level.damageSources().magic()是伤害类型
注册方块和桶
1 2
| public static final Block SEWAGE_BLOCK = register("sewage", p -> new SewageLiquidBlock(ModFluids.SEWAGE, p), BlockBehaviour.Properties.ofFullCopy(Blocks.WATER), false);
|
第三个参数是 false,意思是不注册方块物品
液体方块是没有「方块物品」的,你手上那个是桶,所以我们不需要它
属性直接抄原版的水,ofFullCopy(Blocks.WATER) 一行拿到「无碰撞、可替换、液体、不可破坏」这一整套
桶在这里
1 2
| public static final Item SEWAGE_BUCKET = registerItem("sewage_bucket", p -> new BucketItem(ModFluids.SEWAGE, p.stacksTo(1).craftRemainder(Items.BUCKET)));
|
记得加物品栏
1
| output.accept(ModItems.SEWAGE_BUCKET);
|
客户端渲染
流体和普通方块不一样,它的模型不是json里写的,而是代码注册的
不过在26.1里面,写法和之前的1.21有些不同了
BlockRenderLayerMap跟着渲染层重做一起被删了,SimpleFluidRenderHandler 也没了
现在用的是 FluidRenderingRegistry,注册的是一个「流体模型」
1 2 3 4 5 6
| FluidRenderingRegistry.register(ModFluids.SEWAGE, ModFluids.FLOWING_SEWAGE, new FluidModel.Unbaked( new Material(Identifier.withDefaultNamespace("block/water_still")), new Material(Identifier.withDefaultNamespace("block/water_flow")), new Material(Identifier.withDefaultNamespace("block/water_overlay")), BlockTintSources.constant(0xFF2F4F4F)));
|
FluidModel.Unbaked 四个参数:静止贴图、流动贴图、覆盖贴图、着色
前三个我们全借原版水的,第四个 BlockTintSources.constant(0xFF2F4F4F) 是把它染成灰绿色
原版水用的是 BlockTintSources.water(),按生物群系变色;我们给它一个常数就行
这段写在 TutorialModClient 的 onInitializeClient 里
数据生成
语言文件
1 2
| translationBuilder.add(ModBlocks.SEWAGE_BLOCK, "Sewage"); translationBuilder.add(ModItems.SEWAGE_BUCKET, "Sewage Bucket");
|
中文那份
1 2
| translationBuilder.add(ModBlocks.SEWAGE_BLOCK, "污水"); translationBuilder.add(ModItems.SEWAGE_BUCKET, "污水桶");
|
桶的物品模型
1
| itemModelGenerators.generateFlatItem(ModItems.SEWAGE_BUCKET, ModelTemplates.FLAT_ITEM);
|
普通物品模型,贴图放 textures/item/sewage_bucket.png
方块的模型
流体方块不需要真正的模型,只要一个粒子贴图就够了
数据生成里我们只写方块状态
1
| blockModelGenerators.createNonTemplateModelBlock(ModBlocks.SEWAGE_BLOCK);
|
模型文件自己手写,放 assets/tutorial/models/block/sewage.json
1 2 3 4 5
| { "textures": { "particle": "minecraft:block/water_still" } }
|
流体标签
这一步最容易漏,漏了你的流体就只是看起来像流体,没有水下视角、游泳等特殊功能
路径是 data/minecraft/tags/fluid/water.json,注意这里是单数fluid
不过这里我们用数据生成写,毕竟自己写出错概率还是大,尤其是你有大量流体要添加时
1 2 3 4 5 6 7 8 9 10 11 12
| public class ModFluidTagsProvider extends FabricTagsProvider.FluidTagsProvider { public ModFluidTagsProvider(FabricPackOutput output, CompletableFuture<HolderLookup.Provider> registriesFuture) { super(output, registriesFuture); }
@Override protected void addTags(HolderLookup.Provider provider) { valueLookupBuilder(FluidTags.WATER) .add(ModFluids.SEWAGE) .add(ModFluids.FLOWING_SEWAGE); } }
|
然后在 TutorialModDataGenerator 里挂上 pack.addProvider(ModFluidTagsProvider::new);
进 #minecraft:water 之后,它才会游泳、才有水下视角、才会把火浇灭(是的,这是附带效果)
replace 默认是 false,所以不会把原版的水顶掉,只是往那个标签里追加
测试
跑完数据生成后,就进游戏进行测试吧
常见问题(大肥鱼总结版)
- 液体是紫黑方块:客户端渲染没注册,或者
FluidRenderingRegistry 那段的贴图路径写错
- 液体有颜色但是透明的、看不见流动:
FluidModel.Unbaked 里的流动贴图没给,或者给了不存在的路径
- 倒出来是一层薄薄的、不会流动的东西:
createLegacyBlock 没写或者写错,流体状态没映射到方块
- 液体不会向四周扩散:
getSlopeFindDistance 和 getDropOff 写反了,或者 getTickDelay 太大
- 液体是无限源:
canConvertToSource 返回了 true(原版水是靠 gamerule 控制的)
- 倒下去不显示,
F3 里也没有:register 里漏了那段登记状态的循环,状态没同步
- 泡在里面不掉血:
entityInside 没写,或者判断写成了客户端(level instanceof ServerLevel 写反)
- 掉血一秒掉二十次:
tickCount % 20 忘了写
- 桶舀不起来:
getBucket() 返回的不是桶物品,或者桶的 BucketItem 里传的流体不是源流体
- 桶用一次就没了:
craftRemainder(Items.BUCKET) 漏了
- 方块能挖掉、能拿在手上:注册时第三个参数给了
true,液体不该注册方块物品
- 游戏里飘紫黑粒子:方块模型的
particle 指到了 minecraft:block/water,改成 water_still