做1.12.2模组开发的老哥们应该都有同感TileEntity方块实体是绕不开的一道坎。前三十八期我们搞过基础方块、搞过工具、搞过合成表但真正让一个方块“活”起来能存东西、能运作、能跟玩家交互靠的就是TileEntity。这期要做的“反应器”就是一个标准的TileEntity机器不搞花哨特效重点是把整套机制捋清楚怎么注册、怎么存物品、怎么跑进度、怎么跟客户端同步、怎么处理自动化交互。做完这个再往后写任何机器类Mod都会轻松很多底子就通了。这篇的定位很明确带任务、带完整代码思路适合已经会写普通方块、但第一次碰TileEntity的开发者。我按自己的开发习惯走一遍完整流程从概念拆解到实操到复盘问题尽量不跳步。读到结尾你应该能独立写出一台接口完整、运行稳定、可配配方的反应器。1. 反应器与TileEntity核心概念解析1.1 为什么选TileEntity而不是简单方块类很多新手最容易犯的错是把所有逻辑都往Block类里塞。Block在Minecraft里本质是一个“静态模板”同一个方块的所有实例共享同一个Block对象它没有自身的状态存储也不该保存动态数据。你可以在Block里写方法、判断方向、设置状态但如果你想让某个机器“记住自己肚子里有什么”那就必须要用TileEntity。我个人的理解是Block是外观和碰撞行为TileEntity是内脏和记忆。反应器需要一个燃料槽、一个产物槽、一个内部的燃烧进度变量这些数据只属于这一个方块实例所以必须落在对应的TileEntity对象里。另一个决定性因素是游戏刻更新普通Block没有tick更新入口而TileEntity继承了update()方法每个游戏刻都能被调用机器才能持续推进反应进度。对比一下能力项普通BlockTileEntity存储数据NBT不行可以每tick逻辑更新需要BlockEvent手动通知原生update()自动/管道交互困难实现IInventory即可客户端同步仅BlockState变化可以自定义描述包从这期开始凡是涉及“机器”“容器”“动态渲染”的一律建议走TileEntity否则后期加功能会把自己恶心到。1.2 反应器方案设计需求拆解动手写代码之前先把需求拆明白。我的反应器设计如下两个物品槽0号是燃料槽1号是产物槽。燃料不是直接烧掉就给产物而是需要积累热量进度。每种燃料提供不同的“燃烧值”燃料消耗完时把燃烧值换成进度。当进度累积到目标阈值且产物槽放得下产物就产出一个物品进度清零。机器运行中需要把进度值同步到客户端GUI里画进度条。能接漏斗、管道支持自动输入燃料、自动抽出产物。这套设计有什么用它比一个简单的熔炉式“烧炼”更有扩展空间。你可以在后续给反应器加液体燃料、加多级升级、加产出概率核心的TileEntity框架不用动只需要扩展配方和槽位逻辑。所以这期重点不是“写死一个合成”而是把框架和思路立住。1.3 数据持久化思路TileEntity必须实现readFromNBT和writeToNBT否则存档一读一写数据全丢。这里最容易出问题的不是不会写而是漏写。我的做法是把所有成员变量全部序列化一个都不放过。物品数组、当前进度、燃烧值、燃料存量、剩余时间全写进去。哪怕有些字段暂时用不上写进去也就几行代码但漏掉一个玩家一重启服务器机器就变空壳这种低级Bug排查起来非常痛苦。2. 注册框架与基础类的搭建2.1 创建TileEntityReactor主类先搭主类继承TileEntity并实现IInventory。这里直接给个骨架后面的逻辑逐步往里填。public class TileEntityReactor extends TileEntity implements IInventory { private ItemStack[] inventory new ItemStack[2]; private int processTime; // 当前累计进度 private int maxProcessTime 200; // 需要200tick出一件 private int burnTime; // 当前剩余热量 private int burnTimeTotal; // 当前燃料总热量用来画燃烧图标 public TileEntityReactor() { Arrays.fill(inventory, ItemStack.EMPTY); } }字段定义先这么给后面看具体需求还可以加。注意maxProcessTime我暂时写死但实际开发中更好的做法是从某个配置文件读取或者作为静态字段统一管理避免后期改逻辑时到处找魔法数字。IInventory接口有9个方法必须实现可以先放空实现IDE一键生成后面再逐行补全。这里只说几个必须认真对待的方法getStackInSlot、setInventorySlotContents、decrStackSize。这三个方法是所有自动化交互的基础入口写错一个漏斗和管道就可能给你把物品吞了。2.2 注册与方块关联TileEntity有了还不算完必须注册并且要在对应的Block里绑定。注册做在preInit阶段Mod.EventHandler public void preInit(FMLPreInitializationEvent event) { GameRegistry.registerTileEntity(TileEntityReactor.class, tile_entity_reactor); }Block侧更关键在方块类里重写两个方法Override public boolean hasTileEntity(IBlockState state) { return true; } Override public TileEntity createTileEntity(World world, IBlockState state) { return new TileEntityReactor(); }hasTileEntity不返回true那你原有的TileEntity就是死的永远不会被创建。createTileEntity就是字面意思在方块被放置时由游戏自动调用你也可以在里面做初始化操作但我个人习惯放构造器里做保持这里干净。另外提一个老坑如果方块被破坏默认情况下TileEntity数据会被清掉。要保留物品和进度你必须重写breakBlock并手动把物品掉落或转移到其它地方否则就会灰飞烟灭。很多新人很困惑为什么熔炉敲掉还能留东西其实那是原版代码写了掉落逻辑你写机器也要照做。2.3 描述包同步客户端如何知道进度这是第一次写TileEntity的人最容易懵的地方。服务端是真正的主角所有逻辑都在服务端跑但GUI进度条、方块活跃状态显示都在客户端所以必须把数据从服务端推给客户端。1.12.2的同步方式主要是getUpdateTag、handleUpdateTag和getUpdatePacket、onDataPacket两套机制。getUpdateTag用于区块加载时整包同步。getUpdatePacket用于运行时增量同步。我的反应器只在GUI打开时同步进度所以用后者就够了。在服务端的update()里检测到进度变化就调用Override public SPacketUpdateTileEntity getUpdatePacket() { NBTTagCompound nbt new NBTTagCompound(); nbt.setInteger(processTime, processTime); return new SPacketUpdateTileEntity(pos, 0, nbt); } Override public void onDataPacket(NetworkManager net, SPacketUpdateTileEntity pkt) { NBTTagCompound nbt pkt.getNbtCompound(); processTime nbt.getInteger(processTime); }注意onDataPacket在客户端执行你没有主动请求时它不会来。所以需要有个“何时发送”的判断我后文会在更新逻辑里详细说。2.4 方块状态与朝向如果机器只有“正”“反”两种视觉状态那就不需要复杂的方向系统但反应器想接入管道就必须有明确的前后左右。1.12.2处理朝向的老一套是Override public EnumBlockRenderType getRenderType(IBlockState state) { return EnumBlockRenderType.MODEL; } Override public IBlockState getStateFromMeta(int meta) { return getDefaultState().withProperty(FACING, EnumFacing.getHorizontal(meta)); } Override public int getMetaFromState(IBlockState state) { return state.getValue(FACING).getHorizontalIndex(); }朝向属性和放置时的方向设置是配套的但这不是本期核心各位按需实现即可。如果实在不想做朝向至少把getStateFromMeta写对否则放下来的机器永远是同一面脸。3. 核心逻辑与实现细节3.1 燃料判定与配方体系反应器不能烧所有东西得有燃料白名单。最简单的方式是用一个静态方法判断硬编码物品名或按OreDictionary匹配。你可以说这很“土”但它是理解整个配方的第一步。public static boolean isFuel(ItemStack stack) { if (stack.isEmpty()) return false; Item item stack.getItem(); return item Items.REDSTONE || item Items.GLOWSTONE_DUST; }正式做Mod时我更推荐把燃料表抽成一个类用HashMapItem, Integer保存物品和燃烧值。这样加新燃料只需要注册一行不必改核心代码。public class ReactorFuelRegistry { private static final MapItem, Integer FUELS new HashMap(); public static void registerFuel(Item item, int burnValue) { FUELS.put(item, burnValue); } public static int getBurnValue(ItemStack stack) { if (stack.isEmpty()) return 0; Integer v FUELS.get(stack.getItem()); return v null ? 0 : v; } }这种设计对后面的扩展非常友好而且你在配方表、产物表也能复用同一套思路。我的经验是一个机器类对应一个独立的Registry不要把所有配方都堆在同一个类里不然 Mod 大了以后你连自己都找不到配方在哪儿。3.2 反应器update()主循环逻辑这是全篇最核心的一段。trust me把这段吃透所有机器类你都能写。Override public void update() { super.update(); if (world.isRemote) return; boolean burning burnTime 0; boolean inventoryChanged false; if (burnTime 0) { burnTime--; } if (!inventory[1].isEmpty() inventory[0].isEmpty()) { if (burnTime 0) { int burnValue ReactorFuelRegistry.getBurnValue(inventory[1]); if (burnValue 0) { inventory[1].shrink(1); burnTime burnValue; burnTimeTotal burnValue; inventoryChanged true; } } } if (burnTime 0) { increaseProcessTime(); inventoryChanged true; } int needed getMaxProcessTime(); if (processTime needed !inventory[0].isEmpty()) { craftItem(); cancelProcess(); inventoryChanged true; } if (inventoryChanged) { markDirty(); if (!world.isRemote) { int i this.getWorld().getRedstonePowerFromNeighbors(pos); boolean flag this.getWorld().isBlockPowered(pos); if (flag ! this.previousRedstoneState) { this.previousRedstoneState flag; } } } }我抽掉了几个辅助方法它们对应下面的逻辑。increaseProcessTime就是普通累加private void increaseProcessTime() { processTime; if (processTime maxProcessTime) { processTime maxProcessTime; } }craftItem是产出private void craftItem() { if (inventory[0].isEmpty()) return; ItemStack result getRecipeOutput(inventory[0]); if (inventory[1].isEmpty()) { inventory[1] result.copy(); } else if (inventory[1].isItemEqual(result) inventory[1].getCount() result.getCount() getInventoryStackLimit()) { inventory[1].grow(result.getCount()); } else { return; } inventory[0].shrink(1); }我故意把燃料槽和产物槽的顺序定为[0]产物, [1]燃料这只是约定代码跟着反过来也行。重点是燃料槽空了才能放新燃料这个限制很多人会忽略导致机器一直不会补充燃料。3.3 燃烧值与最大进度的数值关系反应器的核心数值就两个burnTime和processTime。前者是当前燃烧剩余后者是当前合成进度。你可以把它们想象成两个水桶燃料桶每tick漏一单位水漏完就没了。只要燃料桶还在漏水进度桶就持续进水。进度桶满了做一件产物进度桶清空。因此生产速率事实上由燃料燃烧值决定。如果把红石的燃烧值设为100一次投料就能维持100tick的持续加工。而maxProcessTime设成200那两份红石才能出一件产物。这个数值设计要配合你的游戏经济太短是白给太长是折磨。3.4 IInventory的核心方法实现细节IInventory的细节确实多但搞懂它的设计逻辑后就无所谓了。我列几个关键方法的参考实现Override public ItemStack decrStackSize(int index, int count) { ItemStack stack inventory[index]; if (stack.isEmpty()) return ItemStack.EMPTY; ItemStack result stack.splitStack(count); if (stack.isEmpty()) inventory[index] ItemStack.EMPTY; markDirty(); return result; } Override public void setInventorySlotContents(int index, ItemStack stack) { inventory[index] stack; if (stack.getCount() getInventoryStackLimit()) { stack.setCount(getInventoryStackLimit()); } markDirty(); } Override public void markDirty() { super.markDirty(); validate(); }decrStackSize要小心处理split后的残留栈如果split后原槽变空必须把槽设成ItemStack.EMPTY否则后面遍历槽位的时候会拿到一个数量为0但非空的引用造成各种奇怪问题。还有一个必须注意的isItemValidForSlot方法决定了自动化能否把物品放进槽位。你要保证燃料槽只收燃料产物槽什么都不能主动放进Override public boolean isItemValidForSlot(int index, ItemStack stack) { return index 1 ReactorFuelRegistry.getBurnValue(stack) 0; }不做这个限制漏斗会把泥土都塞进你燃料槽那画面太美。3.5 GUI容器与检测服务端进度的正确姿势如果你不给反应器做GUI那TileEntity基本白做因为玩家没法直观看到它的状态。写GUI容器时服务端和客户端各需要一个Container类但进度条的同步有两种思路在Container里重写detectAndSendChanges把进度值放进ICrafting监听列表。在GUI的updateScreen里直接调tileEntity.getField。我个人推荐getField/setField方案代码简单且通用Override public int getField(int id) { switch (id) { case 0: return processTime; case 1: return maxProcessTime; case 2: return burnTime; case 3: return burnTimeTotal; default: return 0; } } Override public void setField(int id, int value) { switch (id) { case 0: processTime value; break; case 1: maxProcessTime value; break; case 2: burnTime value; break; case 3: burnTimeTotal value; break; } }GUI里画进度条时就int progress this.tileEntity.getField(0) * 24 / this.tileEntity.getField(1);这里的24是你进度条贴图的像素宽度具体尺寸按自己素材来。老哥别再问为啥进度条不动了多半是detectAndSendChanges没触发或者你在服务端写逻辑但忘了调markDirty。4. 渲染与客户端表现4.1 方块状态的视觉反馈反应器至少要有一个状态工作中和待机中。最简单的方案是用BlockState的布尔属性public static final PropertyBool ACTIVE PropertyBool.create(active);在update()中根据burnTime0去更新世界内的BlockState。但要注意反复setBlockState会触发客户端渲染更新频繁切换会卡所以我建议if (getWorld().getBlockState(pos).getValue(ACTIVE) ! (burnTime 0)) { world.setBlockState(pos, world.getBlockState(pos).withProperty(ACTIVE, burnTime 0), 3); }flag3意思是“同步给客户端并rerender”。这个操作不要写进每tick无条件执行的地方必须检测状态真的变化了才执行。不然机器每tick都在发渲染数据包帧率会很难看。4.2 进阶模型与TESR的选择如果你的反应器只是“亮起来”的材质切换需求完全不需要TESR。给BlockState里加个材质数组或者用stateMapper就够了性能最好。TESR那种每帧走自定义渲染的是在你确实需要显示流体液位、特殊动画时才值得用。我这次没上TESR。理由很简单能用资源包/方块状态解决的效果就不要写代码渲染。很多同行写TileEntity渲染渲染到崩溃其实原因就是选择了不必要的TESR。4.3 GUI贴图与进度条绘制GUI这块建议直接照原版熔炉的框架抄原因很简单熔炉的GUI是Mojang打磨过的成熟代码你模仿它的容器布局和绘制逻辑天然不会出大问题。绘制进度条时常见的坑是drawTexturedModalRect的坐标算错int progress (int)((float) tileEntity.getField(0) / tileEntity.getField(1) * 22); drawTexturedModalRect(x 79, y 34, 176, 0, progress, 17);22是进度条总宽度176, 0是贴图里进度条起始坐标这个坐标不是随便写的要和你PNG图对应。坐标对不上画出来的就是花屏或者移位。建议先把贴图导出到PS或者画图工具里量一次。5. 常见问题与排查技巧实录5.1 进度不同步GUI进度条不动这个我见过无数次先说结论绝大多数是因为服务端逻辑在跑但客户端TileEntity里的processTime字段根本没被更新。原因就一个你没有正确发送SPacketUpdateTileEntity或者服务端压根没有在进度变化时markDirty。我自己的写法是只要increaseProcessTime执行了立刻把inventoryChangedtrue。然后在update末尾统一发送数据包而不是每tick都无脑发。这样可以少一半的包。判断是否发送还可以加一个简单阈值只有当processTime % 10 0时才发。道理很简单GUI不是视频播放器10tick刷新一次进度条肉眼看不出区别但网络开销能少很多。提示如果你要做的Mod对客户端/服务端同步要求很高建议好好读一下TileEntity源码里的canUpdate()相关的优化点别把无脑同步当成正确写法。5.2 崩溃方块实体没有注册FML常见报错java.lang.IllegalStateException: Failed to load block entity data原因基本就是GameRegistry.registerTileEntity忘记写了或者ID写错。注意ID要全局唯一不能和其它Mod冲突。我会把注册ID写在常量里统一管理这样写错ID的概率会低很多。5.3 自动化管道不工作漏斗、热力管道这类自动化本质是调用IInventory的方法来存取物品。如果你发现管道不识别反应器优先排查以下两点getSlotsForFace返回槽位数组没写就成一个空数组。canInsertItem和canExtractItem没按方向过滤。Override public int[] getSlotsForFace(EnumFacing side) { return new int[]{0, 1}; } Override public boolean canInsertItem(int index, ItemStack stack, EnumFacing direction) { return isItemValidForSlot(index, stack); } Override public boolean canExtractItem(int index, ItemStack stack, EnumFacing direction) { return index 0; }这里我统一允许了两侧插拔如果机器有严格的输入面和输出面你要自己按direction做判断别把四个方向全设为可抽产物那样自动化很容易把燃料槽也抽走了。5.4 燃烧逻辑与产物同时进行的问题我见过不少人把燃料消耗写在最前面然后下一行直接判断burnTime 0就加进度结果导致首tick白白扣掉燃料却没有任何进度。数值一调发现机器效率怎么都对不上。我的约定是先处理上一个tick遗留的burn再消耗新燃料然后在同一次update内用新的burn状态驱动进度。字段更新顺序是burnTime--→ 补充燃料 →increaseProcessTime。这样一次tick内逻辑闭环不会出现跨tick的间隙。5.5 性能优化建议一台机器没问题一百台机器就有感觉了。TileEntity最耗性能的地方就是每tick更新。如果整个方块阵列都在tickCPU会很吃力。1.12.2的常见优化方案用canUpdate控制是否参与tick比如活塞那种不动的机器可以只保存状态。闲置时直接返回不做任何计算。减少world.setBlockState次数状态不变就不设置。发送数据包前做距离检查比如只有16格内才发。实际上一台反应器的工作声和粒子效果如果做太多也吃性能不过本期没有加等后续做进阶效果时再聊。6. 一些模组开发经验TileEntity开发最怕的就是把客户端和服务端逻辑混在一起。我在开发中反复被坑后来总结出一条死规则凡是world.isRemote为真的就直接return所有数据修改永远只在server端执行客户端只拿数据来显示。这条守住至少一半的帧掉线Bug就不会出现在你身上。另一个心得是测试别嫌麻烦。每次改TileEntity逻辑至少覆盖三个场景放下去不处理任何操作、放下去塞满燃料、放下去用漏斗运输。每步都存档重进一次验证NBT持久化再打开GUI看进度条。这几个动作反复做多了你对数据同步的理解会远超只看教程的水平。最后我强烈建议你多下载几个成熟的机器Mod源码来读。不抄就看它的TileEntity如何组织字段、如何处理自动化、如何做可见状态。每个成熟的Mod都会有独到的取舍思路特别是那些流量大、Burn复杂的老牌科技Mod可读性非常强。结合本期学到的框架再回头看它们代码你会觉得自己瞬间从“照葫芦画瓢”进阶到“看得懂它的设计意图”。这次的反应器只是TileEntity领域的第一块拼图。下一期可以在这个框架上继续扩展比如给它加个输入输出液体接口或者做成多方块组件的中枢节点。核心机制不变变的是你在这套骨架上长出来的血肉。有兴趣的老哥可以直接顺着现有代码往下延遇到不懂的随时翻这期来对框架思路。
阅读完成 · 觉得有帮助?