Mod 开发向 Java 速成教程¶
原创教程 · 目标版本:Forge 1.20.1 · 前置知识:会一点编程即可
写在前面:为什么先学 Java¶
Forge 模组是用 Java 写的。Java 是一种面向对象的编程语言,语法上接近 C 系语言(C/C++/C#/JavaScript),如果你之前写过 Python、JavaScript 或 C 语言,看 Java 代码不会太陌生。本教程不追求让你成为 Java 大师,而是用最短的篇幅讲清楚阅读和编写 Forge 模组最常用的语法,每个知识点都配一个贴近模组开发的例子。学完本章,你就能看懂本站其他章节(如注册、物品、能力)中的代码。
本文所有代码基于 Java 17(Forge 1.20.1 的要求),语法上与 Java 8 大体一致,遇到新特性会单独说明。
一、环境与工具链¶
写 Forge 模组之前,先搞清楚三样东西:JDK、IDE、构建工具。
1.1 JDK 17:运行与编译¶
- JDK(Java Development Kit,Java 开发工具包):既负责把
.java源码编译成字节码(javac命令),也负责运行 Java 程序(java命令)。 - Forge 1.20.1 要求 JDK 17,装错版本(比如 8 或 21)会直接构建失败。
- 装好后在命令行执行
java -version,看到openjdk version "17..."即表示成功。
1.2 IntelliJ IDEA:集成开发环境¶
IDE(Integrated Development Environment,集成开发环境) 是写代码的"工作台"。Forge 官方推荐 IntelliJ IDEA(社区版免费够用)。IDEA 会帮你做三件关键的事:
- 代码补全:输入
player.自动列出所有可调用的方法,这是你探索 Minecraft 庞大 API 的最大助力。 - 错误提示:语法错误、类型不匹配会立刻标红。
- 一键运行:直接点运行按钮就能启动 Minecraft 客户端进行调试。
1.3 Gradle 与 ForgeGradle:构建工具¶
Gradle 是一个构建工具(build tool),用一段 Groovy/Kotlin 脚本(build.gradle)描述"项目怎么编译、依赖哪些库、产物是什么"。模组项目里你的代码依赖 Minecraft 本体和 Forge,这些依赖就是由 Gradle 下载并组织在一起的。
ForgeGradle(FG) 是 Forge 官方为 Gradle 写的插件,它把 Minecraft 源码和 Forge 补丁合并、把官方映射(Mojang official mappings)应用进去,让你能直接写 net.minecraft.world.item.Item 这样"人类可读"的类名。在 1.20.1 中对应 ForgeGradle 6(FG6)。
你不需要精通 Gradle,只需要知道几条常用命令(在项目根目录执行):
./gradlew genSources # 生成 Minecraft 源码(IDEA 里才能点进 Minecraft 类看源码)
./gradlew build # 编译并打包出 mod jar
./gradlew runClient # 启动 Minecraft 客户端
一句话总结:JDK 提供语言本身,IDEA 让你舒服地写,Gradle + ForgeGradle 负责把 Minecraft、Forge 和你的代码揉在一起并打包成模组。用官方 MDK(Mod Development Kit,模组开发套件)生成的骨架项目,这三样已经配好,你只需要在 IDEA 里导入即可。
二、语法基础¶
2.1 变量与类型¶
Java 是静态类型语言:每个变量在声明时就必须写明类型,且之后只能存该类型的值。这与 Python/JavaScript 的"动态类型"不同,但也意味着编译器能帮你提前发现大量错误。
基本类型(Primitive Type):不是对象,直接存值。
| 类型 | 含义 | 例子 |
|---|---|---|
int |
整数(32 位) | int count = 3; |
long |
长整数(64 位,末尾加 L) | long time = 123456789L; |
float |
单精度小数(末尾加 f) | float speed = 0.5f; |
double |
双精度小数 | double ratio = 0.75; |
boolean |
布尔值 | boolean enabled = true; |
char |
单个字符 | char letter = 'a'; |
引用类型(Reference Type):指向一个对象。最常用的就是 String(字符串,注意首字母大写):
String modName = "我的第一个模组";
int length = modName.length(); // 调用方法,获取字符串长度
String upper = modName.toUpperCase(); // 转大写
Forge 里大量使用 String,比如模组 ID(mod id)、物品的注册名、翻译键(translation key)都是字符串。
final 关键字表示"赋值后不可再改",相当于其他语言里的常量,Forge 的模组 ID 通常这样声明:
Java 10 起可以用 var 让编译器推断类型(类型推断,Type Inference),写起来更短,但要注意它不是动态类型:
数组(Array) 是固定长度的同类型元素序列,用 [] 声明,下标从 0 开始:
int[] levels = new int[3]; // 长度 3 的 int 数组,初始全为 0
levels[0] = 5;
String[] names = { "甲", "乙", "丙" }; // 直接初始化
int length = names.length; // 3,注意数组长度是字段不是方法
String 有几个模组开发常用的方法:contains(包含)、startsWith(以……开头)、equals(相等)、split(按分隔符拆成数组)、format(格式化)。LOGGER.info("玩家 {} 死了", name) 里的 {} 就是占位符,由日志库完成格式化,等价于 String.format。
2.2 运算符¶
运算符与其他语言大同小异:
// 算术:+ - * / %
int stackSize = 5;
int next = stackSize + 1; // 6
int half = stackSize / 2; // 2(整数除法会丢弃小数部分!)
int remainder = stackSize % 2; // 1
// 自增自减
stackSize++; // 先使用后加 1;++stackSize 则是先加 1 再使用
stackSize += 10; // 等价于 stackSize = stackSize + 10
// 比较:== != < > <= >=
boolean isFull = stackSize >= 64;
// 逻辑:&&(与)||(或)!(非)
boolean canUse = !player.isCreative() && (stackSize > 0 || isFull);
// 三元运算符(Ternary Operator):条件 ? 值1 : 值2
int damage = player.isCreative() ? 0 : 1;
易错点:比较两个
String是否相等要用equals方法,而不是==。==比较引用类型时比较的是"是不是同一个对象","abc" == "abc"有时碰巧为真,但new String("abc") == "abc"一定为假。字符串比较请一律写"examplemod".equals(id)。
2.3 条件判断:if / switch¶
if 和大多数语言一样:
int hunger = player.getFoodData().getFoodLevel();
if (hunger <= 0) {
player.hurt(player.damageSources().starve(), 1.0f); // 饿死了,扣血
} else if (hunger < 6) {
LOGGER.info("玩家很饿");
} else {
LOGGER.info("玩家吃饱了");
}
switch 用于对一个值的多种情况分支。Java 17 支持两种写法:传统 case x:(需要 break)和箭头语法 case x ->(不需要 break)。Forge 代码里两种都常见,务必都能看懂:
// 传统写法
switch (item.getTier().getLevel()) {
case 0:
case 1:
LOGGER.info("石质工具级别");
break;
case 2:
LOGGER.info("铁质工具级别");
break;
default:
LOGGER.info("高级工具");
}
// Java 14+ 箭头写法(不用写 break)
switch (item.getTier().getLevel()) {
case 0, 1 -> LOGGER.info("石质工具级别");
case 2 -> LOGGER.info("铁质工具级别");
default -> LOGGER.info("高级工具");
}
2.4 循环:for / while¶
for、while 与 C 系语言一致:
// 经典 for:执行 5 次
for (int i = 0; i < 5; i++) {
LOGGER.info("第 {} 次", i);
}
// while:条件满足就一直执行
int count = 0;
while (count < 5) {
count++;
}
// do-while:至少执行一次
int x = 10;
do {
x--;
} while (x > 0);
模组开发里最常用的是增强 for 循环(for-each),它直接遍历集合或数组,不需要下标:
// 遍历玩家物品栏里的所有物品堆(ItemStack)
for (ItemStack stack : player.getInventory().items) {
if (!stack.isEmpty()) {
LOGGER.info("物品栏里有:{}", stack.getDisplayName().getString());
}
}
break 提前跳出循环,continue 跳过本次循环的剩余部分:
for (ItemStack stack : player.getInventory().items) {
if (stack.isEmpty()) continue; // 空格子直接跳过
if (stack.is(ExampleMod.RUBY.get())) {
LOGGER.info("找到红宝石!");
break; // 找到就停
}
}
2.5 方法与参数¶
方法(Method) 就是"给一段代码起个名字,可以反复调用"。方法声明包含:访问修饰符、返回值类型、方法名、参数列表、方法体:
// 返回 boolean,接收一个 ItemStack 参数
public boolean isRuby(ItemStack stack) {
return stack.is(ExampleMod.RUBY.get());
}
// 没有返回值的方法用 void
public void logPlayer(Player player) {
LOGGER.info("玩家:{}", player.getName().getString());
}
方法之间可以互相调用,调用时实参(argument)的类型和个数必须与形参(parameter)匹配。
2.6 方法重载与可变参数¶
方法重载(Overload) 指同一个类里可以定义多个同名但参数列表不同的方法,调用时编译器根据实参类型自动选择。LOGGER.info 就有好几种写法(info(String)、info(String, Object...)),它们就是重载。Forge 里也大量使用重载,比如 ItemStack 的 hurt 方法就有多个版本。
可变参数(Varargs) 用 类型... 声明,允许传入任意个数的参数,底层其实是个数组:
public class ChatHelper {
// 可以传任意数量的消息
public static void sendMessages(Player player, String... messages) {
for (String msg : messages) {
player.sendSystemMessage(Component.literal(msg));
}
}
}
// 调用:传 1 个、2 个、0 个都行
ChatHelper.sendMessages(player, "你好");
ChatHelper.sendMessages(player, "你好", "再见");
ChatHelper.sendMessages(player);
看到参数类型后面带 ... 时,就知道这是一个可变参数方法。
三、面向对象¶
面向对象(Object-Oriented) 是 Java 的核心思想:把"数据"和"操作这些数据的方法"打包成一个类(Class),再用类去创建对象(Object,实例)。Minecraft 的整个世界就是由对象组成的:玩家是 Player 对象,每个物品堆是 ItemStack 对象,每只苦力怕是 Creeper 对象。
3.1 类与对象¶
类是模板,对象是按模板造出来的具体东西。new 关键字创建对象:
类由两部分组成:字段(Field,即类的变量) 和 方法(Method)。
3.2 一个完整的类¶
下面是一个真实的模组场景:给物品加"魔法充能"功能的工具类。先看完整代码,再逐行解释:
package com.example.mod;
import net.minecraft.world.item.ItemStack;
public class MagicHelper {
// 字段:这个类的"状态"
private static final String TAG_CHARGED = "Charged";
// 方法:给物品充能
public static void charge(ItemStack stack) {
stack.getOrCreateTag().putBoolean(TAG_CHARGED, true);
}
// 方法:判断是否已充能
public static boolean isCharged(ItemStack stack) {
return stack.hasTag() && stack.getTag().getBoolean(TAG_CHARGED);
}
}
- 包(Package):第一行的
package com.example.mod;声明这个类属于哪个"文件夹命名空间",避免类名冲突。Forge 模组通常以com.你的名字.模组名开头。 import:引用其他包里的类。IDEA 会帮你自动补全。- 字段:
TAG_CHARGED是一个字符串常量,存储 NBT 标签的键名。 - 方法:
charge在物品的 NBT 里写入一个布尔值;isCharged读取它。
3.3 构造器(Constructor)¶
构造器(Constructor) 是"创建对象时执行的初始化代码",名字必须与类名相同,没有返回值。不写构造器时,Java 会提供一个无参默认构造器:
public class MagicSword extends SwordItem {
private final int magicLevel;
// 构造器:接收参数并存入字段
public MagicSword(int magicLevel, Properties properties) {
super(Tiers.DIAMOND, 5, -2.4f, properties);
this.magicLevel = magicLevel; // this 表示"当前这个对象"
}
}
注意 this 关键字:当参数名与字段名相同时,用 this.字段名 区分"当前对象的字段"和"传入的参数"。super(...) 表示调用父类的构造器。
3.4 访问修饰符:public / private / protected¶
访问修饰符(Access Modifier) 控制类成员(字段/方法)能被谁访问,这是封装(Encapsulation) 的基础:
| 修饰符 | 含义 |
|---|---|
public |
任何类都能访问 |
private |
只有本类内部能访问 |
protected |
本类、同包类、子类能访问 |
| (不写) | 包级私有:本类与同包类能访问 |
经验法则:字段尽量 private,对外暴露 public 方法。比如上面的 MagicHelper,外部只能通过 charge / isCharged 操作,不能直接乱改内部状态。
3.5 static:属于类的成员¶
static 修饰的成员属于类本身,不属于某个具体对象,直接用 类名.成员 访问,不需要 new:
// 不需要创建对象,直接调用
MagicHelper.charge(stack);
// 典型例子:Math 类的静态方法
double d = Math.sqrt(16.0); // 4.0
// 静态字段:整个程序只有一份
public static final String MOD_ID = "examplemod";
3.6 继承:extends¶
继承(Inheritance) 让一个类"复用并扩展"另一个类。extends 表示继承,子类自动拥有父类的非私有成员,还可以覆写(Override) 父类的方法来改变行为。
Java 是单继承:一个类只能有一个父类,但可以"层层继承"。Minecraft 的物品体系就是一棵继承树:
实战例子——自定义一个"吃不完的面包",继承 Item 并覆写 getMaxStackSize(最大堆叠数)和 isEdible(是否可食用):
public class InfiniteBread extends Item {
public InfiniteBread(Properties properties) {
super(properties);
}
@Override
public int getMaxStackSize() {
return 1; // 覆写:永远只能堆叠 1 个
}
@Override
public boolean isEdible() {
return true; // 覆写:这东西能吃
}
}
覆写父类方法时,方法签名(方法名 + 参数类型)必须完全一致,返回值类型可以相同或更具体。
3.7 接口:implements¶
接口(Interface) 是一份"能力契约":它只声明方法"应该长什么样",不提供实现。一个类可以 implements 多个接口(弥补单继承的限制),承诺"我会实现这些方法"。
Java 17 中接口里可以写 default 方法(带默认实现)和 static 方法,但核心仍是"接口定义能力":
// 定义一个接口:可充能的东西
public interface Chargeable {
void charge(); // 抽象方法:实现者必须提供实现
boolean isCharged();
default String describe() { // default 方法:实现者可以不改直接用
return "可充能物品";
}
}
// 用 implements 实现接口:必须实现所有抽象方法
public class MagicWand extends Item implements Chargeable {
public MagicWand(Properties properties) {
super(properties);
}
@Override
public void charge() {
LOGGER.info("法杖充能中……");
}
@Override
public boolean isCharged() {
return true;
}
}
什么时候用继承,什么时候用接口? 简单判断:如果新类"是一种"父类(魔剑是一种剑)→ 继承;如果只是"具备某种能力"(能充电、能被序列化)→ 接口。Minecraft 中 Item、Block、Entity 这些体系都用继承,而"可交互""可被拾取"这类横切能力用接口(如 InteractionResult 相关的回调)。
3.8 @Override:给编译器看的"保证书"¶
注解(Annotation) 是以 @ 开头的标记,给编译器或框架提供额外信息,本身不改变代码逻辑。@Override 是最常见的一个:它告诉编译器"这个方法是我覆写父类/实现接口的"。好处是:如果方法签名写错(比如把 isEdible 拼成 isEatble),编译器会立刻报错,而不是静默地创建了一个新方法——这是模组里最常见的隐性 bug 来源。因此:凡是覆写/实现的方法,一律加 @Override。
3.9 对象判等:equals 与 hashCode¶
前面说过字符串要用 equals 比较。其实所有对象的 equals 都决定了它是否与另一个对象"相等",== 只比较是不是同一个对象(同一块内存地址)。
Forge 里最典型的例子是物品堆(ItemStack):两个物品堆可能不是同一个对象,但只要物品相同、数量相同,游戏就认为它们"同种",可以合并堆叠。自定义类如果想让"内容相同即相等",就必须覆写 equals(以及配套的 hashCode):
public class Gem {
private final String name;
private final int power;
public Gem(String name, int power) {
this.name = name;
this.power = power;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true; // 同一个对象肯定相等
if (!(obj instanceof Gem other)) return false; // 类型不同直接不等
return power == other.power && name.equals(other.name);
}
@Override
public int hashCode() {
return name.hashCode() * 31 + power; // 与 equals 使用相同的字段
}
}
规则是:equals 相等的两个对象,hashCode 必须相同。因为 HashSet、HashMap 先按哈希值找"桶",再在桶内用 equals 比对。覆写了 equals 不覆写 hashCode,把对象放进 Set/Map 时就会出诡异的 bug(能放进去却找不到)。IDEA 里可以用 Alt+Insert 自动生成这两个方法,不必手写。
instanceof 是"类型判断"运算符,Java 16+ 支持模式匹配写法 obj instanceof Gem other,判断通过的同时直接把 obj 转型为 Gem 并赋值给 other,省去一行强转。Forge 的事件处理里经常用它判断事件的具体类型。
四、进阶特性¶
4.1 泛型(Generics):带类型的"盒子"¶
泛型(Generics) 用尖括号 <T> 表示"类型参数":让一个类或方法可以"先不决定具体类型,使用时再定"。它最大的价值是类型安全——把错误从运行期提前到编译期。
最典型的例子就是集合。没有泛型时,一个 List 可以塞任何对象,取出时必须手动转型,很容易转错:
// 有泛型:这个列表只能放 ItemStack
List<ItemStack> chestItems = new ArrayList<>();
chestItems.add(new ItemStack(Items.DIAMOND));
// chestItems.add("hello"); // 编译错误!类型不匹配
ItemStack first = chestItems.get(0); // 直接得到 ItemStack,无需转型
Forge 里泛型无处不在。延迟注册器(DeferredRegister) 和 注册表对象(RegistryObject) 都是泛型类:
// DeferredRegister<Item>:专门用来注册"物品"的注册器
public static final DeferredRegister<Item> ITEMS =
DeferredRegister.create(ForgeRegistries.ITEMS, MOD_ID);
// RegistryObject<Item>:将来能拿到真正的 Item 对象
public static final RegistryObject<Item> RUBY =
ITEMS.register("ruby", () -> new Item(new Item.Properties()));
读法:"RegistryObject<Item> 是一个装着 Item 的盒子"。<> 里写什么类型,get() 就返回什么类型,编译器全程帮你把关。
Optional<T> 是另一个常用泛型:它表示"这个值可能有,也可能没有",强制你处理"空"的情况,从而避免空指针异常(NullPointerException):
// 假设从某处查找物品,可能找不到
Optional<ItemStack> found = chestItems.stream()
.filter(stack -> stack.is(Items.EMERALD))
.findFirst();
if (found.isPresent()) { // 方法一:判断是否存在
ItemStack emerald = found.get();
} else {
LOGGER.info("箱子里没有绿宝石");
}
// 方法二:orElse 提供默认值
ItemStack emeraldOrAir = found.orElse(ItemStack.EMPTY);
习惯上,能用
Optional表达"可空"的 API 会返回Optional,而 Minecraft 老代码大量直接返回null,需要你自己判空。写代码时尽量用Optional或明确判空,不要裸奔null。
4.2 Lambda 表达式与函数式接口¶
Lambda 表达式(Lambda Expression) 是 Java 8 引入的语法糖,用来简洁地表示"一段将要被执行的代码"。它的本质是一个"函数值":可以像传参数一样把行为传来传去。
函数式接口(Functional Interface) 是只有一个抽象方法的接口,它是 Lambda 的目标类型。如果你自己定义一个这样的接口,可以用 @FunctionalInterface 注解声明意图,编译器会检查它确实只有一个抽象方法。Java 内置了几个最常用的:
| 函数式接口 | 抽象方法 | 含义 |
|---|---|---|
Supplier<T> |
T get() |
供给者:不给参数,返回一个值 |
Consumer<T> |
void accept(T t) |
消费者:接收一个值,处理它,不返回 |
Function<T, R> |
R apply(T t) |
函数:接收 T,返回 R |
Predicate<T> |
boolean test(T t) |
谓词:接收 T,返回布尔判断 |
Forge 实战 1——Supplier 注册物品。DeferredRegister.register 的第二个参数就是一个 Supplier<Item>(延迟到注册那一刻才真正创建物品):
// 完整写法:匿名内部类
ITEMS.register("ruby", new Supplier<Item>() {
@Override
public Item get() {
return new Item(new Item.Properties());
}
});
// Lambda 写法:() -> 表达式 表示"无参数,返回这个值"
ITEMS.register("ruby", () -> new Item(new Item.Properties()));
Forge 实战 2——Consumer 填充创造模式标签页。CreativeModeTab 构建器里的 displayItems 接收一个 Consumer,用于往标签页里塞物品:
public static final RegistryObject<CreativeModeTab> EXAMPLE_TAB = TABS.register("example",
() -> CreativeModeTab.builder(CreativeModeTab.Row.TOP, 0)
.title(Component.translatable("item_group." + MOD_ID + ".example"))
.icon(() -> new ItemStack(RUBY.get())) // 这里也是 Supplier
.displayItems((params, output) -> { // 这里是 BiConsumer(两个参数的 Consumer)
output.accept(RUBY.get());
output.accept(BLOCK.get());
})
.build()
);
Lambda 语法要点:
(参数列表) -> 表达式:单表达式可以省略return和花括号。(参数列表) -> { 多行语句 }:多行时必须用花括号和return。- 参数类型可以省略,编译器从函数式接口签名推断。
Forge 实战 3——Predicate 过滤。比如筛选玩家物品栏里的食物:
// 遍历物品栏,挑出所有可吃的物品
List<ItemStack> foods = player.getInventory().items.stream()
.filter(stack -> !stack.isEmpty() && stack.getItem().isEdible())
.toList();
4.3 Stream API:流水线式处理集合¶
流(Stream) 把集合处理变成一条"流水线":集合.stream() 产生流,然后链式调用各种操作,最后 collect 或 toList() 收尾。常用三步曲:map(转换)、filter(过滤)、collect(收集)。
一个完整的 Forge 例子——统计玩家物品栏中所有物品的名字,并打印数量:
// 1. stream():打开流水线
// 2. filter:只留下非空物品堆
// 3. map:把 ItemStack 转换成显示名 String
// 4. collect:把结果收集成 List<String>
List<String> names = player.getInventory().items.stream()
.filter(stack -> !stack.isEmpty()) // 过滤:Predicate
.map(stack -> stack.getDisplayName().getString()) // 转换:Function
.collect(Collectors.toList()); // 收集
LOGGER.info("物品栏共 {} 种物品:{}", names.size(), String.join(", ", names));
常用操作一览:
| 操作 | 作用 | 例子 |
|---|---|---|
filter |
按条件留下元素 | .filter(s -> s.isEdible()) |
map |
把每个元素转换成另一种 | .map(ItemStack::getCount)(方法引用) |
distinct |
去重 | .distinct() |
sorted |
排序 | .sorted(Comparator.comparingInt(ItemStack::getCount)) |
limit |
只取前 N 个 | .limit(3) |
anyMatch |
是否存在满足条件的 | .anyMatch(s -> s.is(Items.DIAMOND)) |
count |
计数 | .count() |
collect |
收集成集合 | .collect(Collectors.toList()) |
.map(ItemStack::getCount)这种类名::方法名叫方法引用(Method Reference),是 Lambda 的简写形式,等价于.map(s -> s.getCount())。
再举一个综合例子:判断背包里是否有超过 32 个的任意物品(防止刷物品的简单检查):
boolean hasHugeStack = player.getInventory().items.stream()
.filter(stack -> !stack.isEmpty())
.anyMatch(stack -> stack.getCount() > 32);
进阶用法 Collectors.groupingBy:把物品按"是否可食用"分成两组,得到 Map<Boolean, List<ItemStack>>:
Map<Boolean, List<ItemStack>> byEdible = player.getInventory().items.stream()
.filter(stack -> !stack.isEmpty())
.collect(Collectors.groupingBy(stack -> stack.getItem().isEdible()));
List<ItemStack> foodList = byEdible.getOrDefault(true, List.of()); // 没有食物时返回空列表
注意
List.of()返回的是不可变列表,不能add。new ArrayList<>()才是可变的。这个区别在写模组时经常踩坑:对不可变集合调用add会抛UnsupportedOperationException。
4.4 异常与 try-catch¶
异常(Exception) 是程序运行期出错时抛出(throw)的信号。不处理异常,程序会直接崩溃;用 try-catch 捕获后可以优雅降级。基本结构:
try {
// 可能出错的代码
int level = Integer.parseInt("abc"); // 字符串不是数字,会抛 NumberFormatException
} catch (NumberFormatException e) {
// 出错后执行这里
LOGGER.warn("解析数字失败,改用默认值 0", e);
} finally {
// 无论成功失败都会执行(可省略)
LOGGER.info("解析流程结束");
}
Java 里异常分两类:
- 受检异常(Checked Exception):编译器强制你处理(要么 try-catch,要么方法上声明
throws)。典型如文件读写IOException。 - 非受检异常(Unchecked / RuntimeException):不强制处理,比如
NullPointerException、NumberFormatException、数组越界。Minecraft 的代码大量使用非受检异常。
Forge 实战:读取存档数据(Saved Data)时防止文件损坏导致崩溃:
try {
CompoundTag tag = savedData.getData(); // 假设可能损坏
int level = tag.getInt("Level");
} catch (Exception e) {
LOGGER.error("读取存档数据失败,使用默认值", e);
level = 0;
}
注意:不要用空的
catch (Exception e) {}把错误吞掉——至少打一条日志,否则出错时你完全不知道发生了什么。写模组时日志是你唯一的"眼睛"。
多异常捕获:catch 后面可以并列多个异常类型,用 | 分隔,它们共享同一段处理代码:
try {
int value = Integer.parseInt(input);
} catch (NumberFormatException | NullPointerException e) {
LOGGER.warn("输入不合法:{}", input, e);
}
方法声明 throws:受检异常可以不在方法内处理,而是通过 throws 声明"由调用者处理"。Minecraft 的存档读写代码里经常看到:
public void saveToFile(Path path) throws IOException {
// 如果这里抛 IOException,会传递给调用方
Files.write(path, data);
}
try-with-resources:需要关闭的资源(文件流、网络连接)用 try (...) 写法,代码块结束时 Java 自动关闭,不需要手写 finally:
try (BufferedReader reader = Files.newBufferedReader(Path.of("mods/example.txt"))) {
String line = reader.readLine();
LOGGER.info("第一行:{}", line);
} catch (IOException e) {
LOGGER.error("读取文件失败", e);
}
4.5 常用集合:List / Map / Set¶
除了前面见过的 List,还有两个核心集合接口:
List<T>(列表):有序、可重复,按下标访问。实现类常用ArrayList。Set<T>(集合):无序、不可重复,用于去重和快速判断"是否存在"。实现类常用HashSet。Map<K, V>(映射):键值对,键不可重复。实现类常用HashMap。
Forge 实战——用一个 Map 记录"每个玩家的累计在线时长":
// 键:玩家 UUID(Universally Unique Identifier,玩家唯一标识),值:在线秒数
public static final Map<UUID, Integer> PLAYER_PLAY_TIME = new HashMap<>();
// 玩家每次 tick 时累加
public static void addTick(Player player) {
UUID id = player.getUUID();
PLAYER_PLAY_TIME.put(id, PLAYER_PLAY_TIME.getOrDefault(id, 0) + 1);
}
// 遍历 Map 的三种姿势
for (UUID id : PLAYER_PLAY_TIME.keySet()) { ... } // 遍历键
for (Integer seconds : PLAYER_PLAY_TIME.values()) { ... } // 遍历值
for (Map.Entry<UUID, Integer> entry : PLAYER_PLAY_TIME.entrySet()) {
LOGGER.info("玩家 {} 玩了 {} 秒", entry.getKey(), entry.getValue());
}
getOrDefault(key, 默认值) 是个实用方法:键不存在时返回默认值而不是 null。
五、注解与事件订阅¶
现在把前面学的知识串起来,理解 Forge 模组里最核心的三个注解:@Mod、@SubscribeEvent、@EventBusSubscriber。
5.1 注解到底是什么¶
回顾 3.8 节:注解(Annotation) 是 @ 开头的标记。有的注解被编译器使用(如 @Override),有的被框架在运行时用反射(Reflection) 读取(如 @Mod)。Forge 会扫描你的类,找到带特定注解的方法并"挂接"到它的系统里。你不需要手写反射代码,只需要正确摆放注解。
5.2 @Mod:声明模组主类¶
@Mod 标注的类是主类(Mod Main Class),Forge 启动时会实例化它,作为这个模组的入口:
@Mod(ExampleMod.MOD_ID)
public class ExampleMod {
public static final String MOD_ID = "examplemod";
public static final Logger LOGGER = LogUtils.getLogger();
public ExampleMod() {
// 构造器里做注册等初始化工作
ITEMS.register(FMLJavaModLoadingContext.get().getModEventBus());
}
}
注解里 @Mod(ExampleMod.MOD_ID) 的括号是注解参数,这里传入模组 ID。Forge 加载时用反射找到这个类、调用构造器,整个模组就"活"了。
5.3 事件与 @SubscribeEvent¶
Minecraft 里每时每刻都在发生各种事情:玩家登录、方块被破坏、实体受伤……Forge 把这些时刻抽象成事件(Event) 对象,广播到事件总线(EventBus) 上。你写好处理方法、加上 @SubscribeEvent,Forge 就会在对应事件发生时自动调用它——这就是订阅(Subscribe)。
// 当玩家登录时,Forge 会调用这个方法,并把事件对象传进来
@SubscribeEvent
public static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) {
Player player = event.getEntity(); // 从事件里取出相关对象
player.sendSystemMessage(Component.literal("欢迎回来," + player.getName().getString() + "!"));
}
理解要点:
- 方法名可以随便起,关键是
@SubscribeEvent注解和参数类型。Forge 根据参数类型(PlayerEvent.PlayerLoggedInEvent)决定"这个方法是处理哪个事件的"。 - 事件对象是"信息快递员":你需要的数据(玩家、方块、物品堆……)都通过
event.getXxx()拿。 - Forge 有两类事件总线:MOD 事件总线(mod 加载阶段的事件,如注册)和 FORGE 事件总线(游戏运行时事件,如玩家登录)。
5.4 @EventBusSubscriber:批量订阅¶
单独写 @SubscribeEvent 方法很简单,但 Forge 怎么知道去哪找它们呢?两种方式:
- 在事件总线对象上手动
register一个类的实例。 - 更常用:给类加
@EventBusSubscriber注解,Forge 自动扫描这个类里所有@SubscribeEvent的静态方法并注册:
@Mod.EventBusSubscriber(modid = ExampleMod.MOD_ID, bus = EventBusSubscriber.Bus.FORGE)
public class GameEvents {
@SubscribeEvent
public static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) {
// 注意:方法必须是 static(静态方法),且 public
}
@SubscribeEvent
public static void onBlockBreak(BlockEvent.BreakEvent event) {
// 同一个类里可以订阅多个事件
BlockState state = event.getState();
Player player = event.getPlayer();
LOGGER.info("{} 破坏了 {}!", player.getName().getString(), state.getBlock());
}
}
注解参数说明:
modid:声明这是哪个模组的事件处理器(必须是字符串常量)。bus:挂在哪条总线上。Bus.FORGE(默认)处理游戏运行事件;Bus.MOD处理加载阶段事件(比如RegisterEvent、GatherDataEvent)。- 要求方法为
public static void,参数是具体的事件类型。
为什么这里要求静态方法? 因为 Forge 要通过反射实例化或调用你的类。用 @EventBusSubscriber 时,Forge 不创建对象,直接调用静态方法(static method),所以这些方法必须 static。这也是为什么要用 @Mod.EventBusSubscriber 前缀——它本身就是 EventBusSubscriber 注解在 MOD 总线(Mod EventBus)上的专用版本。
如果你不想用注解扫描,也可以手动把事件处理器注册到总线对象上,两种方式效果等价:
// 在模组构造器里手动注册(处理游戏运行事件)
MinecraftForge.EVENT_BUS.register(new GameEvents());
// 或用 Java 8 方法引用
MinecraftForge.EVENT_BUS.register(GameEvents::onPlayerLoggedIn);
注意区分两条总线的用途,这是新手最容易搞混的地方:注册物品/方块等"游戏内容"走 MOD 总线(mod 加载阶段),监听"游戏中发生的事"(玩家登录、方块破坏、实体受伤)走 FORGE 总线(游戏运行阶段)。把事件挂错总线,方法永远不会被调用。
5.5 综合实战:把本章知识串起来¶
最后写一个完整的迷你模组片段,把注册、事件、泛型、Lambda、Stream 全用上:注册一个"红宝石"物品,当玩家手持红宝石时每 tick 回血,并把红宝石数量记录到日志:
// 主类:声明模组,注册物品
@Mod(ExampleMod.MOD_ID)
public class ExampleMod {
public static final String MOD_ID = "examplemod";
public static final Logger LOGGER = LogUtils.getLogger();
// 延迟注册器 + 注册表对象:泛型 + Lambda(Supplier)
public static final DeferredRegister<Item> ITEMS =
DeferredRegister.create(ForgeRegistries.ITEMS, MOD_ID);
public static final RegistryObject<Item> RUBY =
ITEMS.register("ruby", () -> new Item(new Item.Properties()));
public ExampleMod() {
ITEMS.register(FMLJavaModLoadingContext.get().getModEventBus());
}
}
// 事件处理类:挂在 FORGE 总线上,处理玩家 tick 事件
@Mod.EventBusSubscriber(modid = ExampleMod.MOD_ID)
public class PlayerTickHandler {
@SubscribeEvent
public static void onPlayerTick(TickEvent.PlayerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return; // 只在 tick 结束时处理一次
Player player = event.player;
if (player.level().isClientSide()) return; // 只在服务器逻辑端(Logical Server)处理
// Stream API:统计背包里红宝石的数量
long rubyCount = player.getInventory().items.stream()
.filter(stack -> stack.is(ExampleMod.RUBY.get()))
.mapToInt(ItemStack::getCount)
.sum();
if (rubyCount > 0 && player.getHealth() < player.getMaxHealth()) {
player.heal(0.5f); // 手里有红宝石就慢慢回血
}
}
}
如果你能逐行看懂上面这段代码,恭喜,你已经具备阅读本网站其他章节的基础了。
六、常见新手坑与自测¶
常见新手坑¶
==比较字符串:字符串一律用equals。- 事件挂错总线:注册内容走 MOD 总线,游戏运行时事件走 FORGE 总线(见 5.4 节)。
@SubscribeEvent方法不是static:在@EventBusSubscriber类里忘了写static,方法会静默不生效,日志里只有一条警告。- 修改不可变集合:
List.of()、Set.of()返回的集合不能add,报UnsupportedOperationException。 - 在客户端/服务端逻辑端(Logical Sides)搞混状态:只在
player.level().isClientSide()为 false 时修改世界状态,否则双端不同步会出现"只在客户端生效"的假象。 - 空指针(NullPointerException):Forge 老 API 经常返回
null,调用前先判空,或用Optional。 - 忘记
this:构造器里参数名和字段名相同时,必须用this.字段区分。
自测练习¶
学完本章,试着不看参考答案完成下面三个小练习,能独立完成就说明基础过关了:
- 注册一个自定义物品:用
DeferredRegister注册一个名为silver_ingot的物品,最大堆叠 64。 - 写一个事件处理:在玩家破坏方块时,向聊天栏输出"你破坏了 XX 方块",注意判断方块状态是否为空气。
- 遍历与统计:统计玩家背包中泥土(
Items.DIRT)的总数,超过 32 个时发送一条警告消息。(提示:filter+mapToInt+sum,参考 5.5 节的综合示例)
七、接下来读什么¶
- 01-modfiles.md:模组文件结构,认识
build.gradle、mods.toml - 04-registries.md:注册表与延迟注册器详解,理解
DeferredRegister背后的机制 - 03-items.md:如何自定义物品(覆写
Item方法) - 07-capabilities.md:能力(Capability)系统,涉及大量
Optional与 Lambda - 09-codecs.md:编解码器(Codec),序列化数据的函数式编程风格,能看到
Supplier、Function的密集应用
给新手的最后建议:不要背语法,直接开始改代码——把例子里物品的名字换成你的,把回血量改大点,跑起来看效果。遇到报错就右键看日志,看不懂的类名用 IDEA 的 Ctrl+点击跳进源码。代码看得多了,Java 自然就熟了。