写模组到现在第三十九篇,终于该碰真正的核心机制了。前面几篇基本都在和Block、Item、模型这些静态的东西打交道,而这一篇要讲的TileEntity,本质上就是让方块“活”过来的关键——它可以让一个方块拥有自己的数据、自己的状态、自己的运行逻辑。
我用“反应器”这个名字来作为载体,是因为这类“机器型方块”几乎包含了一个TileEntity能遇到的所有问题:内部要存物品(输入槽、输出槽、燃料槽),要有处理进度,要和服务端逻辑配合,还要把进度同步到客户端让玩家在界面上看到进度条在动。能把反应器做出来,就等于掌握了模组开发里最常用的一类方块实体。这篇文章会从需求拆解开始,把注册、NBT存储、服务端tick逻辑、数据同步、渲染方案全部过一遍,最后一节是我实际踩过的一些坑,照着做就能绕开。
1. 先用需求倒推:反应器的核心难点其实只有三点
1.1 为什么必须引入TileEntity:BlockState搞不定的三件事
先回答一个很多人刚接触时都会问的问题:做个反应器,直接用一个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 注册TileEntity:GameRegistry与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提供物品操作相关接口。
字段设计上,我习惯把所有需要持久化的数据放到一起,方便写NBT:
public 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接口要求按槽位索引访问,一个数组能让你写循环遍历、判断空槽位、实现自动注入逻辑时都方便很多,后续做漏斗交互、管道连接时优势更明显。数组长度为2,0号槽放燃料,1号槽放原料。实际上你完全可以扩展成3个、4个,甚至用List管理动态槽位。
进度值progress是处理进度的核心数值,每次update会增加,达到MAX_PROGRESS时触发一次产出。MAX_PROGRESS=200意味着大概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)来判断物品是否属于燃料列表,而原料则对应一套配方表。配方表可以用Map<ItemStack, 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模型文件,分别对应working=true和working=false。通过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时你会发现前面积累的这些经验全部用得上。