Forge 能量系统简述(二)
这一讲我们将达成两个目标:
- 制造一个作为用电器的机器方块,且当实体生物站在该方块上时耗费能量为实体回血。
- 使电池在右键方块时可以将自己的能量转移到特定方块,按住 Shift 右键则反过来。
添加方块
我们先编写一个最最基础的方块类,并为其指定材料、硬度、和爆炸抗性,同时为对应的物品指定创造模式物品栏:
@Mod.EventBusSubscriber(bus = Mod.EventBusSubscriber.Bus.MOD)
public class FEDemoMachineBlock extends Block
{
public static final String NAME = "fedemo:machine";
@ObjectHolder(NAME)
public static FEDemoMachineBlock BLOCK;
@SubscribeEvent
public static void onRegisterBlock(@Nonnull RegistryEvent.Register<Block> event)
{
FEDemo.LOGGER.info("Registering machine block ...");
event.getRegistry().register(new FEDemoMachineBlock().setRegistryName(NAME));
}
@SubscribeEvent
public static void onRegisterItem(@Nonnull RegistryEvent.Register<Item> event)
{
FEDemo.LOGGER.info("Registering machine item ...");
event.getRegistry().register(new BlockItem(BLOCK, new Item.Properties().group(ItemGroup.MISC)).setRegistryName(NAME));
}
private FEDemoMachineBlock()
{
super(Block.Properties.create(Material.IRON).hardnessAndResistance(3));
}
}
这里使用了 ObjectHolder
注解来使 Forge 自动注入对应的方块类型的实例。注意该注解的参数正是方块的注册名。
然后我们添加语言文件:
"block.fedemo.machine": "FE Heal Machine"
以及同名方块状态 JSON 文件(machine.json
):
{
"variants": {
"": {
"model": "fedemo:block/machine"
}
}
}
该 JSON 文件指向同名材质描述文件。
我们创建 machine.json
文件,该文件的上一级目录名应为 block
:
{
"parent": "block/cube_bottom_top",
"textures": {
"bottom": "block/furnace_top",
"top": "fedemo:block/machine_top",
"side": "fedemo:block/energy_side"
}
}
该文件复用了熔炉的 JSON 材质,并引用了两张额外的材质(machine_top.png
和 energy_side.png
)。
在添加这两张材质的同时,我们不要忘了让 item
目录下的同名文件(machine.json
)引用该 JSON:
{
"parent": "fedemo:block/machine"
}
现在打开游戏。如一切顺利,方块和对应物品均应正常显示:
为方块添加方块实体
如果想要让方块存储复杂的数据,执行复杂的行为,方块实体(TileEntity
)是必不可少的。更重要的一点是,TileEntity
本身实现了 ICapabilityProvider
接口,因此如果我们想要声明一个方块拥有能量,我们必须为该方块指定方块实体。
添加 TileEntity
前必须首先添加 TileEntityType
。和方块物品等类似,TileEntityType
本身也有注册事件,因此我们要监听这一事件并将 TileEntityType
的实例注册进去:
@Mod.EventBusSubscriber(bus = Mod.EventBusSubscriber.Bus.MOD)
public class FEDemoMachineTileEntity extends TileEntity
{
public static final String NAME = "fedemo:machine";
@ObjectHolder(NAME)
public static TileEntityType<FEDemoMachineTileEntity> TILE_ENTITY_TYPE;
@SubscribeEvent
public static void onRegisterTileEntityType(@Nonnull RegistryEvent.Register<TileEntityType<?>> event)
{
FEDemo.LOGGER.info("Registering machine tile entity type ...");
event.getRegistry().register(TileEntityType.Builder.create(FEDemoMachineTileEntity::new, FEDemoMachineBlock.BLOCK).build(DSL.remainderType()).setRegistryName(NAME));
}
private FEDemoMachineTileEntity()
{
super(TILE_ENTITY_TYPE);
}
}
除去注册名外,构造一个 TileEntityType
一共需要不少于三个参数:
create
方法的第一个参数代表方块实体的构造器,而后续参数代表能够和方块实体相容的方块类型(由于是变长参数,因此可传入多个),这里直接传入对应方块就好了。build
方法的唯一参数代表方块实体 NBT 类型。该类型由 Mojang 官方的 DataFixer(com.mojang.datafixers
)定义,这里直接取DSL.remainderType()
(代表未知类型)即可。
最后我们需要在方块类中声明方块和方块实体的关联,为此我们需要覆盖 Block
类的 hasTileEntity
和 createTileEntity
方法:
@Override
public boolean hasTileEntity(@Nonnull BlockState state)
{
return true;
}
@Override
public TileEntity createTileEntity(@Nonnull BlockState state, @Nonnull IBlockReader world)
{
return FEDemoMachineTileEntity.TILE_ENTITY_TYPE.create();
}
为方块实体添加 Capability
由于每个方块实体都分别对应一个 TileEntity
的实例,因此我们可以将数据直接以字段的方式存放在 TileEntity
中。唯一不同的是,为了让我们的数据能够映射到 NBT,我们需要同时覆盖 TileEntity
的 read
和 write
两个方法:
private int energy = 0;
@Override
public void read(@Nonnull CompoundNBT compound)
{
this.energy = compound.getInt("MachineEnergy");
super.read(compound);
}
@Nonnull
@Override
public CompoundNBT write(@Nonnull CompoundNBT compound)
{
compound.putInt("MachineEnergy", this.energy);
return super.write(compound);
}
read
和 write
两个方法反映的分别是方块实体的反序列化和序列化两个过程。一个 TileEntity
通过这两个方法实现了和 NBT 复合标签的映射。
现在我们来实现 getCapability
方法。在上面的内容中我们提到过,TileEntity
本身实现了 ICapabilityProvider
接口,因此我们只需覆盖这一方法即可:
private LazyOptional<IEnergyStorage> lazyOptional; // TODO
@Nonnull
@Override
public <T> LazyOptional<T> getCapability(@Nonnull Capability<T> cap, Direction side)
{
boolean isEnergy = Objects.equals(cap, CapabilityEnergy.ENERGY) && side.getAxis().isHorizontal();
return isEnergy ? this.lazyOptional.cast() : super.getCapability(cap, side);
}
注意相较物品,我们的 getCapability
方法在判断时额外判定了传入的是否为水平朝向(东南西北)。通过这种方法我们可以设定输入输出能量相较朝向的限制,在这里我们直接禁止了能量在上下两个朝向的交互。
然后我们构造 LazyOptional<IEnergyStorage>
的实例:
private final LazyOptional<IEnergyStorage> lazyOptional = LazyOptional.of(() -> new IEnergyStorage()
{
@Override
public int receiveEnergy(int maxReceive, boolean simulate)
{
int energy = this.getEnergyStored();
int diff = Math.min(this.getMaxEnergyStored() - energy, maxReceive);
if (!simulate)
{
FEDemoMachineTileEntity.this.energy += diff;
if (diff != 0)
{
FEDemoMachineTileEntity.this.markDirty();
}
}
return diff;
}
@Override
public int extractEnergy(int maxExtract, boolean simulate)
{
return 0;
}
@Override
public int getEnergyStored()
{
return Math.max(0, Math.min(this.getMaxEnergyStored(), FEDemoMachineTileEntity.this.energy));
}
@Override
public int getMaxEnergyStored()
{
return 192_000;
}
@Override
public boolean canExtract()
{
return false;
}
@Override
public boolean canReceive()
{
return true;
}
});
和基于物品的实现,基于方块实体的实现有以下几点不同:
- 直接通过修改
energy
字段调整能量。 getMaxEnergyStored
返回的是最大存储能量,这里设置为192000
。- 由于是作为用电器的机器,所以能量是只进不出的。注意
canExtract
和extractEnergy
两个方法的返回值。 - 注意
markDirty
的使用。该方法会将相应区块标记为需要保存,虽然如果不标记,游戏通常也会保存,但我们强烈建议这么做。
为方块实现具体功能
为了更方便地调整方块实体的能量,我们为方块实体类添加一个 heal
方法用于回血,一次回复 0.1 点(约一秒一颗心):
public void heal(@Nonnull LivingEntity entity)
{
int diff = Math.min(this.energy, 100);
if (diff > 0)
{
entity.heal((float) diff / 1_000);
this.energy -= diff;
this.markDirty();
}
}
若想判断实体是否接触了方块,我们需要利用方块的 onEntityCollision
方法。原版 Minecraft 会在实体进入方块所处区域时触发该方法,我们覆盖 Block
类的这一方法即可:
@Override
@SuppressWarnings("deprecation")
public void onEntityCollision(@Nonnull BlockState state, @Nonnull World world, @Nonnull BlockPos pos, @Nonnull Entity entity)
{
if (!world.isRemote && entity instanceof LivingEntity)
{
LivingEntity livingEntity = (LivingEntity) entity;
if (livingEntity.getHealth() < livingEntity.getMaxHealth())
{
TileEntity tileEntity = world.getTileEntity(pos);
if (tileEntity instanceof FEDemoMachineTileEntity)
{
((FEDemoMachineTileEntity) tileEntity).heal(livingEntity);
}
}
}
}
在上面的方法里我们主要检查了四件事,如果四件事均满足我们便调用方块实体类的 heal
方法:
- 世界处于逻辑服务端(使用
!world.isRemote
判断)。 - 实体属于实体生物这一范畴(使用
entity instanceof LivingEntity
判断)。 - 实体生物并未满血(使用
livingEntity.getHealth() < livingEntity.getMaxHealth()
判断)。 - 对应的方块实体是我们所期望的(使用
tileEntity instanceof FEDemoMachineTileEntity
判断)。
最后,为了让我们的实体进入方块所处区域,我们需要重新定义碰撞箱,不能让碰撞箱占满整个方块:
@Nonnull
@Override
@SuppressWarnings("deprecation")
public VoxelShape getCollisionShape(@Nonnull BlockState state, @Nonnull IBlockReader world, @Nonnull BlockPos pos, @Nonnull ISelectionContext context)
{
return Block.makeCuboidShape(0, 0, 0, 16, 15, 16);
}
代码很简单,只是让高度也就是 Y 轴从 16 变成了 15 而已,X 轴和 Z 轴方向都没有变。
为物品实现具体功能
现在进入到这一讲的最后一步,也就是实现电池右键方块的行为。原版 Minecraft 会在物品右键方块时调用 Item
类的 onItemUse
方法,因此我们可以通过覆盖这一方法实现相应行为:
@Nonnull
@Override
public ActionResultType onItemUse(@Nonnull ItemUseContext context)
{
World world = context.getWorld();
if (!world.isRemote)
{
TileEntity tileEntity = world.getTileEntity(context.getPos());
if (tileEntity != null)
{
Direction side = context.getFace();
tileEntity.getCapability(CapabilityEnergy.ENERGY, side).ifPresent(e ->
{
this.transferEnergy(context, e);
this.notifyPlayer(context, e);
});
}
}
return ActionResultType.SUCCESS;
}
private void notifyPlayer(@Nonnull ItemUseContext context, @Nonnull IEnergyStorage target)
{
PlayerEntity player = context.getPlayer();
if (player != null)
{
String msg = target.getEnergyStored() + " FE / " + target.getMaxEnergyStored() + " FE";
player.sendMessage(new StringTextComponent(msg).applyTextStyle(TextFormatting.GRAY));
}
}
private void transferEnergy(@Nonnull ItemUseContext context, @Nonnull IEnergyStorage target)
{
// TODO
}
- 首先我们进行了必要的逻辑服务端检查,以及方块实体本身的检查。
- 然后我们通过
getCapability
方法获取方块实体的能量相关信息。 - 紧接着我们调用了
transferEnergy
方法,该方法将完成能量的传输。 - 最后我们调用了
notifyPlayer
方法,通知右键方块的玩家能量相关信息。
我们现在实现 transferEnergy
方法:
private void transferEnergy(@Nonnull ItemUseContext context, @Nonnull IEnergyStorage target)
{
context.getItem().getCapability(CapabilityEnergy.ENERGY).ifPresent(e ->
{
if (context.isPlacerSneaking())
{
if (target.canExtract())
{
int diff = e.getMaxEnergyStored() - e.getEnergyStored();
e.receiveEnergy(target.extractEnergy(diff, false), false);
}
}
else
{
if (target.canReceive())
{
int diff = e.getEnergyStored();
e.extractEnergy(target.receiveEnergy(diff, false), false);
}
}
});
}
我们获取了物品本身对应的 IEnergyStorage
后,判断玩家是否按下 Shift。
接下来进入到了两个分支。我们先从第一个分支,也就是玩家按下 Shift 取出能量开始看:
if (target.canExtract())
{
int diff = e.getMaxEnergyStored() - e.getEnergyStored();
e.receiveEnergy(target.extractEnergy(diff, false), false);
}
一个重要的问题是取出多少能量。很明显,为了达成“能取多少取多少”的目标,我们需要划定一个可以承受的上限,这个上限自然是电池还可以容纳的能量。我们计算出数值后存放到 diff
变量下,然后我们调用方块实体的 extractEnergy
方法以及和物品相关的 receiveEnergy
方法就可以了。
现在我们来看第二个分支,也就是玩家不按下 Shift 存入能量:
if (target.canReceive())
{
int diff = e.getEnergyStored();
e.extractEnergy(target.receiveEnergy(diff, false), false);
}
整段实现和取出能量类似,但具体上仍有细微的差别。除了存取能量的身份对调外,如果我们想贯彻“能存多少存多少”的目标,我们需要把上限划定为 e.getEnergyStored()
。
以下是打开游戏后的显示结果。
代码清单
这一部分添加的文件有:
src/main/java/com/github/ustc_zzzz/fedemo/block/FEDemoMachineBlock.java
src/main/java/com/github/ustc_zzzz/fedemo/tileentity/FEDemoMachineTileEntity.java
src/main/resources/assets/fedemo/blockstates/machine.json
src/main/resources/assets/fedemo/models/block/machine.json
src/main/resources/assets/fedemo/models/item/machine.json
src/main/resources/assets/fedemo/textures/block/energy_side.png
src/main/resources/assets/fedemo/textures/block/machine_top.png
这一部分修改的文件有:
src/main/java/com/github/ustc_zzzz/fedemo/item/FEDemoBatteryItem.java
src/main/resources/assets/fedemo/lang/en_us.json