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.pngenergy_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 类的 hasTileEntitycreateTileEntity 方法:

@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,我们需要同时覆盖 TileEntityreadwrite 两个方法:

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);
}

readwrite 两个方法反映的分别是方块实体的反序列化和序列化两个过程。一个 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
  • 由于是作为用电器的机器,所以能量是只进不出的。注意 canExtractextractEnergy 两个方法的返回值。
  • 注意 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