土球球
本帖最后由 土球球 于 2020-9-6 04:03 编辑

Forge 能量系统简述

这些内容本来是期望成为某本实体书的一部分的,但是一方面编辑在催稿,另一方面再往里塞东西就超字数了(超字数会影响书价,进而影响销量),因此没能最终塞进去。最近我把这些内容整理出来,并顺道把版本从 1.12.2 升级到了 1.14.4(毕竟 Forge 目前声称 1.14.4 是 LTS 来着),在这里分享给大家。

全系列教程分为五讲(看上面的目录),大概涵盖了从物品到机器再到导线等所有和能量有关的东西,希望能够作为各位读者的参考吧。

本文同时在本人的博客发布:


以下是本文相关源代码:

FEDemo.zip (82.51 KB, 下载次数: 309)


2021.12 数据,可能有更多内容
Forge 能量系统简述


这些内容本来是期望成为某本实体书的一部分的,但是一方面编辑在催稿,另一方面再往里塞东西就超字数了(超字数会影响书价,进而影响销量),因此没能最终塞进去。最近我把这些内容整理出来,并顺道把版本从 1.12.2 升级到了 1.14.4(毕竟 Forge 目前声称 1.14.4 是 LTS 来着),在这里分享给大家。


全系列教程分为五讲(看上面的目录),大概涵盖了从物品到机器再到导线等所有和能量有关的东西,希望能够作为各位读者的参考吧。


本文同时在本人的博客发布:



以下是本文相关源代码:








虽然夹杂着众多争议,但 Forge 最终仍然决定在 1.10.2 加入官方的能量系统,并一直将其延续到现在。该系统参考了 CoFH 团队的众多设计,因此和在此前已经拥有鼎鼎大名的 Redstone Flux 能量系统有着众多的相似之处。


在本系列教程中,各位读者将走入 Forge 能量系统所带来的奇妙世界。由于本文将使用 Minecraft 1.14.4 和 Forge 28.2.4 进行讲解,因此如果读者想要顺畅阅读本教程,那么有一些要求是需要满足的:


  • 已能够相对熟练地使用 Java 8 编写代码和设计程序。
  • 已能够基于 Minecraft 1.14.4 和 Forge 添加简单的方块或物品。

废话不多说,我们开始吧。


准备工作


我们决定将 ModID 起名为 fedemo,以下是 META-INF/mods.toml 文件:


代码:

  1. modLoader="javafml"
  2. loaderVersion="[28,)"

  3. [[mods]]
  4. modId="fedemo"
  5. version="${file.jarVersion}"
  6. displayName="FE Demonstration Mod"
  7. description="Demonstration for Forge Energy"

  8. [[dependencies.fedemo]]
  9. modId="forge"
  10. mandatory=true
  11. versionRange="[28.2,)"
  12. ordering="NONE"
  13. side="BOTH"

  14. [[dependencies.fedemo]]
  15. modId="minecraft"
  16. mandatory=true
  17. versionRange="[1.14.4]"
  18. ordering="NONE"
  19. side="BOTH"

以下是主类,非常简洁:


代码:

  1. @Mod("fedemo")
  2. public class FEDemo
  3. {
  4.     public static final Logger LOGGER = LogManager.getLogger(FEDemo.class);
  5. }

本系列教程的所有 Java 代码均在 com.github.ustc_zzzz.fedemo 包下。


Capability 系统


Capability 系统是 Forge 能量系统的基石。


Capability 系统对原版游戏元素和第三方行为(大多数情况下和 Mod 有关)实施了一定程度的解耦合。具体来说,Mod 开发者可通过调用 getCapability 方法获取并操纵特定的第三方行为。getCapability 方法由 ICapabilityProvider 接口声明,而 Forge 为很多游戏元素都实现了这一接口,比如我们耳熟能详的物品堆叠(ItemStack)、实体(Entity)、方块实体(TileEntity)等。


CapabilityDispatcher 是一类特殊的 ICapabilityProvider,因为它可以存有多个 ICapabilityProvider。刚才我们提到的物品堆叠、实体、方块实体等游戏元素,内部都存在一个由 Forge 提供的 CapabilityDispatcher,这使得我们向已有的游戏元素添加 ICapabilityProvider 成为可能。


我们来看 getCapability 方法的声明:


代码:

  1. @Nonnull <T> LazyOptional<T> getCapability(@Nonnull Capability<T> cap, Direction side);

getCapability 方法的第一个参数代表的是特定的 Capability,我们可以通过 CapabilityEnergy.ENERGY 来拿到它,从而为实现 Forge 能量系统铺路;getCapability 方法的第二个参数代表一个方向,在和方块实体打交道的时候我们用得着。


为物品添加 Capability


我们决定制作一个存储 FE 的电池。我们先编写一个最最基础的物品类,并为其指定创造模式物品栏和最大物品数量:


代码:

  1. @Mod.EventBusSubscriber(bus = Mod.EventBusSubscriber.Bus.MOD)
  2. public class FEDemoBatteryItem extends Item
  3. {
  4.     public static final String NAME = "fedemo:battery";

  5.     @SubscribeEvent
  6.     public static void onRegisterItem(@Nonnull RegistryEvent.Register<Item> event)
  7.     {
  8.   FEDemo.LOGGER.info("Registering battery item ...");
  9.   event.getRegistry().register(new FEDemoBatteryItem().setRegistryName(NAME));
  10.     }

  11.     private FEDemoBatteryItem()
  12.     {
  13.   super(new Item.Properties().maxStackSize(1).group(ItemGroup.MISC));
  14.     }
  15. }

Forge 为 Item 额外追加了 initCapabilities 方法,这个方法的返回值是 ICapabilityProvider,我们需要覆盖这个方法:


代码:

  1. @Override
  2. public ICapabilityProvider initCapabilities(@Nonnull ItemStack stack, CompoundNBT nbt)
  3. {
  4.     return new ICapabilityProvider()
  5.     {
  6.   private LazyOptional<IEnergyStorage> lazyOptional; // TODO

  7.   @Nonnull
  8.   @Override
  9.   public <T> LazyOptional<T> getCapability(@Nonnull Capability<T> cap, Direction side)
  10.   {
  11.    boolean isEnergy = Objects.equals(cap, CapabilityEnergy.ENERGY);
  12.    return isEnergy ? this.lazyOptional.cast() : LazyOptional.empty();
  13.   }
  14.     };
  15. }

我们注意到了 LazyOptional 的存在。LazyOptional 类由 Forge 提供,本质和 Java 的 Optional 类似,不过其内部的实例只在用到的时候才会加载。我们在参数为 CapabilityEnergy.ENERGY 的时候返回一个预先准备好的 LazyOptional,否则便返回一个 LazyOptional.empty()


CapabilityEnergy.ENERGY 的类型是 Capability&lt;IEnergyStorage&gt;,因此我们要实现的也是 IEnergyStorage


物品能量的具体实现


ItemStack 存储三种数据:物品类型、数量、和 NBT。很明显,我们的物品能量只能放在 NBT 里。


我们会用到以下五个方法操纵 NBT:


  • hasTag:检查一个 ItemStack 是否拥有 NBT。
  • getTag:返回一个 ItemStack 的 NBT,如果不存在则返回 null
  • getOrCreateTag:返回一个 ItemStack 的 NBT,如果不存在则为其创建一个。
  • putInt:设置 NBT 复合标签的特定整数值。
  • getInt:获取 NBT 复合标签的特定整数值,如果值不存在或者不是整数则返回 0

很好。以下是具体实现:


代码:

  1. private final LazyOptional<IEnergyStorage> lazyOptional = LazyOptional.of(() -> new IEnergyStorage()
  2. {
  3.     @Override
  4.     public int receiveEnergy(int maxReceive, boolean simulate)
  5.     {
  6.   int energy = this.getEnergyStored();
  7.   int diff = Math.min(this.getMaxEnergyStored() - energy, maxReceive);
  8.   if (!simulate)
  9.   {
  10.    stack.getOrCreateTag().putInt("BatteryEnergy", energy + diff);
  11.   }
  12.   return diff;
  13.     }

  14.     @Override
  15.     public int extractEnergy(int maxExtract, boolean simulate)
  16.     {
  17.   int energy = this.getEnergyStored();
  18.   int diff = Math.min(energy, maxExtract);
  19.   if (!simulate)
  20.   {
  21.    stack.getOrCreateTag().putInt("BatteryEnergy", energy - diff);
  22.   }
  23.   return diff;
  24.     }

  25.     @Override
  26.     public int getEnergyStored()
  27.     {
  28.   if (stack.hasTag())
  29.   {
  30.    int energy = Objects.requireNonNull(stack.getTag()).getInt("BatteryEnergy");
  31.    return Math.max(0, Math.min(this.getMaxEnergyStored(), energy));
  32.   }
  33.   return 0;
  34.     }

  35.     @Override
  36.     public int getMaxEnergyStored()
  37.     {
  38.   return 48_000;
  39.     }

  40.     @Override
  41.     public boolean canExtract()
  42.     {
  43.   return true;
  44.     }

  45.     @Override
  46.     public boolean canReceive()
  47.     {
  48.   return true;
  49.     }
  50. });

代码有点复杂,我们一个方法一个方法拆开看。


  • canReceive 代表是否能输入能量,这里我们让它返回 true
  • canExtract 代表是否能输出能量,这里我们也让它返回 true
  • getMaxEnergyStored 代表内部能够存储的最大能量,这里我们设定在 48000
  • getEnergyStored 代表内部存储的实际能量,这里我们通过物品的 NBT 读取能量。
  • extractEnergy 代表实施输出能量的行为,其中 simulate 参数代表是否为模拟行为。
  • receiveEnergy 代表实施输入能量的行为,其中 simulate 参数代表是否为模拟行为。

extractEnergyreceiveEnergy 各自均接收一个 int 作为参数,并生成 int 作为返回值。其中,作为参数传入的 int 代表期望输入输出的能量,而作为返回值的 int 代表实际输入输出的能量。这两个参数都非常重要,希望读者能够加以注意。


杂项


我们可以向 en_us.json 这一语言文件里添加一项:


代码:

  1. "item.fedemo.battery": "FE Battery"

我们还可以通过覆盖 addInformation 方法添加额外的提示文本:


代码:

  1. @Override
  2. @OnlyIn(Dist.CLIENT)
  3. public void addInformation(@Nonnull ItemStack stack, World world, @Nonnull List<ITextComponent> tooltip, @Nonnull ITooltipFlag flag)
  4. {
  5.     stack.getCapability(CapabilityEnergy.ENERGY).ifPresent(e ->
  6.     {
  7.   String msg = e.getEnergyStored() + " FE / " + e.getMaxEnergyStored() + " FE";
  8.   tooltip.add(new StringTextComponent(msg).applyTextStyle(TextFormatting.GRAY));
  9.     });
  10. }

我们也可以通过覆盖 fillItemGroup 方法为创造模式物品栏添加多个物品,分别对应 0 FE,12000 FE,24000 FE,36000 FE,和 48000 FE:


代码:

  1. @Override
  2. public void fillItemGroup(@Nonnull ItemGroup group, @Nonnull NonNullList<ItemStack> items)
  3. {
  4.     if (this.isInGroup(group))
  5.     {
  6.   IntStream.rangeClosed(0, 4).forEach(i ->
  7.   {
  8.    ItemStack stack = new ItemStack(this);
  9.    stack.getCapability(CapabilityEnergy.ENERGY).ifPresent(e ->
  10.    {
  11.     int energy = e.getMaxEnergyStored() / 4 * i;
  12.     e.receiveEnergy(energy, false);
  13.     items.add(stack);
  14.    });
  15.   });
  16.     }
  17. }

到目前为止,我们已经解决了除材质外的所有问题了。


材质


我们为电池绘制了五种材质,分别对应电量空到电量满等五种情况,也恰好对应创造模式物品栏的五个物品:





默认情况自然是电量空,那我们怎么映射剩下的四种情况呢?原版 Minecraft 为我们提供了 Item Property Override 机制,该机制使得根据 NBT 动态调整材质成为可能。


欲使用 Item Property Override,我们只需在构造方法中添加下面一句:


代码:

  1. this.addPropertyOverride(new ResourceLocation("energy"), (stack, world, entity) ->
  2. {
  3.     LazyOptional<IEnergyStorage> lazyOptional = stack.getCapability(CapabilityEnergy.ENERGY);
  4.     return lazyOptional.map(e -> (float) e.getEnergyStored() / e.getMaxEnergyStored()).orElse(0.0F);
  5. });

这样我们的物品就有了一个名为 energy 的属性,我们在描述材质的 JSON 文件(应为 battery.json)写下:


代码:

  1. {
  2. "parent": "item/generated",
  3. "textures": {
  4.     "layer0": "fedemo:item/battery"
  5. },
  6. "overrides": [
  7.     {
  8.    "predicate": {
  9.   "energy": 0.125
  10.    },
  11.    "model": "fedemo:item/battery1"
  12.     },
  13.     {
  14.    "predicate": {
  15.   "energy": 0.375
  16.    },
  17.    "model": "fedemo:item/battery2"
  18.     },
  19.     {
  20.    "predicate": {
  21.   "energy": 0.625
  22.    },
  23.    "model": "fedemo:item/battery3"
  24.     },
  25.     {
  26.    "predicate": {
  27.   "energy": 0.875
  28.    },
  29.    "model": "fedemo:item/battery4"
  30.     }
  31. ]
  32. }

注意和普通 JSON 相比,该文件额外多出了 override 部分,其中 predicate 判定的是当前属性值是否不小于提供的值,因此我们在该 JSON 中将 energy 划分为了五档,从而应对五种可能的情况。


接下来我们只需要完善 battery1.jsonbattery4.json 就可以了。以下是 battery4.json 的全部内容:


代码:

  1. {
  2. "parent": "item/generated",
  3. "textures": {
  4.     "layer0": "fedemo:item/battery4"
  5. }
  6. }

以下是打开游戏后的显示结果。





代码清单


这一部分添加的文件有:


  • src/main/java/com/github/ustc_zzzz/fedemo/FEDemo.java
  • src/main/java/com/github/ustc_zzzz/fedemo/item/FEDemoBatteryItem.java
  • src/main/resources/pack.mcmeta
  • src/main/resources/META-INF/mods.toml
  • src/main/resources/assets/fedemo/lang/en_us.json
  • src/main/resources/assets/fedemo/models/item/battery.json
  • src/main/resources/assets/fedemo/models/item/battery1.json
  • src/main/resources/assets/fedemo/models/item/battery2.json
  • src/main/resources/assets/fedemo/models/item/battery3.json
  • src/main/resources/assets/fedemo/models/item/battery4.json
  • src/main/resources/assets/fedemo/textures/item/battery.png
  • src/main/resources/assets/fedemo/textures/item/battery1.png
  • src/main/resources/assets/fedemo/textures/item/battery2.png
  • src/main/resources/assets/fedemo/textures/item/battery3.png
  • src/main/resources/assets/fedemo/textures/item/battery4.png






这一讲我们将达成两个目标:


  • 制造一个作为用电器的机器方块,且当实体生物站在该方块上时耗费能量为实体回血。
  • 使电池在右键方块时可以将自己的能量转移到特定方块,按住 Shift 右键则反过来。

添加方块


我们先编写一个最最基础的方块类,并为其指定材料、硬度、和爆炸抗性,同时为对应的物品指定创造模式物品栏:


代码:

  1. @Mod.EventBusSubscriber(bus = Mod.EventBusSubscriber.Bus.MOD)
  2. public class FEDemoMachineBlock extends Block
  3. {
  4.     public static final String NAME = "fedemo:machine";

  5.     @ObjectHolder(NAME)
  6.     public static FEDemoMachineBlock BLOCK;

  7.     @SubscribeEvent
  8.     public static void onRegisterBlock(@Nonnull RegistryEvent.Register<Block> event)
  9.     {
  10.   FEDemo.LOGGER.info("Registering machine block ...");
  11.   event.getRegistry().register(new FEDemoMachineBlock().setRegistryName(NAME));
  12.     }

  13.     @SubscribeEvent
  14.     public static void onRegisterItem(@Nonnull RegistryEvent.Register<Item> event)
  15.     {
  16.   FEDemo.LOGGER.info("Registering machine item ...");
  17.   event.getRegistry().register(new BlockItem(BLOCK, new Item.Properties().group(ItemGroup.MISC)).setRegistryName(NAME));
  18.     }

  19.     private FEDemoMachineBlock()
  20.     {
  21.   super(Block.Properties.create(Material.IRON).hardnessAndResistance(3));
  22.     }
  23. }

这里使用了 ObjectHolder 注解来使 Forge 自动注入对应的方块类型的实例。注意该注解的参数正是方块的注册名。


然后我们添加语言文件:


代码:

  1. "block.fedemo.machine": "FE Heal Machine"

以及同名方块状态 JSON 文件(machine.json):


代码:

  1. {
  2. "variants": {
  3.     "": {
  4.    "model": "fedemo:block/machine"
  5.     }
  6. }
  7. }

该 JSON 文件指向同名材质描述文件。


我们创建 machine.json 文件,该文件的上一级目录名应为 block


代码:

  1. {
  2. "parent": "block/cube_bottom_top",
  3. "textures": {
  4.     "bottom": "block/furnace_top",
  5.     "top": "fedemo:block/machine_top",
  6.     "side": "fedemo:block/energy_side"
  7. }
  8. }

该文件复用了熔炉的 JSON 材质,并引用了两张额外的材质(machine_top.pngenergy_side.png)。


在添加这两张材质的同时,我们不要忘了让 item 目录下的同名文件(machine.json)引用该 JSON:


代码:

  1. {
  2. "parent": "fedemo:block/machine"
  3. }

现在打开游戏。如一切顺利,方块和对应物品均应正常显示:





为方块添加方块实体


如果想要让方块存储复杂的数据,执行复杂的行为,方块实体(TileEntity)是必不可少的。更重要的一点是,TileEntity 本身实现了 ICapabilityProvider 接口,因此如果我们想要声明一个方块拥有能量,我们必须为该方块指定方块实体。


添加 TileEntity 前必须首先添加 TileEntityType。和方块物品等类似,TileEntityType 本身也有注册事件,因此我们要监听这一事件并将 TileEntityType 的实例注册进去:


代码:

  1. @Mod.EventBusSubscriber(bus = Mod.EventBusSubscriber.Bus.MOD)
  2. public class FEDemoMachineTileEntity extends TileEntity
  3. {
  4.     public static final String NAME = "fedemo:machine";

  5.     @ObjectHolder(NAME)
  6.     public static TileEntityType<FEDemoMachineTileEntity> TILE_ENTITY_TYPE;

  7.     @SubscribeEvent
  8.     public static void onRegisterTileEntityType(@Nonnull RegistryEvent.Register<TileEntityType<?>> event)
  9.     {
  10.   FEDemo.LOGGER.info("Registering machine tile entity type ...");
  11.   event.getRegistry().register(TileEntityType.Builder.create(FEDemoMachineTileEntity::new, FEDemoMachineBlock.BLOCK).build(DSL.remainderType()).setRegistryName(NAME));
  12.     }

  13.     private FEDemoMachineTileEntity()
  14.     {
  15.   super(TILE_ENTITY_TYPE);
  16.     }
  17. }

除去注册名外,构造一个 TileEntityType 一共需要不少于三个参数:


  • create 方法的第一个参数代表方块实体的构造器,而后续参数代表能够和方块实体相容的方块类型(由于是变长参数,因此可传入多个),这里直接传入对应方块就好了。
  • build 方法的唯一参数代表方块实体 NBT 类型。该类型由 Mojang 官方的 DataFixer(com.mojang.datafixers)定义,这里直接取 DSL.remainderType()(代表未知类型)即可。

最后我们需要在方块类中声明方块和方块实体的关联,为此我们需要覆盖 Block 类的 hasTileEntitycreateTileEntity 方法:


代码:

  1. @Override
  2. public boolean hasTileEntity(@Nonnull BlockState state)
  3. {
  4.     return true;
  5. }

  6. @Override
  7. public TileEntity createTileEntity(@Nonnull BlockState state, @Nonnull IBlockReader world)
  8. {
  9.     return FEDemoMachineTileEntity.TILE_ENTITY_TYPE.create();
  10. }

为方块实体添加 Capability


由于每个方块实体都分别对应一个 TileEntity 的实例,因此我们可以将数据直接以字段的方式存放在 TileEntity 中。唯一不同的是,为了让我们的数据能够映射到 NBT,我们需要同时覆盖 TileEntityreadwrite 两个方法:


代码:

  1. private int energy = 0;

  2. @Override
  3. public void read(@Nonnull CompoundNBT compound)
  4. {
  5.     this.energy = compound.getInt("MachineEnergy");
  6.     super.read(compound);
  7. }

  8. @Nonnull
  9. @Override
  10. public CompoundNBT write(@Nonnull CompoundNBT compound)
  11. {
  12.     compound.putInt("MachineEnergy", this.energy);
  13.     return super.write(compound);
  14. }

readwrite 两个方法反映的分别是方块实体的反序列化和序列化两个过程。一个 TileEntity 通过这两个方法实现了和 NBT 复合标签的映射。


现在我们来实现 getCapability 方法。在上面的内容中我们提到过,TileEntity 本身实现了 ICapabilityProvider 接口,因此我们只需覆盖这一方法即可:


代码:

  1. private LazyOptional<IEnergyStorage> lazyOptional; // TODO

  2. @Nonnull
  3. @Override
  4. public <T> LazyOptional<T> getCapability(@Nonnull Capability<T> cap, Direction side)
  5. {
  6.     boolean isEnergy = Objects.equals(cap, CapabilityEnergy.ENERGY) && side.getAxis().isHorizontal();
  7.     return isEnergy ? this.lazyOptional.cast() : super.getCapability(cap, side);
  8. }

注意相较物品,我们的 getCapability 方法在判断时额外判定了传入的是否为水平朝向(东南西北)。通过这种方法我们可以设定输入输出能量相较朝向的限制,在这里我们直接禁止了能量在上下两个朝向的交互。


然后我们构造 LazyOptional&lt;IEnergyStorage&gt; 的实例:


代码:

  1. private final LazyOptional<IEnergyStorage> lazyOptional = LazyOptional.of(() -> new IEnergyStorage()
  2. {
  3.     @Override
  4.     public int receiveEnergy(int maxReceive, boolean simulate)
  5.     {
  6.   int energy = this.getEnergyStored();
  7.   int diff = Math.min(this.getMaxEnergyStored() - energy, maxReceive);
  8.   if (!simulate)
  9.   {
  10.    FEDemoMachineTileEntity.this.energy += diff;
  11.    if (diff != 0)
  12.    {
  13.     FEDemoMachineTileEntity.this.markDirty();
  14.    }
  15.   }
  16.   return diff;
  17.     }

  18.     @Override
  19.     public int extractEnergy(int maxExtract, boolean simulate)
  20.     {
  21.   return 0;
  22.     }

  23.     @Override
  24.     public int getEnergyStored()
  25.     {
  26.   return Math.max(0, Math.min(this.getMaxEnergyStored(), FEDemoMachineTileEntity.this.energy));
  27.     }

  28.     @Override
  29.     public int getMaxEnergyStored()
  30.     {
  31.   return 192_000;
  32.     }

  33.     @Override
  34.     public boolean canExtract()
  35.     {
  36.   return false;
  37.     }

  38.     @Override
  39.     public boolean canReceive()
  40.     {
  41.   return true;
  42.     }
  43. });

和基于物品的实现,基于方块实体的实现有以下几点不同:


  • 直接通过修改 energy 字段调整能量。
  • getMaxEnergyStored 返回的是最大存储能量,这里设置为 192000
  • 由于是作为用电器的机器,所以能量是只进不出的。注意 canExtractextractEnergy 两个方法的返回值。
  • 注意 markDirty 方法的使用。该方法将方块实体所处区块标记为需要保存,虽然如果不标记,游戏大概率也会保存,但我们强烈建议这么做。

为方块实现具体功能


为了更方便地调整方块实体的能量,我们为方块实体类添加一个 heal 方法用于回血,一次回复 0.1 点(约一秒一颗心):


代码:

  1. public void heal(@Nonnull LivingEntity entity)
  2. {
  3.     int diff = Math.min(this.energy, 100);
  4.     if (diff > 0)
  5.     {
  6.   entity.heal((float) diff / 1_000);
  7.   this.energy -= diff;
  8.   this.markDirty();
  9.     }
  10. }

若想判断实体是否接触了方块,我们需要利用方块的 onEntityCollision 方法。原版 Minecraft 会在实体进入方块所处区域时触发该方法,我们覆盖 Block 类的这一方法即可:


代码:

  1. @Override
  2. @SuppressWarnings("deprecation")
  3. public void onEntityCollision(@Nonnull BlockState state, @Nonnull World world, @Nonnull BlockPos pos, @Nonnull Entity entity)
  4. {
  5.     if (!world.isRemote && entity instanceof LivingEntity)
  6.     {
  7.   LivingEntity livingEntity = (LivingEntity) entity;
  8.   if (livingEntity.getHealth() < livingEntity.getMaxHealth())
  9.   {
  10.    TileEntity tileEntity = world.getTileEntity(pos);
  11.    if (tileEntity instanceof FEDemoMachineTileEntity)
  12.    {
  13.     ((FEDemoMachineTileEntity) tileEntity).heal(livingEntity);
  14.    }
  15.   }
  16.     }
  17. }

在上面的方法里我们主要检查了四件事,如果四件事均满足我们便调用方块实体类的 heal 方法:


  • 世界处于逻辑服务端(使用 !world.isRemote 判断)。
  • 实体属于实体生物这一范畴(使用 entity instanceof LivingEntity 判断)。
  • 实体生物并未满血(使用 livingEntity.getHealth() &lt; livingEntity.getMaxHealth() 判断)。
  • 对应的方块实体是我们所期望的(使用 tileEntity instanceof FEDemoMachineTileEntity 判断)。

最后,为了让我们的实体进入方块所处区域,我们需要重新定义碰撞箱,不能让碰撞箱占满整个方块:


代码:

  1. @Nonnull
  2. @Override
  3. @SuppressWarnings("deprecation")
  4. public VoxelShape getCollisionShape(@Nonnull BlockState state, @Nonnull IBlockReader world, @Nonnull BlockPos pos, @Nonnull ISelectionContext context)
  5. {
  6.     return Block.makeCuboidShape(0, 0, 0, 16, 15, 16);
  7. }

代码很简单,只是让高度也就是 Y 轴从 16 变成了 15 而已,X 轴和 Z 轴方向都没有变。


为物品实现具体功能


现在进入到这一讲的最后一步,也就是实现电池右键方块的行为。原版 Minecraft 会在物品右键方块时调用 Item 类的 onItemUse 方法,因此我们可以通过覆盖这一方法实现相应行为:


代码:

  1. @Nonnull
  2. @Override
  3. public ActionResultType onItemUse(@Nonnull ItemUseContext context)
  4. {
  5.     World world = context.getWorld();
  6.     if (!world.isRemote)
  7.     {
  8.   TileEntity tileEntity = world.getTileEntity(context.getPos());
  9.   if (tileEntity != null)
  10.   {
  11.    Direction side = context.getFace();
  12.    tileEntity.getCapability(CapabilityEnergy.ENERGY, side).ifPresent(e ->
  13.    {
  14.     this.transferEnergy(context, e);
  15.     this.notifyPlayer(context, e);
  16.    });
  17.   }
  18.     }
  19.     return ActionResultType.SUCCESS;
  20. }

  21. private void notifyPlayer(@Nonnull ItemUseContext context, @Nonnull IEnergyStorage target)
  22. {
  23.     PlayerEntity player = context.getPlayer();
  24.     if (player != null)
  25.     {
  26.   String msg = target.getEnergyStored() + " FE / " + target.getMaxEnergyStored() + " FE";
  27.   player.sendMessage(new StringTextComponent(msg).applyTextStyle(TextFormatting.GRAY));
  28.     }
  29. }

  30. private void transferEnergy(@Nonnull ItemUseContext context, @Nonnull IEnergyStorage target)
  31. {
  32.     // TODO
  33. }

  • 首先我们进行了必要的逻辑服务端检查,以及方块实体本身的检查。
  • 然后我们通过 getCapability 方法获取方块实体的能量相关信息。
  • 紧接着我们调用了 transferEnergy 方法,该方法将完成能量的传输。
  • 最后我们调用了 notifyPlayer 方法,通知右键方块的玩家能量相关信息。

我们现在实现 transferEnergy 方法:


代码:

  1. private void transferEnergy(@Nonnull ItemUseContext context, @Nonnull IEnergyStorage target)
  2. {
  3.     context.getItem().getCapability(CapabilityEnergy.ENERGY).ifPresent(e ->
  4.     {
  5.   if (context.isPlacerSneaking())
  6.   {
  7.    if (target.canExtract())
  8.    {
  9.     int diff = e.getMaxEnergyStored() - e.getEnergyStored();
  10.     e.receiveEnergy(target.extractEnergy(diff, false), false);
  11.    }
  12.   }
  13.   else
  14.   {
  15.    if (target.canReceive())
  16.    {
  17.     int diff = e.getEnergyStored();
  18.     e.extractEnergy(target.receiveEnergy(diff, false), false);
  19.    }
  20.   }
  21.     });
  22. }

我们获取了物品本身对应的 IEnergyStorage 后,判断玩家是否按下 Shift。


接下来进入到了两个分支。我们先从第一个分支,也就是玩家按下 Shift 取出能量开始看:


代码:

  1. if (target.canExtract())
  2. {
  3.     int diff = e.getMaxEnergyStored() - e.getEnergyStored();
  4.     e.receiveEnergy(target.extractEnergy(diff, false), false);
  5. }

一个重要的问题是取出多少能量。很明显,为了达成“能取多少取多少”的目标,我们需要划定一个可以承受的上限,这个上限自然是电池还可以容纳的能量。我们计算出数值后存放到 diff 变量下,然后我们调用方块实体的 extractEnergy 方法以及和物品相关的 receiveEnergy 方法就可以了。


现在我们来看第二个分支,也就是玩家不按下 Shift 存入能量:


代码:

  1. if (target.canReceive())
  2. {
  3.     int diff = e.getEnergyStored();
  4.     e.extractEnergy(target.receiveEnergy(diff, false), false);
  5. }

整段实现和取出能量类似,但具体上仍有细微的差别。除了存取能量的身份对调外,如果我们想贯彻“能存多少存多少”的目标,我们需要把上限划定为 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






在这一讲我们将制造一个作为发电机的机器方块:


  • 该方块收集太阳能作为能量来源。
  • 该方块能够向周围方块输出能量。

添加方块和方块实体


以下是方块类的基础实现:


代码:

  1. @Mod.EventBusSubscriber(bus = Mod.EventBusSubscriber.Bus.MOD)
  2. public class FEDemoGeneratorBlock extends Block
  3. {
  4.     public static final String NAME = "fedemo:generator";

  5.     @ObjectHolder(NAME)
  6.     public static FEDemoGeneratorBlock BLOCK;

  7.     @SubscribeEvent
  8.     public static void onRegisterBlock(@Nonnull RegistryEvent.Register<Block> event)
  9.     {
  10.   FEDemo.LOGGER.info("Registering generator block ...");
  11.   event.getRegistry().register(new FEDemoGeneratorBlock().setRegistryName(NAME));
  12.     }

  13.     @SubscribeEvent
  14.     public static void onRegisterItem(@Nonnull RegistryEvent.Register<Item> event)
  15.     {
  16.   FEDemo.LOGGER.info("Registering generator item ...");
  17.   event.getRegistry().register(new BlockItem(BLOCK, new Item.Properties().group(ItemGroup.MISC)).setRegistryName(NAME));
  18.     }

  19.     private FEDemoGeneratorBlock()
  20.     {
  21.   super(Block.Properties.create(Material.IRON).hardnessAndResistance(3));
  22.     }

  23.     @Override
  24.     public boolean hasTileEntity(@Nonnull BlockState state)
  25.     {
  26.   return true;
  27.     }

  28.     @Override
  29.     public TileEntity createTileEntity(@Nonnull BlockState state, @Nonnull IBlockReader world)
  30.     {
  31.   return FEDemoGeneratorTileEntity.TILE_ENTITY_TYPE.create();
  32.     }
  33. }

以下是方块实体类的基础实现:


代码:

  1. @Mod.EventBusSubscriber(bus = Mod.EventBusSubscriber.Bus.MOD)
  2. public class FEDemoGeneratorTileEntity extends TileEntity implements ITickableTileEntity
  3. {
  4.     public static final String NAME = "fedemo:generator";

  5.     @ObjectHolder(NAME)
  6.     public static TileEntityType<FEDemoGeneratorTileEntity> TILE_ENTITY_TYPE;

  7.     @SubscribeEvent
  8.     public static void onRegisterTileEntityType(@Nonnull RegistryEvent.Register<TileEntityType<?>> event)
  9.     {
  10.   FEDemo.LOGGER.info("Registering generator tile entity type ...");
  11.   event.getRegistry().register(TileEntityType.Builder.create(FEDemoGeneratorTileEntity::new, FEDemoGeneratorBlock.BLOCK).build(DSL.remainderType()).setRegistryName(NAME));
  12.     }

  13.     private FEDemoGeneratorTileEntity()
  14.     {
  15.   super(TILE_ENTITY_TYPE);
  16.     }
  17. }

方块和方块实体类的实现和上一讲针对用电器的实现大同小异。


然后我们指定方块状态 JSON(generator.json):


代码:

  1. {
  2. "variants": {
  3.     "": {
  4.    "model": "fedemo:block/generator"
  5.     }
  6. }
  7. }

接下来是描述方块材质的同名 JSON(generator.json):


代码:

  1. {
  2. "parent": "block/cube_bottom_top",
  3. "textures": {
  4.     "bottom": "block/furnace_top",
  5.     "top": "fedemo:block/generator_top",
  6.     "side": "fedemo:block/energy_side"
  7. }
  8. }

以及描述方块对应物品的同名 JSON(generator.json):


代码:

  1. {
  2. "parent": "fedemo:block/generator"
  3. }

相较上一讲,我们额外添加了 generator_top.png 作为发电机顶部的新材质。


最后我们补充语言文件(en_us.json):


代码:

  1. "block.fedemo.generator": "FE Energy Generator"

打开游戏就可以看到效果了:





为方块实体实现 Capability


我们仍然使用一个 int 字段存储方块实体的能量,并将其通过 readwrite 方法和 NBT 映射:


代码:

  1. private int energy = 0;

  2. @Override
  3. public void read(@Nonnull CompoundNBT compound)
  4. {
  5.     this.energy = compound.getInt("GeneratorEnergy");
  6.     super.read(compound);
  7. }

  8. @Nonnull
  9. @Override
  10. public CompoundNBT write(@Nonnull CompoundNBT compound)
  11. {
  12.     compound.putInt("GeneratorEnergy", this.energy);
  13.     return super.write(compound);
  14. }

然后我们基于此实现我们自己的 LazyOptional&lt;IEnergyStorage&gt; 和基于能量的 Capability 实现:


代码:

  1. private final LazyOptional<IEnergyStorage> lazyOptional = LazyOptional.of(() -> new IEnergyStorage()
  2. {
  3.     @Override
  4.     public int receiveEnergy(int maxReceive, boolean simulate)
  5.     {
  6.   return 0;
  7.     }

  8.     @Override
  9.     public int extractEnergy(int maxExtract, boolean simulate)
  10.     {
  11.   int energy = this.getEnergyStored();
  12.   int diff = Math.min(energy, maxExtract);
  13.   if (!simulate)
  14.   {
  15.    FEDemoGeneratorTileEntity.this.energy -= diff;
  16.    if (diff != 0)
  17.    {
  18.     FEDemoGeneratorTileEntity.this.markDirty();
  19.    }
  20.   }
  21.   return diff;
  22.     }

  23.     @Override
  24.     public int getEnergyStored()
  25.     {
  26.   return Math.max(0, Math.min(this.getMaxEnergyStored(), FEDemoGeneratorTileEntity.this.energy));
  27.     }

  28.     @Override
  29.     public int getMaxEnergyStored()
  30.     {
  31.   return 192_000;
  32.     }

  33.     @Override
  34.     public boolean canExtract()
  35.     {
  36.   return true;
  37.     }

  38.     @Override
  39.     public boolean canReceive()
  40.     {
  41.   return false;
  42.     }
  43. });

  44. @Nonnull
  45. @Override
  46. public <T> LazyOptional<T> getCapability(@Nonnull Capability<T> cap, Direction side)
  47. {
  48.     boolean isEnergy = Objects.equals(cap, CapabilityEnergy.ENERGY) && side.getAxis().isHorizontal();
  49.     return isEnergy ? this.lazyOptional.cast() : super.getCapability(cap, side);
  50. }

这里的实现和上一讲针对用电器的实现类似,唯一的不同之处在于:发电机的电量应该是只出不进的。注意 canReceivereceiveEnergy 两个方法的返回值。


为方块实体实现功能


我们既然希望方块收集太阳能,那我们自然是希望方块实体所存储的能量随时间递增。这需要我们让我们的方块实体每 tick 执行一段代码,原版 Minecraft 为我们提供了 ITickableTileEntity 接口。我们只需要让我们的类在继承 TileEntity 的同时实现这一接口即可:


代码:

  1. public class FEDemoGeneratorTileEntity extends TileEntity implements ITickableTileEntity
  2. {
  3.     // ...

  4.     @Override
  5.     public void tick()
  6.     {
  7.   if (this.world != null && !this.world.isRemote)
  8.   {
  9.    this.generateEnergy(this.world);
  10.    this.transferEnergy(this.world);
  11.   }
  12.     }

  13.     private void generateEnergy(@Nonnull World world)
  14.     {
  15.   // TODO
  16.     }

  17.     private void transferEnergy(@Nonnull World world)
  18.     {
  19.   // TODO
  20.     }

  21.     // ...
  22. }

我们先从 generateEnergy 方法的实现开始:


代码:

  1. private void generateEnergy(@Nonnull World world)
  2. {
  3.     if (world.getDimension().hasSkyLight())
  4.     {
  5.   int light = world.getLightFor(LightType.SKY, this.pos.up()) - world.getSkylightSubtracted();
  6.   int diff = Math.min(192_000 - this.energy, 10 * Math.max(0, light - 10));
  7.   if (diff != 0)
  8.   {
  9.    this.energy += diff;
  10.    this.markDirty();
  11.   }
  12.     }
  13. }

表达式 world.getLightFor(LightType.SKY, this.pos.up()) - world.getSkylightSubtracted() 返回的是当前方块上方的天空亮度值,不超过 15。它的下一行代码规定了亮度和能量的映射关系:亮度不超过 10 时不增加 FE,超过 10 后每增加 1 每 tick 相应增加 10 FE,亮度为 15 时为 50 FE。最后别忘了不要让能量值超过能够存储的最大值。


然后我们实现 transferEnergy 方法。


能量的主动输出


我们希望实现发电机和用电器相邻时传输能量的功能,但仅仅为两个机器实现能量相关的 Capability 是远远不够的:计算机程序不是物理定律,不会出现自然而然的能量流动,换言之,我们需要手写能量流动的相关代码。那么这段代码到底应该是“发电机主动输出能量”,还是“用电器主动吸收能量”呢?答案是显然的:我们应该让发电机控制能量的流动,因此,我们需要让我们的发电机对应的方块实体每 tick 自动搜寻附近的方块实体,并分别注入能量。


我们现在来实现 transferEnergy 方法:


代码:

  1. private final Queue<Direction> directionQueue = Queues.newArrayDeque(Direction.Plane.HORIZONTAL);

  2. private void transferEnergy(@Nonnull World world)
  3. {
  4.     this.directionQueue.offer(this.directionQueue.remove());
  5.     for (Direction direction : this.directionQueue)
  6.     {
  7.   TileEntity tileEntity = world.getTileEntity(this.pos.offset(direction));
  8.   if (tileEntity != null)
  9.   {
  10.    tileEntity.getCapability(CapabilityEnergy.ENERGY, direction.getOpposite()).ifPresent(e ->
  11.    {
  12.     if (e.canReceive())
  13.     {
  14.   int diff = e.receiveEnergy(Math.min(500, this.energy), false);
  15.   if (diff != 0)
  16.   {
  17.    this.energy -= diff;
  18.    this.markDirty();
  19.   }
  20.     }
  21.    });
  22.   }
  23.     }
  24. }

方法还是相对简单的:通过遍历水平方向的所有相邻方块,然后逐个注入能量,一次最多注入 500 FE。注意在获取相邻方块时,需要获取的是相反的方向(例如对于东侧的方块,注入能量时应该从该方块的西侧注入),也就是对 Direction 调用 getOpposite 方法并取其返回值。


唯一可能令人费解的是这一行:


代码:

  1. this.directionQueue.offer(this.directionQueue.remove());

通过 directionQueue 字段的声明我们可以注意到,我们把该队列的第一个元素取出放到了最后一个元素的位置,这是为什么呢?


我们思考一下如何不这么做会发生什么:


  • 首先找到北侧的方块并注入能量。
  • 然后找到东侧的方块并注入能量。
  • 接着找到南侧的方块并注入能量。
  • 最后找到西侧的方块并注入能量。

我们可以注意到,如果只是平凡地遍历,那么北侧的方块将永远拥有最大的优先级。如果我们每 tick 只能产出 50 FE 能量,但北侧的方块一次可以吸收 200 FE 的能量,那势必会导致能量会全部被北侧的方块吸走。因此,我们为了雨露均沾,必须每次注入能量时人为调整能量的优先级。当然了,可以考虑的实现有很多,这里读者可以尽情地发挥自己的想象力。


现在打开游戏,能量应能正常收集并传输了。





代码清单


这一部分添加的文件有:


  • src/main/java/com/github/ustc_zzzz/fedemo/block/FEDemoGeneratorBlock.java
  • src/main/java/com/github/ustc_zzzz/fedemo/tileentity/FEDemoGeneratorTileEntity.java
  • src/main/resources/assets/fedemo/blockstates/generator.json
  • src/main/resources/assets/fedemo/models/block/generator.json
  • src/main/resources/assets/fedemo/models/item/generator.json
  • src/main/resources/assets/fedemo/textures/block/generator_top.png

这一部分修改的文件有:


  • src/main/resources/assets/fedemo/lang/en_us.json






在这一讲和下一讲我们将制造一个作为导线的方块。


这一讲我们将从作为方块的导线着手(换言之只是一个空壳子),而下一讲我们将着重介绍作为能量传输载体的导线。


添加方块和方块实体


以下是方块类的基础实现:


代码:

  1. @Mod.EventBusSubscriber(bus = Mod.EventBusSubscriber.Bus.MOD)
  2. public class FEDemoWireBlock extends Block
  3. {
  4.     public static final String NAME = "fedemo:wire";

  5.     @ObjectHolder(NAME)
  6.     public static FEDemoWireBlock BLOCK;

  7.     @SubscribeEvent
  8.     public static void onRegisterBlock(@Nonnull RegistryEvent.Register<Block> event)
  9.     {
  10.   FEDemo.LOGGER.info("Registering wire block ...");
  11.   event.getRegistry().register(new FEDemoWireBlock().setRegistryName(NAME));
  12.     }

  13.     @SubscribeEvent
  14.     public static void onRegisterItem(@Nonnull RegistryEvent.Register<Item> event)
  15.     {
  16.   FEDemo.LOGGER.info("Registering wire item ...");
  17.   event.getRegistry().register(new BlockItem(BLOCK, new Item.Properties().group(ItemGroup.MISC)).setRegistryName(NAME));
  18.     }

  19.     private FEDemoWireBlock()
  20.     {
  21.   super(Block.Properties.create(Material.GLASS).hardnessAndResistance(2));
  22.     }

  23.     @Override
  24.     public boolean hasTileEntity(@Nonnull BlockState state)
  25.     {
  26.   return true;
  27.     }

  28.     @Override
  29.     public TileEntity createTileEntity(@Nonnull BlockState state, @Nonnull IBlockReader world)
  30.     {
  31.   return FEDemoWireTileEntity.TILE_ENTITY_TYPE.create();
  32.     }
  33. }

以下是方块实体类的基础实现:


代码:

  1. @Mod.EventBusSubscriber(bus = Mod.EventBusSubscriber.Bus.MOD)
  2. public class FEDemoWireTileEntity extends TileEntity
  3. {
  4.     public static final String NAME = "fedemo:wire";

  5.     @ObjectHolder(NAME)
  6.     public static TileEntityType<FEDemoWireTileEntity> TILE_ENTITY_TYPE;

  7.     @SubscribeEvent
  8.     public static void onRegisterTileEntityType(@Nonnull RegistryEvent.Register<TileEntityType<?>> event)
  9.     {
  10.   FEDemo.LOGGER.info("Registering wire tile entity type ...");
  11.   event.getRegistry().register(TileEntityType.Builder.create(FEDemoWireTileEntity::new, FEDemoWireBlock.BLOCK).build(DSL.remainderType()).setRegistryName(NAME));
  12.     }

  13.     private FEDemoWireTileEntity()
  14.     {
  15.   super(TILE_ENTITY_TYPE);
  16.     }
  17. }

我们还可以在 en_us,json 给方块起个名字:


代码:

  1. "block.fedemo.wire": "FE Energy Transmission Conduit"

本讲接下来将不会涉及到方块实体的任何内容(放到下一讲进行)。


方块状态


由于导线在和周围连通时的状态会随着周边环境有所不同,因此我们需要为同一个导线指定不同的方块状态(BlockState)。每个方块状态都是特定属性(Property)和对应值的结合,我们需要声明导线在六个方向的连接状态,因此我们需要共六个描述方块状态属性。这六个属性都可以在 BlockStateProperties 类里找到,我们为这六个属性建立一个针对方向的映射表:


代码:

  1. public static final Map<Direction, BooleanProperty> PROPERTY_MAP;

  2. static
  3. {
  4.     Map<Direction, BooleanProperty> map = Maps.newEnumMap(Direction.class);
  5.     map.put(Direction.NORTH, BlockStateProperties.NORTH);
  6.     map.put(Direction.EAST, BlockStateProperties.EAST);
  7.     map.put(Direction.SOUTH, BlockStateProperties.SOUTH);
  8.     map.put(Direction.WEST, BlockStateProperties.WEST);
  9.     map.put(Direction.UP, BlockStateProperties.UP);
  10.     map.put(Direction.DOWN, BlockStateProperties.DOWN);
  11.     PROPERTY_MAP = Collections.unmodifiableMap(map);
  12. }

接下来我们需要覆盖 fillStateContainer 方法,用来声明该方块拥有以上全部六个属性:


代码:

  1. @Override
  2. protected void fillStateContainer(@Nonnull StateContainer.Builder<Block, BlockState> builder)
  3. {
  4.     builder.add(PROPERTY_MAP.values().toArray(new IProperty<?>[0]));
  5.     super.fillStateContainer(builder);
  6. }

StateContainer.Builderadd 方法需要传入变长参数,因此这里直接构造并传入了一个数组。


接下来我们需要在特定场合自动调整方块状态,我们需要:


  • 在放置该方块时调整方块状态
  • 在该方块周围的方块发生变动时调整方块状态

前者对应 getStateForPlacement 方法,后者对应 updatePostPlacement 方法。我们覆盖这两个方法:


代码:

  1. @Override
  2. public BlockState getStateForPlacement(@Nonnull BlockItemUseContext context)
  3. {
  4.     BlockState state = this.getDefaultState();
  5.     for (Direction facing : Direction.values())
  6.     {
  7.   World world = context.getWorld();
  8.   BlockPos facingPos = context.getPos().offset(facing);
  9.   BlockState facingState = world.getBlockState(facingPos);
  10.   state = state.with(PROPERTY_MAP.get(facing), this.canConnect(world, facing.getOpposite(), facingPos, facingState));
  11.     }
  12.     return state;
  13. }

  14. @Nonnull
  15. @Override
  16. @SuppressWarnings("deprecation")
  17. public BlockState updatePostPlacement(@Nonnull BlockState state, @Nonnull Direction facing, @Nonnull BlockState facingState, @Nonnull IWorld world, @Nonnull BlockPos pos, @Nonnull BlockPos facingPos)
  18. {
  19.     return state.with(PROPERTY_MAP.get(facing), this.canConnect(world, facing.getOpposite(), facingPos, facingState));
  20. }

  21. private boolean canConnect(@Nonnull IWorld world, @Nonnull Direction facing, @Nonnull BlockPos pos, @Nonnull BlockState state)
  22. {
  23.     return false; // TODO
  24. }

前者我们需要对六个方向分别检查属性值,而后者我们只需要对受到影响的方向检查就可以了。


我们对连接状态的检查主要分为两部分:


  • 检查连接的是不是我们的导线
  • 检查连接的方块是否有能量相关的 Capability

代码:

  1. private boolean canConnect(@Nonnull IWorld world, @Nonnull Direction facing, @Nonnull BlockPos pos, @Nonnull BlockState state)
  2. {
  3.     if (!state.getBlock().equals(BLOCK))
  4.     {
  5.   TileEntity tileEntity = world.getTileEntity(pos);
  6.   return tileEntity != null && tileEntity.getCapability(CapabilityEnergy.ENERGY, facing).isPresent();
  7.     }
  8.     return true;
  9. }

方块材质


如果考虑所有的方块状态,一个导线甚至能够有多达 64 个方块状态。如果我们为每一个方块状态都指定一次材质和模型,那这注定会带来很大的工作量。


不过,原版 Minecraft 提供了 multipart 机制,能够让我们为每个属性指定独有的一部分模型和材质,然后将每个属性所指定的拼合起来。


以下是我们的整个方块状态 JSON:


代码:

  1. {
  2. "multipart": [
  3.     {
  4.    "apply": {
  5.   "model": "fedemo:block/wire_core"
  6.    }
  7.     },
  8.     {
  9.    "when": {
  10.   "north": "true"
  11.    },
  12.    "apply": {
  13.   "model": "fedemo:block/wire_part"
  14.    }
  15.     },
  16.     {
  17.    "when": {
  18.   "east": "true"
  19.    },
  20.    "apply": {
  21.   "model": "fedemo:block/wire_part",
  22.   "y": 90
  23.    }
  24.     },
  25.     {
  26.    "when": {
  27.   "south": "true"
  28.    },
  29.    "apply": {
  30.   "model": "fedemo:block/wire_part",
  31.   "y": 180
  32.    }
  33.     },
  34.     {
  35.    "when": {
  36.   "west": "true"
  37.    },
  38.    "apply": {
  39.   "model": "fedemo:block/wire_part",
  40.   "y": 270
  41.    }
  42.     },
  43.     {
  44.    "when": {
  45.   "up": "true"
  46.    },
  47.    "apply": {
  48.   "model": "fedemo:block/wire_part",
  49.   "x": 270
  50.    }
  51.     },
  52.     {
  53.    "when": {
  54.   "down": "true"
  55.    },
  56.    "apply": {
  57.   "model": "fedemo:block/wire_part",
  58.   "x": 90
  59.    }
  60.     }
  61. ]
  62. }

  • 我们的核心位于 fedemo:block/wire_core,这是无论什么方块状态都会有的。
  • 我们为每个属性都指定了 fedemo:block/wire_part,在特定方向的连接存在时提供相应的模型和材质。

不同的连接方向属性所引用的 JSON 是相同的,但旋转方向有细微的差别(注意是顺时针):


  • north 为默认,也就是不旋转。
  • east 沿 Y 轴顺时针旋转 90 度。
  • south 沿 Y 轴顺时针旋转 180 度。
  • west 沿 Y 轴顺时针旋转 270 度。
  • up 沿 X 轴顺时针旋转 270 度。
  • down 沿 X 轴顺时针旋转 90 度。

现在我们需要制作一个代表核心的 wire_core.json


代码:

  1. {
  2. "parent": "block/block",
  3. "ambientocclusion": false,
  4. "textures": {
  5.     "wire": "fedemo:block/wire_core_part",
  6.     "particle": "fedemo:block/wire_core_part"
  7. },
  8. "elements": [
  9.     {
  10.    "from": [5, 5, 5],
  11.    "to": [11, 11, 11],
  12.    "faces": {
  13.   "north": {
  14.     "uv": [7, 7, 13, 13],
  15.     "texture": "#wire"
  16.   },
  17.   "east": {
  18.     "uv": [7, 7, 13, 13],
  19.     "texture": "#wire"
  20.   },
  21.   "south": {
  22.     "uv": [7, 7, 13, 13],
  23.     "texture": "#wire"
  24.   },
  25.   "west": {
  26.     "uv": [7, 7, 13, 13],
  27.     "texture": "#wire"
  28.   },
  29.   "up": {
  30.     "uv": [7, 7, 13, 13],
  31.     "texture": "#wire"
  32.   },
  33.   "down": {
  34.     "uv": [7, 7, 13, 13],
  35.     "texture": "#wire"
  36.   }
  37.    }
  38.     }
  39. ]
  40. }

和一个代表连接状态的 wire_part.json


代码:

  1. {
  2. "ambientocclusion": false,
  3. "textures": {
  4.     "wire": "fedemo:block/wire_core_part",
  5.     "particle": "fedemo:block/wire_core_part"
  6. },
  7. "elements": [
  8.     {
  9.    "from": [6, 6, 0],
  10.    "to": [10, 10, 7],
  11.    "faces": {
  12.   "north": {
  13.     "uv": [3, 3, 7, 7],
  14.     "texture": "#wire"
  15.   },
  16.   "east": {
  17.     "uv": [6, 3, 13, 7],
  18.     "texture": "#wire"
  19.   },
  20.   "west": {
  21.     "uv": [6, 3, 13, 7],
  22.     "texture": "#wire"
  23.   },
  24.   "up": {
  25.     "uv": [3, 6, 7, 13],
  26.     "texture": "#wire"
  27.   },
  28.   "down": {
  29.     "uv": [3, 6, 7, 13],
  30.     "texture": "#wire"
  31.   }
  32.    }
  33.     }
  34. ]
  35. }

两个 JSON 引用的是同一个材质(见下图 wire_core_part.png):





最后别忘了添加描述物品材质的 JSON:


代码:

  1. {
  2. "parent": "fedemo:block/wire_core"
  3. }

现在我们可以打开游戏看看效果了:





方块碰撞箱和选择框


由于导线是不完整方块,因此我们需要指定方块的碰撞箱和选择框的形态。


我们先从碰撞箱开始,我们需要覆盖 getCollisionShape 方法:


代码:

  1. @Nonnull
  2. @Override
  3. @SuppressWarnings("deprecation")
  4. public VoxelShape getCollisionShape(@Nonnull BlockState state, @Nonnull IBlockReader world, @Nonnull BlockPos pos, @Nonnull ISelectionContext context)
  5. {
  6.     return VoxelShapes.empty();
  7. }

这里设置的是没有碰撞箱,读者也可以根据自己的喜好设置成其他的碰撞箱。


然后是选择框,我们在这里这里需要覆盖 getShape 方法:


代码:

  1. @Nonnull
  2. @Override
  3. @SuppressWarnings("deprecation")
  4. public VoxelShape getShape(@Nonnull BlockState state, @Nonnull IBlockReader world, @Nonnull BlockPos pos, @Nonnull ISelectionContext context)
  5. {
  6.     return Block.makeCuboidShape(4, 4, 4, 12, 12, 12);
  7. }

我们已经在第二讲接触过碰撞箱的相关内容了,这里的设置大同小异。


这里设置的选择框比导线核心大了一圈,现在可以打开游戏看看了。





代码清单


这一部分添加的文件有:


  • src/main/java/com/github/ustc_zzzz/fedemo/block/FEDemoWireBlock.java
  • src/main/java/com/github/ustc_zzzz/fedemo/tileentity/FEDemoWireTileEntity.java
  • src/main/resources/assets/fedemo/blockstates/wire.json
  • src/main/resources/assets/fedemo/models/block/wire_core.json
  • src/main/resources/assets/fedemo/models/block/wire_part.json
  • src/main/resources/assets/fedemo/models/item/wire.json
  • src/main/resources/assets/fedemo/textures/block/wire_core_part.png

这一部分修改的文件有:


  • src/main/resources/assets/fedemo/lang/en_us.json






欢迎来到整个系列教程中最难的一讲。本讲将侧重于介绍如何为传统意义上的导线实现能量传输。


和现实世界不同,在游戏中实现传统意义上的导线,要比无线充电等其他实现方式困难得多。因此读者如果实在无法完整实现传统意义上的导线,那也可以退而求其次,去实现其他的能量传输形式。导线之外的能量传输形式往往也是会受到玩家欢迎的。


导线连通网络


一个最朴素的想法是让每根导线每 tick 都将附近导线的能量传输过来。实际上一些 Mod(比方说 RailCraft 等)也正是这么做的。


但这样做无疑从游戏体验上就存在一个问题:如果是一根长长的导线,那么每 tick 能量自然只能传输一格,如果导线长达几百根,那毫无疑问需要数秒甚至十多秒的时间才能将能量从一头传到另一头。如果考虑到游戏性能的话,问题更大了:所有的导线都需要每 tick 更新一次,那势必导致世界上每 tick 都更新的方块实体数量大大增加。


所以,一个非常重要的原则就是:不要为代表导线的方块实体实现 ITickableTileEntity 接口。那我们应当什么时候执行导线相关的逻辑呢?


部分 Mod 会为导线指定一个中心方块(比如说 AppliedEnergistics2 的 ME 控制器),这不失为一个好的选择:我们只需要为中心方块实现 ITickableTileEntity,并由它接管所有能量相关的逻辑即可。但是,我们需要实现的是传统意义上的导线,换言之,我们要实现的能量传输系统没有中心方块,对每一根导线而言,它们都是相互平等的。


为了解决这一问题。我们必须为每个世界单独提供一个管理能量传输的导线连通网络,并且自动接管导线的通断相关逻辑。为了方便进一步的操作,我们可以从每一组互相连通的导线(又称一个连通域)中挑选一个代表方块,然后使用该方块代表整组导线。当然,导线的通断会导致一组导线分裂成两组,或是两组导线合并成一组,这些都是我们需要考虑的。


我们现在将接口声明如下:


代码:

  1. public interface IBlockNetwork
  2. {
  3.     int size(BlockPos node);

  4.     BlockPos root(BlockPos node);

  5.     void cut(BlockPos node, Direction direction, ConnectivityListener afterSplit);

  6.     void link(BlockPos node, Direction direction, ConnectivityListener beforeMerge);

  7.     @FunctionalInterface
  8.     interface ConnectivityListener
  9.     {
  10.   void onChange(BlockPos primaryNode, BlockPos secondaryNode);
  11.     }
  12. }

  • size 返回的是导线所处连通域含有的导线数量,如果导线没有和其他导线连通,那么返回值为 1
  • root 返回的是导线所处连通域的代表导线,也有可能是该导线自身(比方说未和其他导线连通的时候)。
  • cut 指将某根导线的某个方向切断。切断导线如果会导致一个连通域分裂成两个,那么会在分裂后调用 afterSplit 的相关方法。
  • link 指将某根导线在某个方向上实施连接。连接导线如果会导致两个连通域合并为一个,那么会在合并前调用 beforeMerge 的相关方法。
  • onChange 方法将在连通域发生变化时调用,其中第一个参数是主连通域(其代表导线不会发生变化),而第二个参数是次连通域(会由于合并而消失,或由于分裂而新出现)。

如何实现这个接口呢?本讲将提供相对简单直观的实现,该实现可以达到 O(N) 的时间复杂度。高效的实现可以做到多项式对数级(O(polylog N))的时间复杂度,但过于复杂:感兴趣的读者可以通过阅读 Wikipedia(英文页面)加以了解。


实现的思路很简单:除了维护所有连接外,我们只需要对每个方块维护一个连通域的相关集合就好了,而集合的第一个元素自然便是连通域的代表方块:


代码:

  1. public class SimpleBlockNetwork implements IBlockNetwork
  2. {
  3.     private final Map<BlockPos, Set<BlockPos>> components;
  4.     private final SetMultimap<BlockPos, Direction> connections;

  5.     public SimpleBlockNetwork()
  6.     {
  7.   this.components = Maps.newHashMap();
  8.   this.connections = Multimaps.newSetMultimap(Maps.newHashMap(), () -> EnumSet.noneOf(Direction.class));
  9.     }

  10.     @Override
  11.     public int size(BlockPos node)
  12.     {
  13.   return 1; // TODO
  14.     }

  15.     @Override
  16.     public BlockPos root(BlockPos node)
  17.     {
  18.   return node; // TODO
  19.     }

  20.     @Override
  21.     public void cut(BlockPos node, Direction direction, ConnectivityListener afterSplit)
  22.     {
  23.   // TODO
  24.     }

  25.     @Override
  26.     public void link(BlockPos node, Direction direction, ConnectivityListener beforeMerge)
  27.     {
  28.   // TODO
  29.     }
  30. }

上面的代码中,components 自然是方块到连通域(Set&lt;BlockPos&gt;)的映射,而 connections 中存储着所有连接。


我们先从 sizeroot 方法的实现开始:


代码:

  1. @Override
  2. public int size(BlockPos node)
  3. {
  4.     return this.components.containsKey(node) ? this.components.get(node).size() : 1;
  5. }

  6. @Override
  7. public BlockPos root(BlockPos node)
  8. {
  9.     return this.components.containsKey(node) ? this.components.get(node).iterator().next() : node.toImmutable();
  10. }

两个实现都十分直观,且都考虑到了有连通域和无连通域的情况。唯一需要注意的是我们需要 toImmutable 方法把 BlockPos 转为不可变的,这样后续我们才能将相应 BlockPos 直接存入 SetMap 中。


接下来我们实现 cutlink 两个方法。


导线连通域的合并


我们再来实现 link 方法:


代码:

  1. @Override
  2. public void link(BlockPos node, Direction direction, ConnectivityListener beforeMerge)
  3. {
  4.     BlockPos secondary = node.toImmutable();
  5.     if (this.connections.put(secondary, direction))
  6.     {
  7.   BlockPos primary = node.offset(direction);
  8.   this.connections.put(primary, direction.getOpposite());
  9.   Set<BlockPos> primaryComponent = this.components.get(primary);
  10.   Set<BlockPos> secondaryComponent = this.components.get(secondary);
  11.   if (primaryComponent == null && secondaryComponent == null)
  12.   {
  13.    Set<BlockPos> union = Sets.newLinkedHashSet();
  14.    beforeMerge.onChange(secondary, primary);
  15.    this.components.put(secondary, union);
  16.    this.components.put(primary, union);
  17.    union.add(secondary);
  18.    union.add(primary);
  19.   }
  20.   else if (primaryComponent == null)
  21.   {
  22.    beforeMerge.onChange(secondaryComponent.iterator().next(), primary);
  23.    this.components.put(primary, secondaryComponent);
  24.    secondaryComponent.add(primary);
  25.   }
  26.   else if (secondaryComponent == null)
  27.   {
  28.    beforeMerge.onChange(primaryComponent.iterator().next(), secondary);
  29.    this.components.put(secondary, primaryComponent);
  30.    primaryComponent.add(secondary);
  31.   }
  32.   else if (primaryComponent != secondaryComponent)
  33.   {
  34.    beforeMerge.onChange(primaryComponent.iterator().next(), secondaryComponent.iterator().next());
  35.    Set<BlockPos> union = Sets.newLinkedHashSet(Sets.union(primaryComponent, secondaryComponent));
  36.    union.forEach(pos -> this.components.put(pos, union));
  37.   }
  38.     }
  39. }

我们一段一段地来分析:


代码:

  1. BlockPos secondary = node.toImmutable();
  2. if (this.connections.put(secondary, direction))

这一段是将 BlockPos 和对应 Direction 添加到 connections 中,如果在这之前 connections 中并不存在该连接,那么 put 方法将返回 true,如果不存在,那么自然就没有进行下一步的意义了。


代码:

  1. BlockPos primary = node.offset(direction);
  2. this.connections.put(primary, direction.getOpposite());

如果连接不存在的话,那么我们还需要找到连接到的 BlockPos,为其相反方向添加连接。


代码:

  1. Set<BlockPos> primaryComponent = this.components.get(primary);
  2. Set<BlockPos> secondaryComponent = this.components.get(secondary);

我们试图获取两个 BlockPos 所处的连通域,至此我们需要分三种情况:


  • 两个连通域都不存在,那我们需要新创建一个连通域,然后把两个 BlockPos 加上去。
  • 一个连通域存在,另一个不存在,那我们需要把对应的 BlockPos 加到相应的连通域中。
  • 两个连通域都存在,那如果它们不相同,我们需要把两个连通域相互合并,然后应用到所有相关节点上去。

代码:

  1. if (primaryComponent == null && secondaryComponent == null)
  2. {
  3.     Set<BlockPos> union = Sets.newLinkedHashSet();
  4.     beforeMerge.onChange(secondary, primary);
  5.     this.components.put(secondary, union);
  6.     this.components.put(primary, union);
  7.     union.add(secondary);
  8.     union.add(primary);
  9. }

这对应两个连通域都不存在的情况:创建一个新连通域(union),然后把两个 BlockPos 加上去。别忘了调用 beforeMerge 的相关方法。


代码:

  1. else if (primaryComponent == null)
  2. {
  3.     beforeMerge.onChange(secondaryComponent.iterator().next(), primary);
  4.     this.components.put(primary, secondaryComponent);
  5.     secondaryComponent.add(primary);
  6. }

这对应第一个连通域不存在而第二个存在的情况,我们需要把第一个 BlockPos 加上去。


代码:

  1. else if (secondaryComponent == null)
  2. {
  3.     beforeMerge.onChange(primaryComponent.iterator().next(), secondary);
  4.     this.components.put(secondary, primaryComponent);
  5.     primaryComponent.add(secondary);
  6. }

这对应第一个连通域存在而第二个不存在的情况,我们需要把第二个 BlockPos 加上去。


代码:

  1. else if (primaryComponent != secondaryComponent)
  2. {
  3.     beforeMerge.onChange(primaryComponent.iterator().next(), secondaryComponent.iterator().next());
  4.     Set<BlockPos> union = Sets.newLinkedHashSet(Sets.union(primaryComponent, secondaryComponent));
  5.     union.forEach(pos -> this.components.put(pos, union));
  6. }

这对应两个连通域都存在且不相同的情况,我们需要创建一个连通域把两个连通域合并到一起,然后应用到两个连通域中的所有节点上。


最后我们注意到,只有两种情况下我们不会调用 beforeMergeonChange 方法:


  • 试图添加的连接已存在。
  • 添加了连接同一个连通域的连接。

导线连通域的分裂


最后我们实现 cut 方法。cut 方法是整个接口中最难实现的一个,因此在动手写代码时,我们首先需要了解相关原理。


我们知道删除某个连接有可能将一个连通域分裂成两半,也有可能不会为一个连通域带来变化。为了检查这两件事,我们需要从被删除的连接所对应的两个 BlockPos 开始,分别进行广度优先搜索,并在以下两个条件中的任何一个达成时同时终止搜索:


  • 当一方搜索到的某个节点已经在另一方搜索到的节点列表中,则代表连通域并未分裂。
  • 当一方已经遍历了所有能够遍历的节点,则代表连通域已被分裂为两半,搜索完成的一方代表其中的一半。

为此,我们需要首先实现一个基于广度优先搜索的 Iterator


代码:

  1. public class BFSIterator implements Iterator<BlockPos>
  2. {
  3.     private final Set<BlockPos> searched = Sets.newLinkedHashSet();
  4.     private final Queue<BlockPos> queue = Queues.newArrayDeque();

  5.     public BFSIterator(BlockPos node)
  6.     {
  7.   node = node.toImmutable();
  8.   this.searched.add(node);
  9.   this.queue.offer(node);
  10.     }

  11.     @Override
  12.     public boolean hasNext()
  13.     {
  14.   return this.queue.size() > 0;
  15.     }

  16.     @Override
  17.     public BlockPos next()
  18.     {
  19.   BlockPos node = this.queue.remove();
  20.   for (Direction direction : SimpleBlockNetwork.this.connections.get(node))
  21.   {
  22.    BlockPos another = node.offset(direction);
  23.    if (this.searched.add(another))
  24.    {
  25.     this.queue.offer(another);
  26.    }
  27.   }
  28.   return node;
  29.     }

  30.     public Set<BlockPos> getSearched()
  31.     {
  32.   return this.searched;
  33.     }
  34. }

广度优先搜索的实现很简单,上面的代码也很清晰,这里就不展开讲解了。


接下来我们使用 BFSIterator 实现 cut 方法:


代码:

  1. @Override
  2. public void cut(BlockPos node, Direction direction, ConnectivityListener afterSplit)
  3. {
  4.     if (this.connections.remove(node, direction))
  5.     {
  6.   BlockPos another = node.offset(direction);
  7.   this.connections.remove(another, direction.getOpposite());
  8.   BFSIterator nodeIterator = new BFSIterator(node), anotherIterator = new BFSIterator(another);
  9.   while (nodeIterator.hasNext())
  10.   {
  11.    BlockPos next = nodeIterator.next();
  12.    if (!anotherIterator.getSearched().contains(next))
  13.    {
  14.     BFSIterator iterator = anotherIterator;
  15.     anotherIterator = nodeIterator;
  16.     nodeIterator = iterator;
  17.     continue;
  18.    }
  19.    return;
  20.   }
  21.   Set<BlockPos> primaryComponent = this.components.get(node), secondaryComponent;
  22.   BlockPos primaryNode = primaryComponent.iterator().next();
  23.   Set<BlockPos> searched = nodeIterator.getSearched();
  24.   if (searched.contains(primaryNode))
  25.   {
  26.    secondaryComponent = Sets.newLinkedHashSet(Sets.difference(primaryComponent, searched));
  27.    primaryComponent.retainAll(searched);
  28.   }
  29.   else
  30.   {
  31.    secondaryComponent = searched;
  32.    primaryComponent.removeAll(searched);
  33.   }
  34.   if (secondaryComponent.size() <= 1)
  35.   {
  36.    secondaryComponent.forEach(this.components::remove);
  37.   }
  38.   else
  39.   {
  40.    secondaryComponent.forEach(pos -> this.components.put(pos, secondaryComponent));
  41.   }
  42.   if (primaryComponent.size() <= 1)
  43.   {
  44.    primaryComponent.forEach(this.components::remove);
  45.   }
  46.   afterSplit.onChange(primaryNode, secondaryComponent.iterator().next());
  47.     }
  48. }

我们在这里还是一段一段地讲解:


代码:

  1. if (this.connections.remove(node, direction))

这里将移除对应边,如果对应边在移除前存在,那么该方法返回 true


代码:

  1. BlockPos another = node.offset(direction);
  2. this.connections.remove(another, direction.getOpposite());

如果连接存在的话,那么我们还需要找到连接到的 BlockPos,为其相反方向删除连接。


代码:

  1. BFSIterator nodeIterator = new BFSIterator(node), anotherIterator = new BFSIterator(another);
  2. while (nodeIterator.hasNext())
  3. {
  4.     BlockPos next = nodeIterator.next();
  5.     if (!anotherIterator.getSearched().contains(next))
  6.     {
  7.   BFSIterator iterator = anotherIterator;
  8.   anotherIterator = nodeIterator;
  9.   nodeIterator = iterator;
  10.   continue;
  11.     }
  12.     return;
  13. }

然后我们为两边的 BlockPos 分别创立 BFSIterator,轮流实施迭代过程。


  • 若当前 BFSIterator 已遍历完所有能够遍历得到的 BlockPoshasNextfalse)则循环结束。
  • 否则,如果另一个 BFSIterator 包含当前节点,那说明它们仍然在同一个连通域,直接 return
  • 最后,如果另一个 BFSIterator 不包含当前节点,那么把两个节点相交换,继续循环过程。

代码:

  1. Set<BlockPos> primaryComponent = this.components.get(node), secondaryComponent;
  2. BlockPos primaryNode = primaryComponent.iterator().next();
  3. Set<BlockPos> searched = nodeIterator.getSearched();
  4. if (searched.contains(primaryNode))
  5. {
  6.     secondaryComponent = Sets.newLinkedHashSet(Sets.difference(primaryComponent, searched));
  7.     primaryComponent.retainAll(searched);
  8. }
  9. else
  10. {
  11.     secondaryComponent = searched;
  12.     primaryComponent.removeAll(searched);
  13. }

如果我们证实我们的连通域会分裂成两半,并且已经搜索到了其中一半(searched),那么接下来我们需要定主连通域和次连通域。主连通域自然是当前节点所在连通域,但我们刚刚遍历收集到的,到底是不是主连通域呢?我们需要 searched.contains(primaryNode) 这一表达式加以判断:


  • 如果是(返回 true),那么我们需要构造一个未遍历到的 BlockPos 集合作为次连通域,然后我们在主连通域中只保留归属于 searchedBlockPosretainAll 方法)。
  • 如果不是(返回 false),那么我们可以直接将 searched 作为次连通域,然后把主连通域中已经从属于 searchedBlockPos 全去掉(removeAll 方法)。

代码:

  1. if (secondaryComponent.size() <= 1)
  2. {
  3.     secondaryComponent.forEach(this.components::remove);
  4. }
  5. else
  6. {
  7.     secondaryComponent.forEach(pos -> this.components.put(pos, secondaryComponent));
  8. }
  9. if (primaryComponent.size() <= 1)
  10. {
  11.     primaryComponent.forEach(this.components::remove);
  12. }
  13. afterSplit.onChange(primaryNode, secondaryComponent.iterator().next());

接下来就要把这两个集合应用到每一个从属于它们的 BlockPos 了。注意如果该连通域中只有一个 BlockPos,那么可以直接将其从 components 中删除。


最后我们调用了 afterSplitonChange 方法。


导线能量网络


我们现在可以基于连通网络实现能量网络了。除了连通网络外,我们还需要存储什么呢?


  • 每个连通域都会存储一定能量用于能量传输,因此我们需要为每个连通域存储这个。
  • 导线并不一定只连着导线,还可能连着机器,因此我们需要把所有和机器有关的连接单独储存。
  • 导线的连接和切断和能量存取会导致数据变化,因此我们需要记录所有变化的区块,从而保证它们能够保存进存档中。

关于能量存储这里补充一点:我们只需要为每个连通域的代表方块存储能量值,而由于能量值一定是非负整数,因此这里使用 Multiset 将十分适合。


代码:

  1. public class SimpleEnergyNetwork
  2. {
  3.     private final IWorld world;
  4.     private final IBlockNetwork blockNetwork;
  5.     private final Queue<Runnable> taskCollection;
  6.     private final Multiset<BlockPos> energyCollection;
  7.     private final SetMultimap<ChunkPos, BlockPos> chunkCollection;
  8.     private final SetMultimap<BlockPos, Direction> machineCollection;

  9.     private SimpleEnergyNetwork(IWorld world, IBlockNetwork blockNetwork)
  10.     {
  11.   this.world = world;
  12.   this.blockNetwork = blockNetwork;
  13.   this.taskCollection = Queues.newArrayDeque();
  14.   this.energyCollection = HashMultiset.create();
  15.   this.chunkCollection = Multimaps.newSetMultimap(Maps.newHashMap(), Sets::newHashSet);
  16.   this.machineCollection = Multimaps.newSetMultimap(Maps.newHashMap(), () -> EnumSet.noneOf(Direction.class));
  17.     }
  18. }

除了上面提到的这些和 world 外,我们还额外添加了一个 taskCollection 字段,稍后我们监听 tick 事件时用得着。


我们还需要考虑一个问题:刚刚我们提到过,我们的能量网络是相对于某个世界的,因此对于某个特定的世界而言,导线的能量数据是全局存储的,但我们应如何把数据放到存档里呢?以连通域为单位存储在这里显然不适合,因为连通域会合并和分裂,从而使得维护存档中导线和连通域之间的关系成为非常困难的工作(在内存中这很容易)。一个很不错的解决方案是:我们可以把能量放到导线里均摊储存,这样不管连通域如何合并和分裂,最终都将落实到每根导线和存档的交互上。为了实现这一解决方案,我们需要声明四个方法:


  • getNetworkSize:获取导线所处连通域的导线数量。
  • getNetworkEnergy:获取导线所处连通域的整体能量。
  • getSharedEnergy:获取导线所处连通域均摊到当前导线的能量。
  • addEnergy:调整导线所处连通域的能量(正数为增加,负数为减少)。

这四个方法的实现都非常简单。Guava 的 MultisetMultimap 在实现上为我们带来了极大的方便:


代码:

  1. public int getNetworkSize(BlockPos pos)
  2. {
  3.     return this.blockNetwork.size(pos);
  4. }

  5. public int getNetworkEnergy(BlockPos pos)
  6. {
  7.     BlockPos root = this.blockNetwork.root(pos);
  8.     return this.energyCollection.count(root);
  9. }

  10. public int getSharedEnergy(BlockPos pos)
  11. {
  12.     int size = this.blockNetwork.size(pos);
  13.     BlockPos root = this.blockNetwork.root(pos);
  14.     int total = this.energyCollection.count(root);
  15.     return root.equals(pos) ? total / size + total % size : total / size;
  16. }

  17. public void addEnergy(BlockPos pos, int diff)
  18. {
  19.     if (diff >= 0)
  20.     {
  21.   this.energyCollection.add(this.blockNetwork.root(pos), diff);
  22.     }
  23.     else
  24.     {
  25.   this.energyCollection.remove(this.blockNetwork.root(pos), -diff);
  26.     }
  27. }

这里唯一需要指出的是能量的分摊方式,也就是在整体能量除以连通域导线数量除不开的时候,问题是如何解决的:


  • 如果当前导线是连通域的代表导线,那么把余数都分摊到该导线上。
  • 如果当前导线不是连通域的代表导线,那么照常除就可以了,不必考虑余数。

在 tick 事件中增删导线


我们现在声明用于删除导线的 disableBlock 方法,和用于添加导线的 enableBlock 方法。但是,这两个方法的实现并没有那么直接,因为我们需要把相关行为托管到 tick 事件中执行。


为什么我们不能立刻增删导线?这是因为在增删导线的时候,我们需要检查导线和周围方块的连通性,而很多时候导线是在世界加载阶段加载的,因此如果在世界加载时获取周围方块的相关信息,将会极易导致死锁。因此我们需要把增删导线的相关逻辑放到 tick 事件中,这正是 taskCollection 字段的存在意义。


代码:

  1. public void disableBlock(BlockPos pos, Runnable callback)
  2. {
  3.     this.taskCollection.offer(() ->
  4.     {
  5.   // TODO
  6.   callback.run();
  7.     });
  8. }

  9. public void enableBlock(BlockPos pos, Runnable callback)
  10. {
  11.     this.taskCollection.offer(() ->
  12.     {
  13.   // TODO
  14.   callback.run();
  15.     });
  16. }

  17. private void tickStart()
  18. {
  19.     for (Runnable runnable = this.taskCollection.poll(); runnable != null; runnable = this.taskCollection.poll())
  20.     {
  21.   runnable.run();
  22.     }
  23. }

我们为 disableBlockenableBlock 两个方法添加了 Runnable 作为回调函数,并在 tickStart 方法调用时调用。我们稍后便会在 tick 事件的监听器里调用 tickStart 方法。


向能量网络增删导线


我们先来实现删除导线:


代码:

  1. public void disableBlock(BlockPos pos, Runnable callback)
  2. {
  3.     this.taskCollection.offer(() ->
  4.     {
  5.   this.chunkCollection.remove(new ChunkPos(pos), pos);
  6.   for (Direction side : Direction.values())
  7.   {
  8.    this.blockNetwork.cut(pos, side, this::afterSplit);
  9.   }
  10.   this.machineCollection.removeAll(pos);
  11.   callback.run();
  12.     });
  13. }

  14. private void afterSplit(BlockPos primaryNode, BlockPos secondaryNode)
  15. {
  16.     int primarySize = this.blockNetwork.size(primaryNode), secondarySize = this.blockNetwork.size(secondaryNode);
  17.     int diff = this.energyCollection.count(primaryNode) * secondarySize / (primarySize + secondarySize);
  18.     this.energyCollection.remove(primaryNode, diff);
  19.     this.energyCollection.add(secondaryNode, diff);
  20. }

除了调用回调函数外,删除导线主要做三件事:


  • 记录导线所归属的区块。
  • 切断导线在六个方向的所有连接。
  • 切断导线在所有方向上和机器的连接。

切断导线连接时需要传入 ConnectivityListener,这里声明并实现了 afterSplit 方法,并传入方法引用作为实现。afterSplit 方法所做的事很简单:把当前连通域的整体能量按所拥有的导线数量分离一部分出来给一个新的连通域。


然后我们再来实现添加导线:


代码:

  1. public void enableBlock(BlockPos pos, Runnable callback)
  2. {
  3.     this.taskCollection.offer(() ->
  4.     {
  5.   this.chunkCollection.put(new ChunkPos(pos), pos.toImmutable());
  6.   for (Direction side : Direction.values())
  7.   {
  8.    if (this.hasWireConnection(pos, side))
  9.    {
  10.     if (this.hasWireConnection(pos.offset(side), side.getOpposite()))
  11.     {
  12.   this.machineCollection.remove(pos, side);
  13.   this.blockNetwork.link(pos, side, this::beforeMerge);
  14.     }
  15.     else
  16.     {
  17.   this.machineCollection.put(pos.toImmutable(), side);
  18.   this.blockNetwork.cut(pos, side, this::afterSplit);
  19.     }
  20.    }
  21.    else
  22.    {
  23.     this.machineCollection.remove(pos, side);
  24.     this.blockNetwork.cut(pos, side, this::afterSplit);
  25.    }
  26.   }
  27.   callback.run();
  28.     });
  29. }

  30. private boolean hasWireConnection(BlockPos pos, Direction side)
  31. {
  32.     return false; // TODO
  33. }

  34. private void beforeMerge(BlockPos primaryNode, BlockPos secondaryNode)
  35. {
  36.     int diff = this.energyCollection.count(secondaryNode);
  37.     this.energyCollection.remove(secondaryNode, diff);
  38.     this.energyCollection.add(primaryNode, diff);
  39. }

除了调用回调函数外,添加导线主要做的也是三件事:


  • 记录导线所归属的区块。
  • 添加导线和其他导线之间的连接。
  • 添加导线和机器的连接。

和删除导线相比,添加导线还需要检查周围方块是否能够与其相互连接,因此实现会稍加复杂:


  • 如果导线在某个方向上不和相邻方块连接,那自然既不考虑连通网络,也不考虑机器了。
  • 如果导线在某个方向上和相邻方块连接,且连接的方块是在相反方向上连接的导线,那么将该导线添加到连通网络。
  • 如果导线在某个方向上和相邻方块连接,且连接的方块不是在相反方向上连接的导线,那么将该导线连接的方块视为机器。

我们现在实现 hasWireConnection 方法:


代码:

  1. @SuppressWarnings("deprecation")
  2. private boolean hasWireConnection(BlockPos pos, Direction side)
  3. {
  4.     if (this.world.isBlockLoaded(pos))
  5.     {
  6.   BlockState state = this.world.getBlockState(pos);
  7.   return state.getBlock().equals(FEDemoWireBlock.BLOCK) && state.get(FEDemoWireBlock.PROPERTY_MAP.get(side));
  8.     }
  9.     return false;
  10. }

这里需要额外注意 isBlockLoaded 方法的调用。如果我们不事先进行 isBlockLoaded 这一检查,那么 getBlockState 方法在检查未加载的方块坐标时,将会自动将该方块坐标所处的区块予以加载,而加载会导致连接状态的变化,因此如果世界上有一长链导线,这会导致途径的所有区块全部加载,这显然是没有必要的。更为重要的是,游戏会在加载区块后试图卸载不必要的区块,而卸载区块同样会导致连接状态的变化,这一变化又会反过来加载区块,因此区块会不断地在加载和卸载之间循环,这显然会带来不必要的性能损失。稍后我们会在其他方法再次调用 isBlockLoaded 方法进行方块是否已加载的检查。


增删导线的逻辑到这里就彻底写完了。接下来我们要写另一处需要在 tick 事件中执行的逻辑。


在 tick 事件中输送能量


在编写发电机的时候我们曾经提到,能量的流动应由发电机控制,而发电机实现了 ITickableTileEntity 接口,因此可以在实现该接口的 tick 方法时输送能量。刚刚我们提到,导线能量网络是以世界为单位的,因此我们同样需要监听世界的 tick 事件完成这件事。我们把这一行为写进 tickEnd 方法:


代码:

  1. @SuppressWarnings("deprecation")
  2. private void tickEnd()
  3. {
  4.     for (Map.Entry<BlockPos, Direction> entry : this.shuffled(this.machineCollection.entries()))
  5.     {
  6.   Direction direction = entry.getValue();
  7.   BlockPos node = entry.getKey(), root = this.blockNetwork.root(node);
  8.   if (this.world.isBlockLoaded(node.offset(direction)))
  9.   {
  10.    TileEntity tileEntity = this.world.getTileEntity(node.offset(direction));
  11.    if (tileEntity != null)
  12.    {
  13.     tileEntity.getCapability(CapabilityEnergy.ENERGY, direction.getOpposite()).ifPresent(e ->
  14.     {
  15.   if (e.canReceive())
  16.   {
  17.    int diff = this.energyCollection.count(root);
  18.    this.energyCollection.remove(root, e.receiveEnergy(diff, false));
  19.   }
  20.     });
  21.    }
  22.   }
  23.     }
  24. }

  25. private <T> List<T> shuffled(Iterable<? extends T> iterable)
  26. {
  27.     List<T> list = Lists.newArrayList(iterable);
  28.     Random rand = this.world.getRandom();
  29.     Collections.shuffle(list, rand);
  30.     return list;
  31. }

该方法的实现很简单:遍历所有的机器(在遍历前打乱了一遍次序),然后如果机器可以接收能量,那么便向其输送能量。注意 isBlockLoaded 方法的调用,因为我们并不希望向未加载的区块中的方块实体输送能量。


标记需要保存的区块


我们需要在保存存档的时候标记所有需要保存的区块。我们声明一个 markDirty 方法,并在该方法内部实现相应的逻辑:


代码:

  1. @SuppressWarnings("deprecation")
  2. private void markDirty()
  3. {
  4.     for (ChunkPos chunkPos : this.chunkCollection.keys())
  5.     {
  6.   BlockPos pos = chunkPos.asBlockPos();
  7.   if (this.world.isBlockLoaded(pos))
  8.   {
  9.    this.world.getChunk(pos).setModified(true);
  10.   }
  11.     }
  12. }

稍后我们会监听保存世界存档的事件,然后调用这一方法。


导线能量网络的管理


我们需要一个全局化的管理类,我们决定让它成为 SimpleEnergyNetwork 的嵌套类:


代码:

  1. @Mod.EventBusSubscriber(bus = Mod.EventBusSubscriber.Bus.FORGE)
  2. public static class Factory
  3. {
  4.     private static final Map<IWorld, SimpleEnergyNetwork> INSTANCES = Maps.newIdentityHashMap();

  5.     public static SimpleEnergyNetwork get(IWorld world)
  6.     {
  7.   return INSTANCES.computeIfAbsent(world, k -> new SimpleEnergyNetwork(k, new SimpleBlockNetwork()));
  8.     }

  9.     @SubscribeEvent
  10.     public static void onSave(WorldEvent.Save event)
  11.     {
  12.   if (INSTANCES.containsKey(event.getWorld()))
  13.   {
  14.    INSTANCES.get(event.getWorld()).markDirty();
  15.   }
  16.     }

  17.     @SubscribeEvent
  18.     public static void onUnload(WorldEvent.Unload event)
  19.     {
  20.   INSTANCES.remove(event.getWorld());
  21.     }

  22.     @SubscribeEvent
  23.     public static void onWorldTick(TickEvent.WorldTickEvent event)
  24.     {
  25.   if (LogicalSide.SERVER.equals(event.side))
  26.   {
  27.    switch (event.phase)
  28.    {
  29.     case START:
  30.     {
  31.   Factory.get(event.world).tickStart();
  32.   break;
  33.     }
  34.     case END:
  35.     {
  36.   Factory.get(event.world).tickEnd();
  37.   break;
  38.     }
  39.    }
  40.   }
  41.     }
  42. }

该类提供了构造并返回 SimpleEnergyNetwork 方法,并且有三个事件监听器:


  • 在世界开始保存存档时调用 markDirty 方法。
  • 在世界准备卸载时移除已经持有的 SimpleEnergyNetwork 实例。
  • 在世界的 tick 事件触发时调用 tickStarttickEnd 方法。

到这里,整个 SimpleEnergyNetwork,就完全实现完了,我们稍后会在导线的方块实体类里调用里面的相关方法。


为导线实现 Capability


我们现在为代表导线的方块实体添加 Capability:


代码:

  1. private final LazyOptional<IEnergyStorage> lazyOptional = LazyOptional.of(() -> new IEnergyStorage()
  2. {
  3.     private final SimpleEnergyNetwork network = SimpleEnergyNetwork.Factory.get(FEDemoWireTileEntity.this.world);

  4.     @Override
  5.     public int receiveEnergy(int maxReceive, boolean simulate)
  6.     {
  7.   int energy = this.getEnergyStored();
  8.   int diff = Math.min(500, Math.min(this.getMaxEnergyStored() - energy, maxReceive));
  9.   if (!simulate)
  10.   {
  11.    this.network.addEnergy(FEDemoWireTileEntity.this.pos, diff);
  12.    if (diff != 0)
  13.    {
  14.     FEDemoWireTileEntity.this.markDirty();
  15.    }
  16.   }
  17.   return diff;
  18.     }

  19.     @Override
  20.     public int extractEnergy(int maxExtract, boolean simulate)
  21.     {
  22.   int energy = this.getEnergyStored();
  23.   int diff = Math.min(500, Math.min(energy, maxExtract));
  24.   if (!simulate)
  25.   {
  26.    this.network.addEnergy(FEDemoWireTileEntity.this.pos, -diff);
  27.    if (diff != 0)
  28.    {
  29.     FEDemoWireTileEntity.this.markDirty();
  30.    }
  31.   }
  32.   return diff;
  33.     }

  34.     @Override
  35.     public int getEnergyStored()
  36.     {
  37.   return Math.min(this.getMaxEnergyStored(), this.network.getNetworkEnergy(FEDemoWireTileEntity.this.pos));
  38.     }

  39.     @Override
  40.     public int getMaxEnergyStored()
  41.     {
  42.   return 1_000 * this.network.getNetworkSize(FEDemoWireTileEntity.this.pos);
  43.     }

  44.     @Override
  45.     public boolean canExtract()
  46.     {
  47.   return true;
  48.     }

  49.     @Override
  50.     public boolean canReceive()
  51.     {
  52.   return true;
  53.     }
  54. });

  55. @Nonnull
  56. @Override
  57. public <T> LazyOptional<T> getCapability(@Nonnull Capability<T> cap, Direction side)
  58. {
  59.     boolean isEnergy = Objects.equals(cap, CapabilityEnergy.ENERGY);
  60.     return isEnergy ? this.lazyOptional.cast() : super.getCapability(cap, side);
  61. }

由于导线既可以输入能量,也可以输出能量,因此 canExtractcanReceive 都应返回 true,剩下的实现和之前的发电机和用电器都大同小异,这里就不展开了。


导线本身的加载与卸载


Minecraft 原版和 Forge 共为 TileEntity 提供了三个方法用于描述方块实体的加载和卸载过程:


  • onLoad 方法将在方块实体加载时(包括手动放置对应方块和以及区块加载)触发。
  • onChunkUnloaded 方法将在方块实体所在区块被卸载时触发。
  • remove 方法将在方块实体被拆除时触发。

我们还需要覆盖读取 NBT 里会调用的 read 方法和写入 NBT 时会调用的 write 方法。我们先实现这两个方法:


代码:

  1. private Integer tmpEnergy = null;

  2. @Override
  3. public void read(@Nonnull CompoundNBT compound)
  4. {
  5.     this.tmpEnergy = compound.getInt("WireEnergy");
  6.     super.read(compound);
  7. }

  8. @Nonnull
  9. @Override
  10. public CompoundNBT write(@Nonnull CompoundNBT compound)
  11. {
  12.     SimpleEnergyNetwork network = SimpleEnergyNetwork.Factory.get(this.world);
  13.     compound.putInt("WireEnergy", network.getSharedEnergy(this.pos));
  14.     return super.write(compound);
  15. }

我们可以注意到一件事:write 方法是直接从导线能量网络里获取均摊能量,而 read 方法却写入到了一个临时值,为什么要这样做?这是因为,read 方法第一次调用的时机特别特别早,甚至方块实体还没有被加载到世界中,因此我们甚至连方块实体所处的世界都无法获取得到,更逞论获取导线能量网络中的均摊能量了。因此,我们只能先写入一个临时值,然后在 onLoad 方法里读取这个临时值:


代码:

  1. @Override
  2. public void onLoad()
  3. {
  4.     if (this.world != null && !this.world.isRemote)
  5.     {
  6.   SimpleEnergyNetwork network = SimpleEnergyNetwork.Factory.get(this.world);
  7.   if (this.tmpEnergy != null)
  8.   {
  9.    int diff = this.tmpEnergy - network.getSharedEnergy(this.pos);
  10.    network.addEnergy(this.pos, diff);
  11.    this.tmpEnergy = null;
  12.   }
  13.   network.enableBlock(this.pos, this::markDirty);
  14.     }
  15.     super.onLoad();
  16. }

注意该方法设置能量的方式:通过添加差额能量的方式设置。


最后我们还剩下 onChunkUnloadedremove 两个方法。我们现在实现这两个方法:


代码:

  1. @Override
  2. public void onChunkUnloaded()
  3. {
  4.     if (this.world != null && !this.world.isRemote)
  5.     {
  6.   SimpleEnergyNetwork network = SimpleEnergyNetwork.Factory.get(this.world);
  7.   network.disableBlock(this.pos, this::markDirty);
  8.     }
  9.     super.onChunkUnloaded();
  10. }

  11. @Override
  12. public void remove()
  13. {
  14.     if (this.world != null && !this.world.isRemote)
  15.     {
  16.   SimpleEnergyNetwork network = SimpleEnergyNetwork.Factory.get(this.world);
  17.   network.disableBlock(this.pos, () ->
  18.   {
  19.    int diff = network.getSharedEnergy(this.pos);
  20.    network.addEnergy(this.pos, -diff);
  21.    this.markDirty();
  22.   });
  23.     }
  24.     super.remove();
  25. }

onChunkUnloaded 相比,remove 方法额外多做了一件事:把导线连通网络里当前位置的能量清零。这可以避免在该位置重新添加导线时附带残留能量。


到这里,整个导线的方块实体相关代码,就全部实现完了。但我们还有一件事没处理:如果导线附近的方块发生变化了怎么办?


导线附近方块的变化


如果导线附近添加了新的机器,那么我们应当将这件事通知能量网络。这可以通过覆盖 Block 类的 neighborChanged 方法来实现。


我们在方块类(FEDemoWireBlock)写下以下代码:


代码:

  1. @Override
  2. @SuppressWarnings("deprecation")
  3. public void neighborChanged(@Nonnull BlockState state, @Nonnull World world, @Nonnull BlockPos pos, @Nonnull Block fromBlock, @Nonnull BlockPos fromPos, boolean isMoving)
  4. {
  5.     if (!world.isRemote)
  6.     {
  7.   TileEntity tileEntity = world.getTileEntity(pos);
  8.   if (tileEntity instanceof FEDemoWireTileEntity)
  9.   {
  10.    SimpleEnergyNetwork.Factory.get(world).enableBlock(pos, tileEntity::markDirty);
  11.   }
  12.     }
  13. }

很好,关于导线的一切我们都已经写完了。





代码清单


这一部分添加的文件有:


  • src/main/java/com/github/ustc_zzzz/fedemo/util/IBlockNetwork.java
  • src/main/java/com/github/ustc_zzzz/fedemo/util/SimpleBlockNetwork.java
  • src/main/java/com/github/ustc_zzzz/fedemo/util/SimpleEnergyNetwork.java

这一部分修改的文件有:


  • src/main/java/com/github/ustc_zzzz/fedemo/block/FEDemoWireBlock.java
  • src/main/java/com/github/ustc_zzzz/fedemo/tileentity/FEDemoWireTileEntity.java


1649367534
求大佬告知用什么启动器可以玩

sanbing
MCBBS有你更精彩~

清风叹
MCBBS有你更精彩~

kuchazi
感谢大佬

E.T.星落辰
本帖最后由 E.T.星落辰 于 2020-5-4 12:54 编辑

给土球球比心()
感觉FE系统现在还是用的人少啊,不愧蝙蝠那一句苏联笑话2333

金额锦江
一脸懵逼的我来走了,正如我一脸懵逼的来了

故乡味俱全
RF能用的是什么能量系统呀

我系切糕qwq
好像很厉害awa 话说哪里能玩到qaq

craos
?为什么我没看懂这是个什么 是太硬核了吗

lpokilpoki
66666666666刷起来

lpokilpoki
66666666666刷起来

lpokilpoki
66666666666刷起来

lpokilpoki

66666666666刷起来

lpokilpoki
66666666666刷起来

2xiaoxiao
6666666666666

Adora-
感谢分享哇~

458277
大佬nb66666

206946354
MCBBS有你更精彩~

hyd77778888
1649367534 发表于 2020-5-3 13:34
求大佬告知用什么启动器可以玩

应该是hmcl

kuchazi
感谢大佬

2724478006
MCBBS有你更精彩

qwerzxcvplm
呃!膜拜大佬!看得我一脸懵!

花园粑粑
emmmmmmmmmmmmmm

DaiBM1
E.T.星落辰 发表于 2020-5-4 12:53
给土球球比心()
感觉FE系统现在还是用的人少啊,不愧蝙蝠那一句苏联笑话2333 ...

FE是电量单位吗?就像EU,RF一样

Lovelyhamster
emmm.....虽然我看不懂,但看起来好厉害的样子

xiaohongyi
完全看不懂大佬发的代码....可能是因为我是小白的原因吧.

deskdeskhome
捕捉土球!

Bear_Spirits
666666666666

し不懂丶装懂つ
mcbbs有你更精彩

zymzdgm
MCBBS有你更精彩~

zymzdgm
MCBBS有你更精彩~

zymzdgm
MCBBS有你更精彩~

lop201
MCBBS有你们更精彩

Bear_Spirits
666666666666666666666

949151128
感谢带佬的分享,,已阅

Goyon
感觉很牛逼的样子  就是怕看都看不懂。。。

mcliugepi
eeeeeeeeee.....

mcliugepi
这是什么,      


DouTea_0418
感觉好厉害

youyihj
本帖最后由 友 于 2020-5-17 00:36 编辑

我想说个关于例子的错误。

AE2的管道从来不需要控制器。

套你瘊子
我学了好久 终于会了

110278995
啦啦啦啦啦啦啦啦

qq2479976168
好像很厉害awa 话说哪里能玩到qaq

qq852810523
好评!高大上的样子!收藏了

ACE滑稽
感谢感谢

三角函数zfz
mcbbs有你更好了

xyq680303
RF能用的是什么能量系统呀

3491638801
adasdsadassda

华夏后人
MCBBS有你更精彩~

下一页 最后一页