模组开发

引擎自带一个 javassist agent 内核:不改游戏本体、不替换原引擎,只在原 transformer 之后追加自己的一层注入, 并把「模组网络通道 / 服务端点击钩子 / 方块新增同步 / 世界生成」开放给模组。

6 处内核注入ModNet 收发通道 方块新增自动同步ServiceLoader 发现 mod

内核注入了什么

全部是 insertBefore(一处 insertAfter),方法体一行都没改。

类 # 方法插进去的代码作用
服务端PlayerManager#handlePlayerClickPacket if (ModHooks.onServerClick(...)) return;让模组接管放/挖
服务端BlockManager#setBlockKernelBlockSync.beforeServerSetBlock(...)记录被改的格子
服务端BlockManager#onUpdateKernelBlockSync.flushServerBlockAdds($0)广播方块新增(引擎缺的就是这一环)
服务端BlockOutputter#generateChunk末尾KernelWorldGen.decorate($0,$1,$2)区块生成时种矿脉 / 矮树
客户端BlockManager#registerKernelBlockSync.armClient($0)接入方块同步
客户端Manager#update(float)KernelBlockSync.flushClientAdds()渲染线程落地(安全改 Scene2D)
顺序很关键:原引擎的 transformer 忽略传入的 byte[]、直接从 classpath 重新读类, 所以内核必须注册在它之后,并基于传入字节修改,否则会把原引擎注入的 loadServer / loadClient / tickAll 覆盖掉。

ModNet:模组网络通道

引擎原本只给模组 manager,没有任何发包/收包入口。

// 服务端
ModNet.bindServer(api);                                   // api 就是 GameWorld
ModNet.listen(300, 300, MyPacket.class, (conn, pkt) -> { ... });
ModNet.broadcast(new MyPacket(...));                     // 服务端 → 所有客户端
ModNet.onServerClick((conn, pkt, id, cx, cy, lx, ly, type) -> {
    if (type != 1) return false;                        // 0=左键挖;返回 false = 交给引擎
    ...自己放置...
    return true;                                            // 跳过引擎硬编码的"放泥土"
});

// 客户端
ModNet.bindClient(api);
ModNet.register(300, 300, MyPacket.class);                   // 发送前必须注册
ModNet.send(new MyPacket(...));

类型 id 要避开这些

  • 引擎:101-104·106·107/组 100、151·152/150、201-203/200、253/250
  • 内核:310/310(方块新增同步)
  • hotbar:300 SelectRequest、301 PlaceRequest(已废弃)、302 背包同步、303 合成请求

包类的硬性要求

  • public 字段 + 无参构造(引擎用 Jackson 序列化,且不写类名
  • 收发两端字段必须一致(同一个 jar 最省事)
  • 收包是按 groupId 分发的,同组回调要自己 instanceof 判类型

三步做一个 mod

  1. 生成项目双击 开发\工具\新建Mod项目.bat,输入包名(小写字母+数字,如 planttools), 它会自动改包名/类名/服务声明并试编译一次。
  2. 写逻辑服务端改 registerServer(ServerAPI api),客户端改 registerClient(ClientAPI api)。想在每 tick 做事用 ModLoader.registerTick(...)
  3. 编译部署双击该项目的 编译打包.bat:编译 → 校验入口类 → 打包 → 自动拷到 mods\。然后重启服务端/客户端看日志里的 Mod registered on server: ...
入口声明在 src/META-INF/services/dev.YN.PlantGame.core.api.mod.IMod(写入口类的全名)。 改类名/包名后必须同步改它,否则 ServiceLoader 找不到。

踩过的坑清单

这些都是实测撞出来的,写 mod 前值得先看一眼。

① 模组写世界的时机

ModLoader.tickAll() 跑在 GameWorld.onTick() 之后,而引擎的放/挖点击包是在 onTick() 里按到达顺序处理的。在自己的 tick 回调里写世界会越过这个顺序: "右键放 → 同一 tick 左键挖"就变成"挖掉又被放回来"。放置请走内核点击钩子。

② 区块没初始化会静默丢弃

Chunk.setForegroundBlock 在方块数组还是 null直接 return: 不报错、不抛异常、也没日志。写完一定要 getBlockByBlockPos 读回校验。

③ 背景方块没有 isBackground 标记

地形生成写背景层用的是 Chunk.setBackgroundBlock(),它只往数组里塞、从不设置 block.isBackground = true。判断"能不能挖到东西"要看前景层,别看这个标志。

④ 碰撞矩形是"区块局部坐标"

客户端拿到矩形后会自己加 chunkIndex * chunkSize。传世界坐标的话 chunk(0,0) 恰好相等看不出问题,其它区块整个偏一个区块宽 —— 表现就是"有时有实体、有时没有"。

⑤ 自绘 UI 要自己开 GL 混合

引擎画完世界/UI 后会 glDisable(GL_BLEND),而 ShapeRenderer 只在构造时开一次混合。 所以每次 shapes.begin() 前自己 glEnable(GL_BLEND),否则半透明矩形会变成实心 (踩过:遮罩把整屏刷成纯黑)。

⑥ 服务端"只发包"也要先注册

MessageCodec.encode 查不到类型 id 会抛异常。如果这个包是在点击钩子里发的, 异常冒出去会被内核当成"钩子拒绝接管",引擎就会去放它默认的泥土 —— 表现是"我什么都没干,方块自己变了"。

按键别用 E / Q:引擎自己的 client.World 用它们做相机缩放。 mod 想开面板可以用 I 之类没被占用的键,并用 Gdx.input.isKeyJustPressed 轮询 (别去抢游戏的 InputProcessor)。

没开游戏也能验

开发\工具\无头自测\ 里 6 个程序 + 一个 自测.bat,不需要人进服务器。

测试验证什么期望
KernelBlockSyncTest方块新增同步(引擎自己也放泥土那条路)地形阶段 0 个包;右键后恰好 1 个包;碰撞矩形是局部坐标
KernelOreGenTest矿脉生成四种矿石都出现、数量 > 0、碰撞信息完整
KernelProductionTest掉落 / 合成台 / 消耗 / 防刷failures=0
KernelDigRaceTest放/挖顺序竞态restoredAfterDig=0ghost=0、旧通道已失效
KernelClickTest服务端点击钩子接管放置日志出现 [Hotbar] 点击放置 … 读回 …
ClientModHarness / ServerModHarness假 API 走真实 mod 路径自测.bat 的 6 步

打开完整开发指南(Markdown) 内核扩展说明 hotbar mod 说明