这是“Minecraft 1.21.1 NeoForge 开发笔记”的第二篇。上一篇我们把 MDK 工程跑通,确认开发环境没问题了,接下来就该回答一个逃不掉的问题:工程里那些文件和目录到底是干嘛的?那个被@Mod标记的类凭什么就是整个模组的入口?如果你正在用 NeoForge 给 1.21.1 写第一个模组,这篇笔记就是给你准备的。不堆概念,只讲我实际开发里反复用到、也反复踩过坑的部分:项目文件结构、三个关键配置文件、Mod 主类的工作原理,最后带一个从主类出发注册第一个物品的完整示例。看完你至少能自己解释“为什么加一个物品要动这么多文件”。
1. 先认识项目文件结构,避免“照着敲都找不到地方”
模组开发跟普通 Java 项目最大的区别就是:你写的代码不是独立运行的,它要被塞进 Minecraft 这个庞然大物里。Minecraft 怎么认识你的模组,全靠一套约定好的目录和文件。1.21.1 的 NeoForge MDK 工程,顶层结构大概是下面这样。
your-mod/ ├── build.gradle ├── settings.gradle ├── gradle.properties # 部分模板叫 version.properties ├── gradlew ├── gradlew.bat ├── gradle/ │ └── wrapper/ └── src/ └── main/ ├── java/ │ └── com/yourname/yourmod/ │ ├── YourMod.java │ └── item/ModItems.java └── resources/ ├── META-INF/ │ └── neoforge.mods.toml ├── pack.mcmeta ├── assets/ │ └── yourmod/ └── data/ └── yourmod/1.1 顶层每个文件都在干什么
先说build.gradle。它是项目构建脚本,声明了插件、版本号、依赖和运行任务。1.21.1 的 NeoForge 工程用的是 ModDevGradle 插件,不是老 Forge 那套 ForgeGradle,好处是“把模组代码和 Minecraft 拼接起来”的复杂过程基本被封装掉了,日常开发不太需要碰它。但只要涉及升级 NeoForge 版本、改模组版本号、加依赖库,你都得回到这个文件里改,它是整个工程的地基。
然后是settings.gradle。这是 Gradle 自己的工程和仓库配置文件,MDK 默认生成后一般不用动。我见过有人为了折腾别名把它删了,结果整个工程无法同步,得不偿失。gradle.properties(新一些的 MDK 模板里叫version.properties)则集中管理一些键值对,比如 Minecraft 版本、NeoForge 版本、模组 id、模组版本,你要统一改版本号时直接在这里改,不用去 build.gradle 里翻硬编码。
gradlew、gradlew.bat和gradle/wrapper/是 Gradle Wrapper。它最大的价值是保证所有人用同一个 Gradle 版本构建,排除“我电脑上明明能跑”这类玄学问题。这里有个真实踩过的坑:在 Ubuntu 这类 Linux 系统上,第一次执行./gradlew runClient之前必须执行chmod +x gradlew,否则直接报 Permission denied。Windows 上则要记得用gradlew.bat,光敲gradlew也会出问题。
1.2 源码目录和资源目录的边界
src/main/java下按包名组织,模组主类放在最外层包,后面按功能拆子包,比如item、block、client、network这些。技术上 NeoForge 是靠扫描 jar 里所有带@Mod注解的类来发现入口的,不限定类路径,但从工程维护角度看,包结构不梳理清楚,等注册几十个物品和方块之后,自己找文件都会疯掉。
src/main/resources里装的是所有非 Java 的东西:模组元数据、资源包文件、数据包文件。这里要建立一个核心认知:模组 jar 本身同时充当一个资源包和一个数据包。assets/yourmod/底下放贴图、模型、语言文件、音效,data/yourmod/底下放配方、战利品表、标签、进度这些游戏数据。注意assets和data下面必须有一层“以模组 id 命名的目录”,也就是yourmod这层,不可以直接乱放,否则游戏在对应命名空间里找不到资源。
1.3 这套结构决定了“能跑”还是“跑不起来”
很多人第一次自己加贴图失败,就是没搞懂命名空间。游戏里所有资源都通过命名空间:路径来引用,比如yourmod:textures/item/example_item.png。资源文件在 jar 里的实际路径是assets/yourmod/textures/item/example_item.png,贴图路径是assets/yourmod/textures/item/example_item.png,模型文件引用它时写yourmod:item/example_item。命名空间和 jar 内目录那一层必须严格对上,大小写、下划线都不能错。我见过因为模组 id 里带了大写字母,资源死活加载不出来的情况,后面会专门讲。
src/main/java/ -> 编译后成为 jar 里的 class src/main/resources -> 原样打包进 jar 的根目录所以你在 IDE 里看到的是两个并列的目录,但打包后会融合进一个 jar。理解这个,后面很多资源加载问题都能自己定位。
2. 三个关键配置文件:build.gradle、neoforge.mods.toml、pack.mcmeta
工程能跑、模组能被识别,靠的就是这三个文件。很多人开新坑时直接复制别人的模板,然后报错了不知道怎么改,其实就是没理解这三个文件的职责边界。
2.1 构建入口 build.gradle 如何正确配置
1.21.1 的 MDK 使用的是 ModDevGradle 插件,build.gradle的骨架大致如下:
plugins { id 'java' id 'net.neoforged.moddev' version '2.0.x' } group = 'com.yourname' version = '1.0.0' java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } neoForge { version = '21.1.109' runs { client { client() } server { server() } configureEach { logLevel = org.slf4j.event.Level.INFO } } mods { yourmod { sourceSet sourceSets.main } } }这里有几处需要注意。toolchain指定 Java 21,因为 Minecraft 1.21 这个版本线强制要求 JDK 21,用 JDK 17 去跑会在构建时报Unsupported class file major version,这是个很典型的报错。neoForge.version是你用的 NeoForge 版本,我示例里写的21.1.109只是我当时用的版本号,实际以你 MDK 模板里的为准,升级 NeoForge 时只改这一个地方就行。
runs块定义运行方式,client()和server()会生成对应的 Gradle 运行任务,你在 IDE 右侧 Gradle 面板里直接双击runClient就能启动游戏,不用再配启动参数。mods块像个“识别证”,它把当前工程的sourceSets.main映射成模组yourmod,保证最后打出来的 jar 被 NeoForge 正确识别。刚入门时这个地方容易忽略,复制别人的 build.gradle 后没有改成自己的模组 id,结果加载时各种对不上。
2.2 neoforge.mods.toml 每一项的作用
neoforge.mods.toml位于src/main/resources/META-INF/下,它就是模组的“身份证”。1.21.1 的 MDK 模板里文件名为neoforge.mods.toml,更早的一些教程里你可能会看到mods.toml,两者本质是同一种 TOML 格式。核心内容如下:
modLoader = "javafml" loaderVersion = "[4,)" license = "All Rights Reserved" [[mods]] modId = "yourmod" version = "1.0.0" displayName = "Your Mod" description = "A short description of your mod." authors = "Your Name" [[dependencies.yourmod]] modId = "neoforge" type = "required" versionRange = "[21.1.0,)" ordering = "NONE" side = "BOTH" [[dependencies.yourmod]] modId = "minecraft" type = "required" versionRange = "[1.21.1,1.21.1]" ordering = "NONE" side = "BOTH"各字段的作用和容易踩的坑,我整理成了一张对照表:
| 字段 | 作用 | 容易出错的地方 |
|---|---|---|
modLoader | 固定为javafml,声明用 FML 加载 | 不要改,改了就认不出 |
loaderVersion | 声明需要的 FML loader 版本范围 | 按模板默认来,一般不用动 |
license | 模组许可协议 | 缺失或乱填会在加载日志里出现警告 |
modId | 模组唯一标识 | 必须和@Mod注解、build.gradle 的mods块、assets/data目录名完全一致,不一致直接加载失败 |
version | 模组版本 | 一般和 build.gradle 里的 version 保持一致 |
displayName | 游戏内模组列表显示的名字 | 可以用空格和中文 |
description | 模组描述 | TOML 里用三引号括起来可以写多行 |
dependencies | 声明依赖 | 漏掉neoforge或minecraft依赖,加载时会有依赖缺失的报错 |
dependencies那段形式上是[[dependencies.yourmod]],意思是“给 yourmod 这个模组声明依赖”,里面分别声明了对 NeoForge 和 Minecraft 的硬依赖。如果你以后要依赖别的模组,照抄两个块改掉modId和versionRange就行。
2.3 pack.mcmeta 和语言文件的约定
pack.mcmeta放在src/main/resources/pack.mcmeta,内容很简单:
{ "pack": { "description": "Your Mod resources", "pack_format": 34 } }pack_format对应资源包格式版本,1.21.1 是 34。如果新建工程时是复制别人的旧版本,这里的数字不对,资源虽然能加载但游戏会提示版本过期。MDK 模板自带的值就是对的,一般不需要手改。真正容易漏的是语言文件:路径为assets/yourmod/lang/en_us.json。
{ "item.yourmod.example_item": "Example Item" }如果你注册了一个物品却忘了加语言文件,游戏里物品名字会直接显示原始翻译键,比如item.yourmod.example_item,特别丑。记住翻译键的规则:物品是item.<modid>.<注册名>,方块是block.<modid>.<注册名>,后面做配置界面时还会有其他前缀。
3. 理解 Mod 主类:整个模组的启动中枢
文件结构理清之后,最重要的就是那个带@Mod注解的类。它不是一个普通的 Java 类,而是 NeoForge 加载模组时的核心回调对象。主类怎么写,直接决定了你的物品、方块和事件系统能不能正常接入游戏。
3.1 @Mod 注解:加载器如何找到你的入口
先看一个标准的主类骨架:
package com.yourname.yourmod; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.Mod; @Mod(YourMod.MOD_ID) public class YourMod { public static final String MOD_ID = "yourmod"; public YourMod(IEventBus modEventBus, ModContainer modContainer) { // 注册物品、方块、声音等 } }@Mod(YourMod.MOD_ID)把当前类标记为主类,参数是模组 id。NeoForge 在启动时会扫描 jar 里所有带该注解的类,并实例化它,然后调用构造方法。所以主类的public无参构造是关键,JVM 才能正常实例化。如果你在构造方法里搞了很重的初始化逻辑(比如读取网络、弹窗),加载就会卡死,所以主类构造方法里通常只做注册,不干重活。
一个模组只有一个主类是黄金法则,别在一个 jar 里放两个带同一个@Mod注解的类。我见过有人把配置类也顺手加了@Mod,结果 NeoForge 报“已经被注册”的错,排查半天。主类所在的包建议就用最外层那个包,保证后续新增的类都在它的子包下面,扫描和引用都方便。
3.2 构造方法注入:IEventBus 和 ModContainer 是哪来的
注意构造方法的参数:IEventBus modEventBus和ModContainer modContainer。这两个对象不是你 new 出来的,而是 NeoForge 加载框架通过“依赖注入”方式塞进来的。老版本 Forge 里常见的是在构造方法里通过FMLJavaModLoadingContext.get().getModEventBus()拿事件总线,NeoForge 改成了这种更干净的注入形式,你只需声明参数,框架负责提供实例。
IEventBus是模组事件总线,也是整个主类里最常用的对象。所有注册器(DeferredRegister)都必须调用它的register(...)方法,才能真正把注册内容挂到游戏上。ModContainer是当前模组的容器信息,你可以通过它拿模组 id、版本号等元数据,但入门阶段基本用不到,偶尔在日志输出里用一下modContainer.getModInfo().getDisplayName()之类,属于留个后手。
这里有个容易误解的点:IEventBus只属于当前模组,而后面要说到的游戏事件总线是全局的。你在主类里拿到的modEventBus是“模组自己的总线”,不要用它去监听玩家登录这种游戏事件,那种事件应该在游戏总线上处理。
3.3 两条事件总线:为什么事件经常“不触发”
NeoForge 1.21.1 有两条总线,这是新手最晕的地方。整理成表你就能一眼看明白:
| 对比项 | MOD 总线 | GAME 总线 |
|---|---|---|
| 触发时机 | 模组加载阶段:构造、注册、初始化 | 游戏运行阶段:玩家操作、实体行为、方块事件 |
| 获取方式 | 主类构造参数modEventBus | NeoForge.EVENT_BUS |
| 典型事件 | FMLCommonSetupEvent、FMLClientSetupEvent | PlayerEvent、EntityJoinLevelEvent、LivingHurtEvent |
| 注解方式 | @EventBusSubscriber(bus = Bus.MOD) | @EventBusSubscriber(默认 GAME) |
实践中最常见的错误,就是新手把PlayerEvent.PlayerLoggedInEvent这类游戏事件挂在 MOD 总线上监听,结果那个监听函数在游戏里一次都不触发。判断依据很简单:只要关心的是“模组加载到哪一步了”,走 MOD 总线;关心的是“游戏里发生了什么”,走 GAME 总线。
至于监听方式,在主类构造方法里可以直接注册实例方法:
public YourMod(IEventBus modEventBus, ModContainer modContainer) { modEventBus.addListener(this::commonSetup); } private void commonSetup(FMLCommonSetupEvent event) { // 跨注册表的初始化,少量代码可以直接在这里写 }如果监听方法比较多,更推荐用注解式写法。在任意类上标注@EventBusSubscriber,类里的静态方法标@SubscribeEvent,加载器会自动把类注册到对应总线。注意一个细节:用注解方式时,监听方法必须是static的,因为加载器直接反射调用,不创建外部类实例。
import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.fml.common.EventBusSubscriber.Bus; @EventBusSubscriber(modid = YourMod.MOD_ID, bus = Bus.MOD) public class ModLifecycleEvents { @SubscribeEvent public static void onCommonSetup(FMLCommonSetupEvent event) { // ... } }4. 动手实战:从主类出发注册第一个物品
理解了主类,就该实际写一个能进游戏的东西了。下面我以注册一个测试物品为例,把主类、注册器、语言文件完整串起来。
4.1 定义主类并注册 DeferredRegister
首先在包com.yourname.yourmod下新建主类YourMod.java:
package com.yourname.yourmod; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.common.Mod; import net.neoforged.fml.ModContainer; @Mod(YourMod.MOD_ID) public class YourMod { public static final String MOD_ID = "yourmod"; public YourMod(IEventBus modEventBus, ModContainer modContainer) { ModItems.ITEMS.register(modEventBus); } }然后新建item/ModItems.java,用DeferredRegister管理物品注册:
package com.yourname.yourmod.item; import com.yourname.yourmod.YourMod; import net.minecraft.world.item.Item; import net.neoforged.neoforge.registries.DeferredItem; import net.neoforged.neoforge.registries.DeferredRegister; public class ModItems { public static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(YourMod.MOD_ID); public static final DeferredItem<Item> EXAMPLE_ITEM = ITEMS.register("example_item", () -> new Item(new Item.Properties())); }你可能好奇为什么register的第二个参数是个() -> new Item(...)的 lambda,而不是直接new Item(...)。这是因为注册动作要等 NeoForge 的注册表准备好后才能执行,直接用静态字段初始化,类加载顺序的细微差别就可能导致注册失败。这种“懒加载”写法是 DeferredRegister 的核心思想:先声明“我要注册什么”,真正执行时再构造对象。createItems是 NeoForge 1.21.1 提供的便捷方法,专门用于物品注册;如果你之后注册方块,用的是DeferredRegister.createBlocks(MOD_ID)。
注册方块、声音、药水效果等,写法套路完全一样:
public static final DeferredRegister<SoundEvent> SOUNDS = DeferredRegister.create(Registries.SOUND_EVENTS, YourMod.MOD_ID); public static final DeferredHolder<SoundEvent, SoundEvent> EXAMPLE_SOUND = SOUNDS.register("example_sound", () -> SoundEvent.createVariableRangeEvent(ResourceLocation.fromNamespaceAndPath(YourMod.MOD_ID, "example_sound")));看到规律了吗?核心只有三步:声明对应类型的DeferredRegister、调用register声明条目、在主类构造方法中把DeferredRegister注册到modEventBus上。三步缺一不可,很多人漏掉第三步,结果所有注册内容像没发生过,游戏里找半天找不到。
4.2 补上资源文件让物品在游戏里像样
物品注册只完成了逻辑部分,游戏里能拿到但名字是原始翻译键、贴图缺失。需要补两份资源:语言文件assets/yourmod/lang/en_us.json:
{ "item.yourmod.example_item": "Example Item" }以及贴图文件assets/yourmod/textures/item/example_item.png。如果只是测试,随便拿一张 PNG 贴图放上去,再把模型文件放好即可。对于简单物品,可以直接用assets/yourmod/models/item/example_item.json:
{ "parent": "minecraft:item/generated", "textures": { "layer0": "yourmod:item/example_item" } }这里layer0的值yourmod:item/example_item对应的正是assets/yourmod/textures/item/example_item.png。路径对不上,游戏里拿到的物品就是黑白紫块贴图,这是资源加载错误最常见的表现之一。
4.3 运行验证:runClient 与 give 指令
在 IDE 的 Gradle 面板里找到runClient任务,双击运行,NeoForge 会拉起一个带开发环境的 Minecraft 客户端。进入世界后执行:
/give @s yourmod:example_item如果命令正确返回了物品,说明注册链路是通的。这时你拿起来应该能看到物品名字“Example Item”和贴图。如果@s不行,也可以换成你的玩家名或@p。这个流程我每次新建模组都会先跑一遍,确认从主类到资源配置整条链路没问题,再开始写复杂功能。如果物品没出现,多半是主类构造方法里忘记调用ITEMS.register(modEventBus),或者 modId 不一致导致的静默失败。
5. 常见问题与排查:把开发中踩过的坑一次说清楚
这一节没有按教程顺序来,而是按我实际开发中遇到的频率排序。每条背后都是真实报错场景,排查思路可以通用到之后的开发中。
5.1 模组加载失败或启动闪崩
启动游戏后,如果 NeoForge 提示模组加载失败,第一反应别去问别人,先看日志。目录是run/logs/latest.log,完整的错误堆栈在里面。最常见的加载失败原因有三个。
第一是 modId 不一致。@Mod(YourMod.MOD_ID)、neoforge.mods.toml里的modId、build.gradle 的mods块、assets/yourmod目录名,任何一个对不上都会出问题。改模组 id 时要把这几处一次性全改掉,我经常改完 toml 忘了改 build.gradle,然后启动时报找不到模组。第二是主类里有多个@Mod注解,或者两个类用了同一个 modId,报错通常是“already registered”。第三是主类构造方法抛异常,比如在里面做了网络请求或读取外部文件。构造方法只做注册,别的统统延后到事件里做,这是最稳的。
5.2 事件监听完全不触发
如果你的监听函数从没执行过,按三个方向排查。首先确认总线的选择,MOD bus 还是 GAME bus,参考上面的对照表。其次确认类上有@EventBusSubscriber注解,并且监听方法是 static 的。很多人写了个内部类或普通类,方法上标了@SubscribeEvent,但类本身没被注册到任何总线,自然不触发。还有一种情况是注册了但 jar 里没带上这个类,比如源码目录没被 Gradle 识别,检查build/classes或build/resources下有没有对应产物。
另外要注意,某些注册类任务必须在特定时机执行,比如FMLCommonSetupEvent里跨注册表操作,框架会要求你调用event.enqueueWork(() -> ...)放到工作线程里去。如果漏了这层包装,运行时可能会报“串线程”错误,这类问题日志里通常有Wrong thread之类的关键词。
5.3 客户端与服务端环境导致的经典报错
Minecraft 分客户端和服务端两个环境,开发期最常见的问题是:在普通代码里直接引用了客户端类。比如在公共逻辑里写Minecraft.getInstance(),本地运行客户端没问题,一旦上服务器就报ClassNotFoundException或NoClassDefFoundError,因为服务端环境根本没有这些类。
处理方式就是在纯客户端逻辑上加@OnlyIn(Dist.CLIENT)注解,或者把客户端相关代码放在专门的client子包里,用独立的@EventBusSubscriber类监听客户端事件。不要指望“反正本地能跑就没事”,服务器是另一套类加载环境,开发中期就要养成隔离客户端代码的习惯,否则发布时炸一次就够头疼的。
5.4 构建与运行环境相关的琐碎问题
这类问题不涉及代码逻辑,但非常消磨耐心。第一就是 JDK 版本。NeoForge 1.21.1 要求 Java 21,IDE 里项目 SDK 要选成 21,命令行构建前用./gradlew --version确认实际用的 JDK 版本。出现Unsupported class file major version时,基本就是 JDK 版本不对。第二是 Ubuntu/Linux 下gradlew没有执行权限,报Permission denied,先chmod +x gradlew。第三是首次 Gradle 同步或构建特别慢,因为要下载 NeoForge 依赖,这时候别急着强杀进程,让它跑完,否则容易留下不完整的缓存,下次还是要重新下载。
最后给一个小技巧:如果你改了资源和代码但游戏里没变化,优先检查run/目录。开发时 runClient 用的是独立的游戏目录,资源是从build/resources/main拷贝过去的,不是直接读src。有时 Gradle 增量构建没触发,手动执行一下./gradlew prepareRun或直接清理build目录,资源就能刷出来。
写这套开发笔记时,我自己有个习惯:每解决一个报错,就把“触发条件 + 报错关键字 + 解决方式”记在项目根目录的 NOTES.md 里,绝不依赖记忆。模组开发最吃经验的不是写功能,而是排错,很多问题网上搜不到直接答案,只能靠日志和实验。这篇里的坑大多是我在 1.21.1 上实际遇到的,你现在看着简单,真上手跑不通时再翻回来对照,能少走不少弯路。