本篇教程的视频

本篇教程的源代码

GitHub地址:

本篇教程目标

  • 搞清「双格方块」是怎么回事
  • 学会继承原版 BedBlock 做自己的床
  • 学会用 getRenderShape 让一份两格长的模型只画一次
  • 学会给双格方块写战利品表,只掉一个

介绍

前面几期我们做的方块都只占一格

沙发虽然能连成一排,但每一段自己占一格

这一期做床,它一个方块就占两格

这类方块在游戏里不少:门、床、高花、双箱子,都是「放下去变成两个方块」的东西

原版的床是两个方块拼起来的,脚一半、头一半,各自有一份模型

我们的床是 Blockbench 做的一整张,所以只能换一个思路:让其中一半负责画模型,另一半什么都不画

为什么必须继承 BedBlock

床不只是个模型,它还牵扯到睡觉、重生点、下界爆炸这些逻辑

而游戏里判断「这是不是床」的方式很粗暴:blockState.getBlock() instanceof BedBlock(在LivingEntity的startSleeping方法)

你要是不继承它,玩家右键就睡不上去,除非写 Mixin 去改原版的判断

所以这期的做法是:继承 BedBlock,把模型相关的部分改成自己的

查看源代码

两个属性

1
2
public static final EnumProperty<BedPart> PART = BlockStateProperties.BED_PART;
public static final BooleanProperty OCCUPIED = BlockStateProperties.OCCUPIED;

PART 是「脚」还是「头」,只有 foot 和 head 两个值

OCCUPIED 是这张床有没有被占用,主要是村民晚上会自己爬上去睡

放下去为什么变成两格

1
2
3
4
5
6
7
8
@Override
public void setPlacedBy(final Level level, final BlockPos pos, final BlockState state, @Nullable final LivingEntity by, final ItemStack itemStack) {
super.setPlacedBy(level, pos, state, by, itemStack);
if (!level.isClientSide()) {
BlockPos otherPos = pos.relative(state.getValue(FACING));
level.setBlock(otherPos, state.setValue(PART, BedPart.HEAD), 3);
}
}

这一段是BedBlock已有的逻辑

逻辑是:你放下的那一格是脚,然后在 facing 方向上再放一格头

所以facing指的是「从脚指向头」的方向

顺带一提,getStateForPlacement 里也判断了「头那一格有没有位置」,没位置就返回 null,床就放不下去

两半怎么绑在一起

1
2
3
4
5
6
7
8
9
10
@Override
protected BlockState updateShape(...) {
if (directionToNeighbour == getNeighbourDirection(state.getValue(PART), state.getValue(FACING))) {
return neighbourState.is(this) && neighbourState.getValue(PART) != state.getValue(PART)
? state.setValue(OCCUPIED, neighbourState.getValue(OCCUPIED))
: Blocks.AIR.defaultBlockState();
} else {
return super.updateShape(...);
}
}

这个是更新方块的逻辑,它保证了床如果有一部分被移除,另一部分也会跟着消失

右键床

1
2
@Override
protected InteractionResult useWithoutItem(BlockState state, final Level level, BlockPos pos, final Player player, final BlockHitResult hitResult) {

这个方法原版写得挺长,我们不用改,但值得看一眼它做了什么

先是判断当前点的是不是头,不是的话换成头那一格

然后判断这个维度能不能睡床(下界和末地会直接炸)

再判断床有没有被占,被村民占了就把村民踹下去,被别的玩家占了就提示「这张床已被占用」

最后才是 player.startSleepInBed(pos),睡觉、设重生点都在里面

在床上蹦

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void fallOn(final Level level, final BlockState state, final BlockPos pos, final Entity entity, final double fallDistance) {
super.fallOn(level, state, pos, entity, fallDistance * 0.5);
}

@Override
public void updateEntityAfterFallOn(final BlockGetter level, final Entity entity) {
if (entity.isSuppressingBounce()) {
super.updateEntityAfterFallOn(level, entity);
} else {
this.bounceUp(entity);
}
}

摔在床上一半伤害,而且会弹起来

方块实体

26.1 的 BedBlock 还实现了一个 EntityBlock,newBlockEntity 里返回的是 BedBlockEntity,里面只存了一个 DyeColor

这个东西不是摆设,它暴露了 26.1 原版床的渲染方式:方块状态文件是空的

1
2
3
4
5
6
7
{
"variants": {
"": {
"model": "minecraft:block/bed"
}
}
}

而 minecraft:block/bed 这个模型本身也是空的,只剩一个 particle

真正那张床是 BedRenderer(方块实体渲染器)画出来的,它从 BedBlockEntity 里读颜色,决定画哪一张床

所以原版床压根不需要 part、facing 的方块状态,朝向和形状都是渲染器自己算的

我们的思路反过来:不搞渲染器,就用方块模型

那就得把 newBlockEntity 重写成 null,让原版那套渲染器没有东西可读,再用 getRenderShape 把模型画出来

NeoForge 1.21.1 那期的教程说要「注册方块实体」,其实就是被这套东西绊了一下;26.1 里返回 null 更省事

动手写

ModBedBlock

1
2
3
4
5
6
7
8
9
10
11
12
public class ModBedBlock extends BedBlock {
public static final VoxelShape SHAPE = Block.box(0, 0, 0, 16, 8, 16);

public ModBedBlock(DyeColor color, Properties properties) {
super(color, properties);
}

@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
return SHAPE;
}
}

构造函数要传一个 DyeColor,这是 BedBlock 规定的

随便给一个就行,因为我们不用原版那套按颜色换模型的逻辑,给 ORANGE 意思一下

碰撞箱给 8 像素高,和原版床一样高

只画一半

1
2
3
4
@Override
protected RenderShape getRenderShape(BlockState state) {
return state.getValue(PART) == BedPart.HEAD ? RenderShape.MODEL : RenderShape.INVISIBLE;
}

getRenderShape 决定「这个方块要不要画模型」

MODEL 是按方块模型正常渲染,INVISIBLE 是不画

所以头那一格画整张床,脚那一格什么都不画,看起来就是一张完整的床

注意这一步是必须的:原版床的几何是 BedRenderer 画的,我们不给它方块实体,就没人画了,只能靠方块模型顶上

这里有个前提:模型必须是「床头在原点、往 +Z 方向长两格」

我们这个模型正好是这样:床头板在 z 从 0 到 2,枕头在 2 到 7,床尾在 30 到 32

因为 facing=north 的时候,脚那一格正好在头的南边(+Z),模型不用转就能盖住两格

你自己建模型的时候如果反过来了,把床头放在 z=32 那一端,那就得把下面数据生成里的 NOP 和 Y_ROT_180 换个位置

不要方块实体

1
2
3
4
5
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return null;
}

直接返回 null,游戏就不会给它创建方块实体

注册

1
2
public static final Block BED = register("bed", p -> new ModBedBlock(DyeColor.ORANGE, p),
BlockBehaviour.Properties.of().strength(2.0f, 2.0f).noOcclusion(), true);

noOcclusion 别忘,床不是满方块

顺手加物品栏和语言

1
2
3
4
output.accept(ModBlocks.BED);

translationBuilder.add(ModBlocks.BED, "Bed");
translationBuilder.add(ModBlocks.BED, "床");

数据生成

战利品表

床是两格,如果两格都掉,拆一张床能捡到两个,那就亏了

所以要加条件,只让头那一格掉

1
add(ModBlocks.BED, createSinglePropConditionTable(ModBlocks.BED, BedBlock.PART, BedPart.HEAD));

createSinglePropConditionTable 是原版数据生成里的现成方法,意思是「只有某个属性等于某个值时,才掉这个东西」

它其实就是给战利品表塞一个 block_state_property 条件,和门的写法一样

方块状态

床的方块状态有两个属性:part 和 facing,一共 8 种

虽然我们只有一份模型,但两个 part 在方块状态文件里都得列出,不然缺状态

现成的 createBed 用不了

BlockModelGenerators 里其实有个 createBed,名字看着就是给床准备的

1
2
3
4
5
6
7
8
9
10
11
12
public final void createBed(final Block bed, final Block itemParticle, final DyeColor dyeColor) {
MultiVariant blockModel = plainVariant(ModelLocationUtils.decorateBlockModelLocation("bed"));
this.blockStateOutput.accept(createSimpleBlock(bed, blockModel));

Item bedItem = bed.asItem();
Identifier baseModel = ModelTemplates.BED_INVENTORY
.create(ModelLocationUtils.getModelLocation(bedItem), TextureMapping.particle(itemParticle), this.modelOutput);
Transformation headTransformation = BedRenderer.modelTransform(Direction.SOUTH);
ItemModel.Unbaked headPart = ItemModelUtils.specialModel(baseModel, headTransformation, new BedSpecialRenderer.Unbaked(dyeColor, BedPart.HEAD));
...
this.itemModelOutput.accept(bedItem, ItemModelUtils.composite(headPart, footPart));
}

原版那 16 张床就是用它生成的,但我们用不了,三个原因

  • decorateBlockModelLocation("bed") 返回的就是 minecraft:block/bed,它是个加了 @Deprecated 的老方法,固定用 minecraft 命名空间,没法传自己的模型
  • createSimpleBlock(bed, blockModel) 生成的就是 {"variants": {"": {...}}},既没有 part 也没有 facing 的分发
  • 物品模型走的是BedSpecialRenderer,它按DyeColor画原版床,手里没有颜色就画不出来,我们的模型插不进去

所以这个方法只适合「就是原版那张床、只是换个颜色」的情况

我们要把自己 Blockbench 做的模型塞进去,还是得自己拼 MultiVariantGenerator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void generateBedBlock(BlockModelGenerators blockModelGenerators, Block block) {
Identifier model = modLoc("block/bed");
blockModelGenerators.blockStateOutput.accept(
MultiVariantGenerator.dispatch(block)
.with(PropertyDispatch.initial(BedBlock.PART)
.select(BedPart.FOOT, BlockModelGenerators.plainVariant(model))
.select(BedPart.HEAD, BlockModelGenerators.plainVariant(model)))
.with(PropertyDispatch.modify(BlockStateProperties.HORIZONTAL_FACING)
.select(Direction.NORTH, BlockModelGenerators.NOP)
.select(Direction.EAST, BlockModelGenerators.Y_ROT_90)
.select(Direction.SOUTH, BlockModelGenerators.Y_ROT_180)
.select(Direction.WEST, BlockModelGenerators.Y_ROT_270))
);
blockModelGenerators.registerSimpleItemModel(block, model);
}

和沙发那期一个套路,两层 with

第一层按 PART 选模型,两半都指向同一份

第二层按 FACING 套旋转

模型和贴图

1
2
assets/tutorial/models/block/bed.json
assets/tutorial/textures/block/bed.png

模型是两格长的,注意别只做一格

测试

跑完数据生成就可以在游戏里测试了

常见问题(大肥鱼总结版)

  • 一张床显示成两格重复的模型:getRenderShape 没写
  • 床显示出来了,但错开一格:模型的床头没放在 z=0 那一端,或者方块状态的 y 旋转对不上
  • 只显示一半床,另一半是空的:getRenderShape 里 HEAD 和 FOOT 写反了
  • 拆一半另一半还在:updateShape 被你自己覆盖掉了,记得保留原版的判断
  • 拆一张床掉两个:战利品表没加 part=head 条件
  • 睡不上去,右键没反应:没有继承 BedBlock,或者 getStateForPlacement 那一格没空间
  • 下界放床就炸:这是原版逻辑,不是 bug
  • 日志里刷「block entity not valid」:newBlockEntity 没返回 null,游戏创建了原版的 BedBlockEntity
  • 床的碰撞箱太高:SHAPE 给的是 8 像素,别按整格写
  • 物品栏里的床是拉长的一坨:模型 display 里的 gui 没调,Blockbench 里重新导一次
  • 想用 createBed 一行搞定,结果模型变成原版床:这个方法把模型路径写死成 minecraft:block/bed,自定义模型用不上,还是得自己拼