本篇教程的视频

本篇教程的源代码

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