跳转至

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 会帮你做三件关键的事:

  1. 代码补全:输入 player. 自动列出所有可调用的方法,这是你探索 Minecraft 庞大 API 的最大助力。
  2. 错误提示:语法错误、类型不匹配会立刻标红。
  3. 一键运行:直接点运行按钮就能启动 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 通常这样声明:

public static final String MOD_ID = "examplemod";

Java 10 起可以用 var 让编译器推断类型(类型推断,Type Inference),写起来更短,但要注意它不是动态类型:

var player = event.getEntity(); // 等价于 Player player = event.getEntity();

数组(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

forwhile 与 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 里也大量使用重载,比如 ItemStackhurt 方法就有多个版本。

可变参数(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 关键字创建对象:

ItemStack stack = new ItemStack(Items.DIAMOND, 1); // 创建一个"1 个钻石"的物品堆

类由两部分组成:字段(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(所有物品的祖宗)
 └── SwordItem(剑)
      └── MagicSword(你自己的魔剑)

实战例子——自定义一个"吃不完的面包",继承 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 中 ItemBlockEntity 这些体系都用继承,而"可交互""可被拾取"这类横切能力用接口(如 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 必须相同。因为 HashSetHashMap 先按哈希值找"桶",再在桶内用 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() 产生流,然后链式调用各种操作,最后 collecttoList() 收尾。常用三步曲: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() 返回的是不可变列表,不能 addnew 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):不强制处理,比如 NullPointerExceptionNumberFormatException、数组越界。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 怎么知道去哪找它们呢?两种方式:

  1. 在事件总线对象上手动 register 一个类的实例。
  2. 更常用:给类加 @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 处理加载阶段事件(比如 RegisterEventGatherDataEvent)。
  • 要求方法为 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); // 手里有红宝石就慢慢回血
        }
    }
}

如果你能逐行看懂上面这段代码,恭喜,你已经具备阅读本网站其他章节的基础了。

六、常见新手坑与自测

常见新手坑

  1. == 比较字符串:字符串一律用 equals
  2. 事件挂错总线:注册内容走 MOD 总线,游戏运行时事件走 FORGE 总线(见 5.4 节)。
  3. @SubscribeEvent 方法不是 static:在 @EventBusSubscriber 类里忘了写 static,方法会静默不生效,日志里只有一条警告。
  4. 修改不可变集合List.of()Set.of() 返回的集合不能 add,报 UnsupportedOperationException
  5. 在客户端/服务端逻辑端(Logical Sides)搞混状态:只在 player.level().isClientSide() 为 false 时修改世界状态,否则双端不同步会出现"只在客户端生效"的假象。
  6. 空指针(NullPointerException):Forge 老 API 经常返回 null,调用前先判空,或用 Optional
  7. 忘记 this:构造器里参数名和字段名相同时,必须用 this.字段 区分。

自测练习

学完本章,试着不看参考答案完成下面三个小练习,能独立完成就说明基础过关了:

  1. 注册一个自定义物品:用 DeferredRegister 注册一个名为 silver_ingot 的物品,最大堆叠 64。
  2. 写一个事件处理:在玩家破坏方块时,向聊天栏输出"你破坏了 XX 方块",注意判断方块状态是否为空气。
  3. 遍历与统计:统计玩家背包中泥土(Items.DIRT)的总数,超过 32 个时发送一条警告消息。(提示:filter + mapToInt + sum,参考 5.5 节的综合示例)

七、接下来读什么

  • 01-modfiles.md:模组文件结构,认识 build.gradlemods.toml
  • 04-registries.md:注册表与延迟注册器详解,理解 DeferredRegister 背后的机制
  • 03-items.md:如何自定义物品(覆写 Item 方法)
  • 07-capabilities.md:能力(Capability)系统,涉及大量 Optional 与 Lambda
  • 09-codecs.md:编解码器(Codec),序列化数据的函数式编程风格,能看到 SupplierFunction 的密集应用

给新手的最后建议:不要背语法,直接开始改代码——把例子里物品的名字换成你的,把回血量改大点,跑起来看效果。遇到报错就右键看日志,看不懂的类名用 IDEA 的 Ctrl+点击跳进源码。代码看得多了,Java 自然就熟了。