YAOTU INSIGHTS

TileEntity核心机制详解:从反应器到模块开发实战

TileEntity核心机制详解:从反应器到模块开发实战
写模组到现在第三十九篇终于该碰真正的核心机制了。前面几篇基本都在和Block、Item、模型这些静态的东西打交道而这一篇要讲的TileEntity本质上就是让方块“活”过来的关键——它可以让一个方块拥有自己的数据、自己的状态、自己的运行逻辑。我用“反应器”这个名字来作为载体是因为这类“机器型方块”几乎包含了一个TileEntity能遇到的所有问题内部要存物品输入槽、输出槽、燃料槽要有处理进度要和服务端逻辑配合还要把进度同步到客户端让玩家在界面上看到进度条在动。能把反应器做出来就等于掌握了模组开发里最常用的一类方块实体。这篇文章会从需求拆解开始把注册、NBT存储、服务端tick逻辑、数据同步、渲染方案全部过一遍最后一节是我实际踩过的一些坑照着做就能绕开。1. 先用需求倒推反应器的核心难点其实只有三点1.1 为什么必须引入TileEntityBlockState搞不定的三件事先回答一个很多人刚接触时都会问的问题做个反应器直接用一个Block类不行吗答案是不行至少做不出真正意义上的“反应器”。原因有三条。第一是状态存储。BlockState在1.12.2里的本质是方块自身属性的组合它存在世界里的时候只是一组Property和对应的值没法塞进去一个ItemStack数组。可反应器内部要存燃料、原料、产物这是典型的多档位物品存储只有TileEntity能在世界数据里单独挂一块NBT给它存这些内容。第二是运行逻辑。BlockState没有tick方法当然1.12.2里可以给方块设置setTickRandomly但那只能做非常稀疏的随机刻用来跑“每200tick烧一次燃料”这种流程别说手感不对实际表现也会非常奇怪。真正稳定的做法是让TileEntity实现ITickable每个游戏刻都被服务端调用一次update。第三是数据同步。客户端的模型要根据工作状态变化GUI界面要显示当前进度这些都需要一个能被服务器主动推送数据、客户端能读取解析的实体BlockState只靠IBlockState里的Property做不了这么细致的数据流通。打个比方Block是“外表”TileEntity是“内脏”。外表可以换颜色、换朝向但里面的血液、呼吸、心跳这些数据信息必须要内脏来管理。1.2 反应器的功能定义输入槽、输出槽、燃料与进度在动手写代码之前先把这台“反应器”到底做什么定义清楚。我把它设计成一个基础冶炼机器有两个输入槽一个是燃料槽比如煤炭一个是原料槽比如铁锭一个输出槽。反应器会消耗燃料将原料转化为产物同时积累一个进度值。考虑到系列文章的内容密度我决定这期不写GUI只把TileEntity的核心逻辑、数据存储、同步和模型状态做完整。没有GUI并不影响学习TileEntity因为TileEntity本身的数据链和交互链是独立于GUI的。GUI在下一期再单独展开那一篇会引入Container和Slot的完整体系。这里要用到的技术点包括Block的hasTileEntity与createNewTileEntity方法、TileEntity的readFromNBT与writeToNBT、ITickable接口、IInventory接口让Reactable可以被管道、漏斗等外部系统理解、服务端到客户端的同步通道。如果这些词你现在看着还发懵问题不大接下来每一个我都会给出完整的代码和解释。2. 从Block到TileEntity的注册链路少了哪一步都不行2.1 注册TileEntityGameRegistry与createNewTileEntity的配合TileEntity不是一个独立的注册对象它必须依附在Block上。你在游戏里放置一个方块游戏才会根据这个方块对应的TileEntity类在内存中new一个实体出来再绑定到那个BlockPos坐标上。在1.12.2 Forge里第一步是用GameRegistry.registerTileEntity注册类与ID的映射关系。注意这里的ID是字符串要和TileEntity类绑在一起并且只在服务端注册一次即可。通常会写在Mod主类或者一个专门放注册逻辑的类里我习惯放在一个RegistryHandler类中。public class RegistryHandler { public static void registerTileEntities() { GameRegistry.registerTileEntity(TileEntityReactor.class, reactor_te); } }一个很容易被忽略的细节注册用的key不要用中文也不要太长。它会被写进存档如果你以后改了key旧存档里的区块加载时就会找不到对应的TileEntity类直接崩档或者丢数据。所以命名一定要稳定类似reactor_te这种短字符串最稳妥。第二步最关键在Block类里重写两个方法Override public boolean hasTileEntity(IBlockState state) { return true; } Override public TileEntity createNewTileEntity(World world, int metadata) { return new TileEntityReactor(); }这两个方法缺一不可。hasTileEntity决定这个方块在放置时要不要附带创建实体createNewTileEntity就是创建的具体逻辑。很多新手只写了hasTileEntity就迫不及待进游戏测试结果放置的方块完全没有数据存储能力查了半天发现是createNewTileEntity没实现。2.2 方块注册、模型绑定与掉落处理的连带改动Block本身还是要正常注册的和前面的基础方块一样setRegistryName、setUnlocalizedName、加到创造模式物品栏、注册ModelLoader资源。这些内容在系列前面已经写过这里就不重复贴代码了只强调几个和TileEntity相关的连带改动。第一如果你用了物品栏里的方块物品要确保在客户端注册对应的Item模型否则放置后模型可能变紫黑色。这属于老生常谈但在加了TileEntity之后容易忘。第二方块被破坏时TileEntity里的物品不会自动掉出来。你必须在breakBlock方法里自己处理掉落逻辑否则玩家用镐子挖掉反应器里面的燃料和产物会直接蒸发。Override public void breakBlock(World world, BlockPos pos, IBlockState state) { TileEntity tileEntity world.getTileEntity(pos); if (tileEntity instanceof TileEntityReactor) { TileEntityReactor reactor (TileEntityReactor) tileEntity; for (int i 0; i reactor.getSizeInventory(); i) { ItemStack stack reactor.getStackInSlot(i); if (!stack.isEmpty()) { spawnAsEntity(world, pos, stack); } } } super.breakBlock(world, pos, state); }这里用的是Forge提供的spawnAsEntity方法它负责在指定位置生成一个物品实体。注意循环里要先判断stack是否为空1.11以后ItemStack不允许出现Item为null的实例一律用ItemStack.EMPTY来表示空槽位判断方法就是isEmpty()。还有一个偏门但很重要的方法叫eventReceived它和Block与TileEntity之间的客户端事件通信有关。比如你想在客户端播放音效、粒子效果时可以通过world.addBlockEvent触发最终会调用到这个方法并转发给TileEntity的receiveClientEvent。1.12.2里很多机器型方块会重写它来实现“敲一下发个信号”的交互反应器这版先不涉及但你要知道有这个东西存在。3. 核心实现让TileEntity自己动起来的完整代码3.1 TileEntityReactor的字段设计与生命周期现在进入正题。TileEntityReactor需要继承TileEntity然后实现ITickable和IInventory。ITickable提供update方法IInventory提供物品操作相关接口。字段设计上我习惯把所有需要持久化的数据放到一起方便写NBTpublic class TileEntityReactor extends TileEntity implements ITickable, IInventory { private ItemStack[] inventory new ItemStack[2]; private int progress 0; private static final int MAX_PROGRESS 200; private ItemStack fuelStack; private ItemStack materialStack; public TileEntityReactor() { for (int i 0; i inventory.length; i) { inventory[i] ItemStack.EMPTY; } } }我这里故意用了一个独立的ItemStack[]数组而不是直接用两个单独的字段。为什么因为IInventory接口要求按槽位索引访问一个数组能让你写循环遍历、判断空槽位、实现自动注入逻辑时都方便很多后续做漏斗交互、管道连接时优势更明显。数组长度为20号槽放燃料1号槽放原料。实际上你完全可以扩展成3个、4个甚至用List管理动态槽位。进度值progress是处理进度的核心数值每次update会增加达到MAX_PROGRESS时触发一次产出。MAX_PROGRESS200意味着大概10秒完成一次处理1.12.2的tick是每秒20次。这是一个很“手感化”的参数需要根据你的机器定位调整。如果希望反应器是一个前期工具200刚好如果希望它是中后期才能效率化使用的设备可以抬高到400甚至更高。TileEntity的生命周期里有三个时刻最值得注意放置时构造、每个tick被update调用、被保存或加载时执行readFromNBT/writeToNBT。构造时把数组所有槽位初始化成ItemStack.EMPTY可以避免很多空指针问题这个习惯从第一天写TileEntity就要养成。3.2 核心update与配方判断服务端主循环的写法update方法是整个TileEntity的心脏也是最容易写出性能问题的地方。第一行永远是判断客户端因为TileEntity的tick逻辑必须在服务端执行客户端只需要被动接收同步数据。如果客户端也跑了这套逻辑就会出现两边数据不一致进度条抽风甚至服务端把客户端数据覆盖掉的严重bug。Override public void update() { if (world.isRemote) { return; } boolean needSync false; if (canProcess()) { if (fuelStack.isEmpty() || fuelStack.getCount() 0) { // 没燃料试着从燃料槽拿一组进来 if (!inventory[0].isEmpty()) { fuelStack inventory[0].splitStack(1); needSync true; } } if (!fuelStack.isEmpty() !materialStack.isEmpty()) { progress; if (progress MAX_PROGRESS) { processResult(); progress 0; needSync true; } } else { progress 0; } } else { progress 0; } if (needSync || progress 0) { markDirty(); } if (needSync) { world.notifyBlockUpdate(pos, world.getBlockState(pos), world.getBlockState(pos), 3); } }canProcess方法负责判断当前是否满足处理条件private boolean canProcess() { return !inventory[0].isEmpty() !inventory[1].isEmpty(); }这里简化了“什么能当燃料、什么能当原料”的判定。实际项目里我会建议单独写一个方法比如isFuel(ItemStack)来判断物品是否属于燃料列表而原料则对应一套配方表。配方表可以用MapItemStack, ItemStack或者更工程化的Recipe对象来实现。本篇为了聚焦TileEntity本身先用“非空即可处理”的逻辑占位后续加配方系统时只需替换canProcess和processResult内部实现其他部分完全不用动。processResult负责一个周期结束后真正“产出”什么东西private void processResult() { ItemStack result new ItemStack(Items.DIAMOND); // 示意实际应该按配方产出 inventory[0].shrink(1); inventory[1].shrink(1); inventory[2] result.copy(); fuelStack inventory[0]; materialStack inventory[1]; }这里有一个编程细节inventory[0].shrink(1)会就地减少堆叠数量但1.12.2里ItemStack是不可变对象shrink会返回一个新的栈所以更稳妥的写法是把shrink的结果重新赋值给原槽位。很多老教程里的stackSize--在新版MCP映射下已经不可用了。我实际写的时候更喜欢这样inventory[0] inventory[0].splitStack(inventory[0].getCount() - 1); if (inventory[0].getCount() 0) { inventory[0] ItemStack.EMPTY; }看起来啰嗦但它能保证任何情况下槽位都不会出现“只剩一个空ItemStack对象但还没变成EMPTY”的隐患。3.3 IInventory接口让漏斗和其他Mod认识你的机器为什么一定要实现IInventory因为这等于给你的TileEntity定了一套标准通信协议。原版的漏斗、投掷器、其他模组的自动化管道都通过IInventory来读取和写入物品。你只要实现这个接口反应器天然就能被原版漏斗自动供料这个效果会让人非常有成就感。IInventory在1.12.2里有一堆方法除了必须返回数组长度、判断空、获取槽位物品之外还有decrStackSize、setInventorySlotContents、markDirty、isUsableByPlayer等。写的时候我没必要全贴但有两个方法值得单独说。第一个是isItemValidForSlot(int index, ItemStack stack)它决定哪些槽位可以接收哪些物品。我建议在0号槽只接收燃料1号槽接收原料。这样漏斗尝试自动输入时不会把铁锭塞到燃料管里。Override public boolean isItemValidForSlot(int index, ItemStack stack) { if (index 0) { return isFuel(stack); } else if (index 1) { return isMaterial(stack); } return false; }第二个是markDirty()。每次物品数量变化、进度变化、状态变化时都要调用它作用是把TileEntity标记为“脏”提示存档系统在合适的时间保存数据。漏写markDirty的典型症状是服务端逻辑正常但区块保存后重启游戏物品或进度回到旧状态。IInventory接口里还有setField和getField这两个在配合GUI的Container做进度条同步时特别有用。1.12.2的Container里IPC通过getField读取数值、setField写入数值从而实现客户端和服务端的整数同步。我这一期的代码里没写GUI但这两个方法还是要实现留好扩展位下一期做GUI时直接就能用。4. 数据同步原理解决进度条不动、界面上数值不对4.1 三条同步通道的职责划分加载、更新、渲染TileEntity的数据同步是新手最容易翻车的地方因为这里涉及三条完全不同的通道每一条都对应不同的游戏阶段。第一条是区块加载同步。当玩家走进一个已加载的区块时客户端需要知道这个区块里所有TileEntity的完整状态。每条通道都由一个核心方法负责getUpdateTag读写出用于同步的完整的NBT数据然后通过SPacketUpdateTileEntity发送。这条通道会调用getUpdatePacket和onDataPacket方法。第三条是区块渲染同步。当客户端开始渲染一个区块时它不会直接拉取服务端的所有数据而是依赖服务端在方块状态变化、TileEntity状态变化时主动推送。实际开发中最常用到的就是第二条方块更新同步。在服务端改变进度、改变工作状态后调用world.notifyBlockUpdate就可以触发一次客户端更新。这个方法的第三个参数是flags推荐用3表示同时通知邻居更新和自身更新。4.2 让进度条动起来的标准做法三次强调nottifyBlockUpdate进度条动不起来或者整个工作方的模型不随状态切换改变通常都是同一个原因服务端改了数据但没通知客户端。我处理这类问题有个标准三步走。第一步服务端逻辑里修改关键字段后立即markDirty保证之后区块保存不丢数据。第二步调用world.notifyBlockUpdate(pos, state, state, 3)主动通知客户端这个坐标的方块状态需要刷新。第三步在TileEntity里实现getUpdatePacket方法返回一个SPacketUpdateTileEntity同时在onDataPacket里把收到的NBT读取回来。三件事配合才是一条完整的数据同步链路。Override public NBTTagCompound getUpdateTag() { return writeToNBT(new NBTTagCompound()); } Nullable Override public SPacketUpdateTileEntity getUpdatePacket() { return new SPacketUpdateTileEntity(pos, 1, getUpdateTag()); } Override public void onDataPacket(NetworkManager net, SPacketUpdateTileEntity pkt) { readFromNBT(pkt.getNbtCompound()); world.markBlockRangeForRenderUpdate(pos, pos); }很多新手只实现了getUpdateTag忘了getUpdatePacket。结果就是打开存档时能读到正确数据但游戏运行中客户端拿不到变化进度条自然不动。记住getUpdateTag负责“首次加载时同步全量数据”getUpdatePacket负责“运行中主动推送更新”两者都要有。onDataPacket里的最后一行也不可省略。readFromNBT只是把数据写回TileEntity字段但客户端渲染层并不知道数据变了。markBlockRangeForRenderUpdate会主动标脏渲染区域让客户端重新构建这一段的渲染状态。没有这行模型切换有时就会卡在旧状态上玩家的体感就是“反应器一直在工作但外观一直显示待机”。5. 渲染方案进度条与工作动画怎么做才不卡5.1 方案一用getActualState做多态模型推荐1.12.2里做机器工作状态的渲染最常用也最推荐的做法是让TileEntity的状态驱动IBlockState的Property。具体思路是在Block类里定义一个PropertyBool类型的working属性重写getActualState方法时查询该坐标的TileEntity运行状态把working设置为true或false。public static final PropertyBool WORKING PropertyBool.create(working); Override public IBlockState getActualState(IBlockState state, IBlockAccess world, BlockPos pos) { TileEntity te world.getTileEntity(pos); boolean working false; if (te instanceof TileEntityReactor) { working ((TileEntityReactor) te).isWorking(); } return state.withProperty(WORKING, working); }isWorking方法可以从TileEntity内部状态来定义比如燃料槽和原料槽都不为空或者progress 0。我建议用progress 0这个判定因为它更真实地反映了“正在处理进度中”的语义。然后你需要给这个方块准备两个JSON模型文件分别对应workingtrue和workingfalse。通过blockstate JSON里的multipart规则把方块在working不同取值下用不同模型渲染。这是很多“机器在运行”外观效果的标准做法。这套方案的优点是性能极好几乎没有任何运行时开销客户端拿到的只是两个不同静态模型。缺点是做不了连续动画比如进度条长度平滑增长就无能为力。如果你的反应器外观想展示一个“进度条逐步填满”的视觉效果那就得上方案二。5.2 方案二TileEntitySpecialRenderer做自定义渲染TESR的思路是完全绕过普通方块模型由自己写OpenGL调用逻辑来渲染TileEntity的形状和动态效果。1.12.2里有TileEntitySpecialRenderer 这个抽象类你重写render方法绑定自己加载的模型然后根据TileEntity的progress字段来计算旋转角度、位移、亮度、粒子效果等。TESR能做真正的平滑动画这是它比getActualState强的地方。但代价也很明显每帧每个TileEntity都要调用你的render方法如果你不做优化成千上万个机器同时在工作会直接把帧率拉垮。如果你决定用TESR我的建议是优先尝试FastTESR。FastTESR会跳过一些常规的渲染状态准备性能更好代价是你不能使用大部分原版渲染辅助方法而且要自己管理OpenGL状态。新手不推荐一上来就写FastTESR先学会普通TESR理解了渲染流程后再优化不迟。还有一个必坑点TESR必须独立注册不能依赖方块注册流程。1.12.2里用ClientRegistry.bindTileEntitySpecialRenderer(TileEntityReactor.class, new RenderReactor()); 放在客户端初始化阶段执行即可。6. 排查记录我在这套代码上踩过的坑6.1 六个常见问题速查表这几个问题基本上每个做TileEntity的新手都会遇上我把它们整理出来遇到相同现象直接对着查。问题现象常见原因解决方案放入燃料和原料后机器完全不动update方法里没写world.isRemote判断在update第一行return掉客户端分支重启游戏后进度和物品丢失槽位数组没写入NBT或没调用markDirty检查readFromNBT/writeToNBT确保每个槽位都序列化客户端进度条数值永远是0没实现getUpdatePacket补上getUpdatePacket和onDataPacket玩家挖掉方块内部物品消失没重写breakBlock处理掉落遍历IInventory调用spawnAsEntity模型不切换工作态看不出来getActualState的working属性没监听TileEntity状态在getActualState里查询shape并确保update里调用了notifyBlockUpdate漏斗能输出但不能输入isItemValidForSlot判断过严检查槽位索引和物品判断逻辑是否合理6.2 几个让代码更稳的长期习惯做TileEntity开发时间久了我总结出几条保命经验。第一所有持久化字段集中管理不要散落在类的各个角落。我见过很多项目把进度值、燃料时间、配方ID都写成独立字段写NBT时漏一个补一个最后维护成本高到爆炸。统一放到一个区域每次改字段都顺手检查NBT的读写是否同步更新。第二永远不要在update里直接new ItemStack。游戏每秒钟会调用20次update如果你的处理逻辑里每tick都构造新对象GC压力会很明显。最好在配方判定阶段缓存结果或者用同一组ItemStack复用。原版的熔炉实现里有很多细节可以学习它用一个私有数组缓存当前烧炼结果这是很成熟的设计思路。第三明确区分“服务端数据”和“客户端数据”。progress这个字段在客户端的存在意义只是为了显示和渲染不应该参与真正的逻辑判断。如果你在canProcess里用了客户端同步过来的progress做判定那服务端也会跟着出问题。唯一的原则就是业务逻辑要模拟在服务端的常用经验就是给update方法加上一个“是否客户端”的判断以后所有的逻辑判断都用服务端独立计算的字段客户端只读现成结果。第四方块更新通知不能滥用。如果你每个tick都调world.notifyBlockUpdate那任何在附近观察的玩家都会收到大量网络包人多的时候服务器会卡。我建议只在几个关键节点触发同步开始处理时、处理完成时、燃料或原料耗尽时。中途的progress增长不需要实时同步等到状态变化时一次性同步即可。很多机器模组之所以卡就是因为它们把同步频率设成了每个tick一次。7. 扩展方向从一台反应器到一个完整的机器体系当你把反应器的TileEntity逻辑跑通以后后续的扩展就只是量变到质变的问题了。最自然的几个方向我在文章里也提过第一加入GUI和Container让玩家能手动放置和取出物品第二加入真正的配方系统用Map或者JSON配置文件来定义“什么燃料烧什么材料”再和合成表对应起来第三加入红石控制比如用IBlockState里的powered属性让反应器在收到红石信号时才工作第四加入多方块结构让反应器需要一个外壳或配套方块才能运行这就接近一些大型模组的机器体系了。我个人的建议是先把无GUI版的完整同步链路彻底吃透再一步步加上这些壳子。TileEntity的核心难点永远不在表面花活而在数据、逻辑、同步这三者的协调。反应器这期把这三件事都覆盖到了下一期加GUI时你会发现前面积累的这些经验全部用得上。