尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Minecraft 1.21.1 NeoForge模组开发笔记:工程结构与Mod主类完全解析

Minecraft 1.21.1 NeoForge模组开发笔记:工程结构与Mod主类完全解析 这是“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 总线触发时机模组加载阶段构造、注册、初始化游戏运行阶段玩家操作、实体行为、方块事件获取方式主类构造参数modEventBusNeoForge.EVENT_BUS典型事件FMLCommonSetupEvent、FMLClientSetupEventPlayerEvent、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.javapackage 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 DeferredItemItem 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 DeferredRegisterSoundEvent SOUNDS DeferredRegister.create(Registries.SOUND_EVENTS, YourMod.MOD_ID); public static final DeferredHolderSoundEvent, 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 21IDE 里项目 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 上实际遇到的你现在看着简单真上手跑不通时再翻回来对照能少走不少弯路。
返回列表