Skip to main content

Forge 导论

0.1 Forge 历史与定义

  • Minecraft 其中发行版的一个大类是由 Java写的商业软件,这意味着:

    • Minecraft 容易反编译和修改:由于 Java 半解释型语言的特性(但凡换成 C++ 就很可能不会有今天 Mod 丰富的生态了,毕竟 Mojang 当年一直不给官方 API);
    • 代码本身是闭源的、被混淆的:这毕竟是一款商业软件。
  • 为了给 Minecraft 增添更多的游戏特性,大家千方百计地寻找添加代码的办法。最终 MCP(Mod Coder Pack)项目诞生了,它规避了没有官方 API 的问题,通过反编译、反混淆直接修改 Minecraft jar 包中的内容。其发展过程中,人们研究各类的名称的产生如下:

    • notch 名:Minecraft 各种类直接反编译、反混淆之后的名称,通常是无意义的字母数字组合。

      例如 j 就是一个典型的 notch 名;

    • srg 名:与 notch 名一一对应。极大的好处是在一个版本里是不会变动的,这意味着类名渐渐可读起来,有相应的前缀后缀来区分。之所以叫做srg名,是为了纪念MCP项目开发的领导者Searge;

      例如 notchj 对应 srg 名的 func_70114_g

    • mcp 名:这是当年 MCP 项目的 mod 开发者使用的最多的名称。在mcp 名中,代码已经是可读的了。和我们正常写java程序中的名称没什么两样,但是也会变动。

      例如,notch 名为 j 的函数,其 srg 名为 func_70114_g,其 mcp 名是 getCollisionBox

  • 随着时间推移和生态的扩展,大家发现这么做很不行,因为直接修改 Jar 文件写 mod 的方式太过于粗暴了,而且 Mod 和 Mod 之间的兼容性可以说基本没有。于是,Forge 项目就诞生了。

  • Forge 是通过修改 Minecraft 方式实现的第三方的 API,给广大 mod 开发者提供了标准化的接口的指引。而Forge本身也在 Minecraft 1.13 版本到来之后经历了一次重写,引入了大量函数式编程的API。

    随着时间的发展,MCP 项目现在已经死亡了,除了 Forge 这套API,Fabric 也风头正盛。

  • Forge 的工作原理也采用了 MCP 的思路。在为指定版本的 Minecraft 安装完 Forge 之后,游戏的运行过程中,所有的内容都会反编译成 srg 运行,你编译好的 mod 同样也会被混淆成 srg,保证它可以正常运行。

    srg 名就是因为它每个版本不变。

0.2 Minecraft 的架构

除了了解 Forge 的历史和定义,Minecraft 的架构和运作方式在 Mod 开发中也绝对是必要的。

  • Minecraft 是一种 C/S 架构,整体逻辑如下:

    • 服务端:负责游戏的逻辑,数据的读写。
    • 客户端:接受用户的输入输出,根据来自服务端的数据来渲染游戏画面。
  • Tips 1. 这里客户端和服务端的区分仅是逻辑上的区分

    • 实际上如果你处于单人模式,那么你的电脑上会同时存在服务端和客户端,而且他们处于不同的线程(Server thread & Render thread);
    • 但是当你连接某个服务器时,你的电脑上只存在客户端,服务端被转移到了远程的一台服务器上。
  • Tips 2. 客户端、服务端各存在一份数据模型。不过「客户端数据模型」只是「服务端数据模型」一个副本,虽然它们都有独立的游戏 Tick,也共享很多相同的代码,但是最终逻辑还是以服务端为准。

  • Tips3. 客户端和服务端是存在于不同线程的,所以它们不可避免地需要同步数据。而数据同步都是通过网络数据包实现的。

    在大部分时候原版已经实现好了数据同步的方法,我们只需要调用已经实现好的方法就行。

    但是在某些情况下,原版没有实现对应的功能,或者不适合使用原版提供的功能,我们就得自己创建和发送网络数据包来完成数据的同步。【可能需要计算机网络基础、Java 网络编程基础】

  • 在代码中,区分服务器端和客户端的方式:World 中有一个 isRemote 字段,开发时判断它就行。

0.3 Minecraft 的运行模式 & 总线

  • 离散事件驱动模式,详见数据结构书籍。这个模式包含了 3 个概念:
    • 事件:“当方块被破坏” 这个就是一个事件,“当玩家死亡” 这个也是一个事件,甚至 “当渲染模型时” 这个也是一个事件;
    • 事件处理器:用来处理 “事件” 的函数。例如可以注册一个事件处理器来处理 “玩家死亡事件”,里面的内容是 “放置一个墓碑”;
    • 总线:总线是连接 “事件” 和 “事件处理器” 的工具,当 “事件” 发生的时候,“事件” 的信息将会被发送到总线上,然后总线会选择监听了这个 “事件” 的 “事件处理器”,执行这个事件处理器。
  • 在Minecraft中,所写的逻辑基本上都是事件处理。
  • 在Forge开发里有两条总线,Mod 总线和 Forge 总线,所有和初始化相关的事件都是在 Mod 总线内,其他所有事件都在 Forge 总线内。

0.4 重要概念准备

  • 注册:如果想往 Minecraft 里添加一些内容,那么你必须做的一件事就是注册。注册是一种机制,告诉游戏本身,有哪东西可以使用。你注册时需要的东西基本上可以分成两个部分:一个注册名一个实例

    更准确地说,是一个对象注册名(唯一标识这个对象的字符串),以及一个创建这个类实例的 Java

  • 资源地址(ResourceLocation):Minecraft 管理、定位资源(音频 / 图片)的方式是采用特殊格式的字符串。格式为:<domain>:<UNIX-Style relative path>

    • 域可以是 minecraft(原版资源),也可以是 mod 的名称。相对路径是相对于 mod 根目录下的 assets 目录而言(如果是原版资源,即域名为 minecraft,那么相对于 .minecraft/assets);
    • 例如:minecraft:textures/block/stone.pngmod1:textures/Alex.png
  • 模型和材质:在游戏中 3D 的对象基本上都有它的模型,模型和材质组合在一起规定了一个对象具体的样子。模型相当于是骨头,材质相当于是皮肤。在大部分时候,你的材质都是 png 图片【可能需要平面设计 和 PS 的相关功底】。

    注意保证材质背景是不透明的,也不要在材质中使用半透明像素,会有不可预知的问题。

0.5 开发环境

Minecraft Forge 是由 Gradle 管理的项目,而 Forge 官方写了一个叫做 ForgeGradle(以后简称 FG)的插件来负责整个 mod 开发环境的配置,本节主要介绍这个环境的配置和使用;

0.5.1 前提

  1. JDK 8 or 11(1.16.x 兼容版本)和 64 bit JVM;请确保您的操作系统已经安装并配置好环境变量 JAVA_HOMECLASS_PATH
  2. 官网获得 MDK(Mod Development Kit):我们这里 1.16.5 选择官网推荐的 forge-36.2.42 Downloads for Minecraft Forge for Minecraft 1.16.5
  3. Java 开发 IDE,可以选 VSCode / Eclipse / IDEA,本文以 IDEA 为例进行。

0.5.2 开发准备工作

  1. 这里我们将下载的 MDK 解压到一个空目录下。以后这里是 mod 项目的根目录了;

    FG 项目结构:

    ├─ .gitattributes		# Git 用来记录某些文件属性
    ├─ .gitignore # Git 用来忽略版本控制的记录文件
    ├─ build.gradle # Gradle 构建脚本
    ├─ changelog.txt # Forge 项目版本迭代情况
    ├─ CREDITS.txt # Forge 项目致谢和 credits
    ├─ settings.gradle # Gradle 插件配置文件
    ├─ gradle.properties # Gradle 属性文件,用来定义其他变量和设置
    ├─ gradlew # Unix 类系统执行 Gradle wrapper 的 shell
    ├─ gradlew.bat # Windows 系统 ~
    ├─ LICENSE.txt # Forge 项目证书
    ├─ README.txt # 基本安装指导说明书

    ├─ gradle/
    │ └─wrapper/ # 这里包含了 Gradle Wrapper,这里使用的是 7.4.2
    │ ├─ gradle-wrapper.jar
    │ └─ gradle-wrapper.properties

    ├─ run/ # 在构建项目后会出现,相当于 .minecraft
    |
    └─src/ # 源文件目录
    └─main/ # main 分组的源文件目录
    ├─java/ # main 分组的 java 源文件
    │ # 这里是 java 的 package,将来在这里写 mod

    └─resources/ # main 分组的资源目录
    ├─ pack.mcmeta # 被 minecraft 用来识别数据和资源包的文件

    └─META-INF/ # Forge 资源 metadata 信息文件存放目录
    └─ mods.toml # mod 声明的文件

    其中最为重要的几个分别为

    • build.gradlesettings.gradlegradle.propertiesgradlew.batgradlewgradle/ 目录(Java Gradle 项目构建系统,类似 C++ 的 CMake);

    关于资源目录,我们在后文会详细介绍,这里先熟悉一下。

  2. 用 IDEA 打开这个新目录,即可自动下载依赖配件、设置项目;如果中途出现错误,99% 是因为网络错误。请竖起梯子,或者上网找国内源解决;

  3. 打开后,在 IDEA Terminal 下执行 gradlew genIntellijRuns(如果是 eclipse/vscode,那么后面一个词分别是 genEclipseRunsgenVSCodeRuns),它会进一步下载游戏需要的资源,并且设置 Run Configure

0.5.3 项目构建脚本 build.gradle

[!Tip]

如果你对 Java 语言和 Gradle 了解不多,你可以按需跳过这节。

但是如果你想要诸如 “其他 mod 联动”、“多 mod 兼容”、“附属 mod”、“Jar 依赖” 的特性,还是需要看一下这节。

先看第一段:

plugins {
id 'eclipse'
id 'idea'
id 'maven-publish'
id 'net.minecraftforge.gradle' version '[6.0,6.2)'
}

version = mod_version
group = mod_group_id

base {
archivesName = mod_id
}

// Mojang ships Java 17 to end users in 1.18+, so your mod should target Java 17.
java.toolchain.languageVersion = JavaLanguageVersion.of(8)

println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}"

上面的内容是准备 Gradle 构建环境、引入 Forge 和 Minecraft 开发的辅助插件(参见 settings.gradle),包括预制的构建任务等等。除了某些教程明确要求(例如 parchment mapping),否则你不应该修改这块内容。

配置文件的下面是 minecraft 闭包,配置中的注释介绍的非常清楚,这里不再赘述。本文只是介绍一下,我们可以在这个闭包中**修改开发时 Gradle Task 的启动参数**(例如 client/server/data 构建任务执行时的命令行参数)。

接下来:

// 添加配置工程的资源存放目录(这里通常是 data generators 生成的),最终打包时会汇总到 JAR 包的对应位置被游戏/你的 mod 识别
sourceSets.main.resources { srcDir 'src/generated/resources' }

然后,在 repositories {} 闭包中,你可以:

  • 添加 Maven Repositories(依赖位于的仓库。一般情况下其实不需要添加,因为比较常见的库都在 Maven 主仓 / Forge 主仓中,而主仓已经在 ForgeGradle 插件配置时引入了);

  • 添加自己的依赖仓库的位置。这通常是本地目录,如果你有些不需要引用源码的 JAR 包依赖,则可以把它们统一放到一个目录中,然后在这个闭包中将这个目录告诉 Gradle,这样在 dependecies 闭包中引用自己的 JAR 包就能找到了(有点像 CMake 的 target_include_directories);举个例子:

    repositories {
    flatDir {
    dir 'libs'
    }
    }

dependencies {} 闭包中,我们可以引用在 Maven 主仓 / Forge 主仓 / repositories 引入的依赖库。默认一定需要引入 minecraft

一般我们有几种情况需要添加依赖:例如开发时的工具库 (e.g. moonlight library)、mod 联动,等等。

举个例子,如果我需要和 JEI 联动,那么我们就要引入 JEI 的开发 API 和运行时依赖,这时就需要添加下面的配置:

compileOnly fg.deobf("mezz.jei:jei-${mc_version}:${jei_version}:api") // Adds JEI API as a compile dependency
runtimeOnly fg.deobf("mezz.jei:jei-${mc_version}:${jei_version}") // Adds the full JEI mod as a runtime dependency

另外,如果我需要引入之前在 repositories 中指定的本地依赖,则需要使用 blank 作为 group ID:

// 假设我们在 ./libs/ 中有一个 coolmod-${mc_version}:${coolmod_version}.jar
// 然后我们前面在 repositories 中引入了 libs 目录(./libs),这里就能找到
implementation fg.deobf("blank:coolmod-${mc_version}:${coolmod_version}")

其中 deobf 是 ForgeGradle 帮我们准备的工具函数,可以对指定的 JAR 文件反混淆,帮助我们进行 Mod 开发和代码 Hint;

关于 dependencies 的依赖配置,更多的使用场景和使用方法,可以参见下面的 Gradle 官方文档:

综上,如果你是有 Java 开发经历的开发者,并且使用过 Gradle,那么这里的阅读难度并不大。

再下面的内容就是在构建时将 gradle.properties 中的环境值替换到 META-INF/mods.tomlpack.mcmeta 这样的文件中,实现配置的统一管理。具体设置请参见下节。

0.5.4 项目环境配置文件 gradle.properties

Forge 36.2.42 已经将几乎所有需要配置的信息都集中在这个文件中,我们按照注释和变量名称填写设计就好了!

  • 将 “examplemod” 都替换为自己 mod id(自己定,唯一,只能有小写字母,不能有大写/空格/其他字符,切记!!!)

0.6 项目结构

  • 和普通 Java 项目一样,设定好 top level package,然后为 mod 起一个唯一的名字;比如,我的 top level package 名叫做 com.test,然后我起个包名 helloMC,于是叫 com.test.helloMC

    还是补充一下,如果以后做 Java Web,top level package 的名字需要是自己有的域名前缀,例如我有 xxx.org,那么为这个域名开发 Java Web 服务的规范就是 top level package org.xxx

  • mods.toml 文件设计:上面说过,这个文件定义了 mod 的元信息(metadata),可以被 mod 使用者在游戏添加 mod 的界面看到

    • 一个信息文件能够描述多个 mod;

    • mods.toml 的语言是 TOML(可以理解为和 YAML 差不多的东西,语法不一样),必须要被存放于 src/main/resources/META-INF/ 下;

    • 在 Forge 36.2.42 中,配置信息都已经由环境变量的形式管理在 gradle.properties 中,这里无需配置。不过还有些额外的配置(例如 displayURL / credits)你可以在这个 TOML 文件中修改;

  • @Mod annotation:在编写 mod 中,这个标识是用来提示 Forge Mod Loader这个类是一个 Mod entry point;另外,这个标识的值需要是 modid(在 gradle.properties 中配置过);

  • 一个建议(不强制):比起将源文件散落在文件夹中,使用 sub-packages 的结构可读性更强;例如像物体(items)、方块(blocks)、数据实体(tile entities,或者其他 sub-packages)应该放在 common 包,而屏幕(Screens)、渲染器(Renderers)应该放在 client 下;

    这里补充一下什么是 tile entities,后面用到详细说。

    Tile Entities就像简化的实体一样,绑定到Block上。 它们用于存储动态数据,执行基于tick的任务以及动态渲染。

    原本 Minecraft的一些例子是:处理库存(箱子),熔炉上的冶炼逻辑或信标的区域效应。 mod中存在更高级的示例,例如采石场,分拣机,管道和显示器。

    注意:不要滥用!如果使用不当会导致卡顿;

  • 类名命名规范:在您创建的类名之后加上它们的父类名(即是什么),可以让读者更能理解这个类在干什么

    这其实是所有语言、所有场景开发的共同的规范。

    例如,一个自定义物品 PowerRing,它的类继承于 Item,因此最好定为 PowerRingItem;

    再如一个自定义方块 NotDirt,继承于 Block,因此命名为 NotDirtBlock

  • forge 项目的构建和测试;

    • 当 mod 开发结束后,根目录运行 gradlew build,会向 build/libs 中构建 [archivesBaseName]-[version].jar,您可以直接将这个包放在安装了 forge 的 minecraft 游戏的 mod 目录中,即可加载;

    • 当然,每写一次就要加入游戏目录的操作不现实。所以测试中,可以使用 Run Configure 来运行测试的 Minecraft 服务器 + 客户端,这时会加入开发目录(之前说过,src/main/java)的所有 mod jar 包

      具体命令:启动服务器 gradlew runServer,自动绑定在 localhost 的指定端口;启动客户端 gradlew runClient

0.7 Mod 更新系统

Forge 提供了一个可选的、轻量级的更新检查的框架,在作者提交更新后,使用 mod 的用户会在游戏中 mod 管理的按钮上看到更新,并且会写入 changelogs.txt,但不会自动下载升级;

为了集成这个功能,只需设置上面 mods.toml 的可选参数 updateJSONURL,这个 URL 可以指向您提供 “update json” 的网站服务器或者 github 上(只要别人能访问到);

而这个 “update json” 的格式为:

{
"homepage": "<homepage/download page for your mod>",
"<mcversion>": {
"<modversion>": "<changelog for this version>",
// List all versions of your mod for the given Minecraft version, along with their changelogs
...
},
"promos": {
"<mcversion>-latest": "<modversion>",
// Declare the latest "bleeding-edge" version of your mod for the given Minecraft version
"<mcversion>-recommended": "<modversion>",
// Declare the latest "stable" version of your mod for the given Minecraft version
...
}
}

值得注意的是:

  • homepage 的地址在使用者的 mod 需要更新时会显示出来,注意隐私;
  • Forge 使用一套内置算法来判断当前版本和 update JSON 的版本哪个更新,大多数情况下应该没问题,如果有疑惑可以查阅 ComparableVersion 类或 Semantic Versioning
  • 上面的 changelog 字符串可以使用 \n,也可以给用户提供一个网站让他们在网站上详细看;

0.8 Mod 进阶调试

Minecraft 自身提供了一个 Debug Profiler,能够分析出耗时的代码块,这对于 mod 开发者和服务器管理员非常有用;

开始分析命令:/debug start,结束分析命令:/debug end

  • 建议最少给 Debug Profiler 的分析留出 1 min 时间,时间越多,分析约准确;

  • 要分析的实体(Entities)需要在当前世界中存在,不然分析不到它;

在结束分析后,会自动生成 profile-results-yyyy-mm-dd_hh.mi.ss.txt

文件格式:<the depth of the section> | <the name of the section> - <the percentage of time it took in relation to it’s parent>/<how much time it took from the entire tick>

0.9 第一个 Mod:跑通流程

上面说了很多内容,现在让我们以一个没有内容的测试 mod 来实际跑一遍:

step 1.src/main/java 下创建一个包,例如 com.test,创建 Java 类 HelloMC

step 2. 根据 0.6 节中介绍的 modid,需要用 @Mod(<modid>) 修饰 mod 的 entry point 类,保持:@Mod 修饰值、mods.tomlmodidbuild.gradleexamplemod 三者一致。因此,我们需要给 HelloMC@Mod() annotation。为了方便,我们另外创建一个类,专门存储全局变量,就定为 Utils(你也可以不用,看自己的编码习惯):

// File: HelloMC.java
package com.test.HelloMC;

import net.minecraftforge.fml.common.Mod;

@Mod(Utils.MOD_ID)
public class HelloMC {
// 这里先空着
}
// File: Utils.java
package com.test.Utils;

public class Utils {
public static final String MOD_ID = "mymod"; // 自己写 modid
}

step 3. 现在去写 gradle.properties,注意填写 mod_id:如果需要的话可以补充一下 mod.toml 的可选配置。修改个性化内容,然后构建!

step 4. 启动 IDEA 上的任务设置 “RunClient”,进入游戏查看自己的 mod 是否已经显示!

0.10 事件系统

早在 0.3 节说明运行模式的时候,我们提到了 Mod 总线和 Forge 总线,这里在开发前必须要说清楚。Forge 自己的事件系统内是独立于 Minecraft 的事件系统的。

使用 Forge 事件系统的方法有 2 种,先看个例子:

public class TestEventHandler {
@SubscribeEvent
public void pickupEvent(EntityItemPickupEvent event) {
System.out.println("Item picked up!");
}
}

这里定义了一个类 TestEventHandler,里面有个实例方法 pickupEvent()。注意 @SubscribeEvent 标记,它的作用就是指示下方的方法为事件处理器,而它监听的事件类型由它的参数决定(EntityItemPickupEvent,实体捡起物品这个事件)。

但是只是让他声明为事件处理器还不够,还需要在合适的位置将含有事件处理器的类实例化,并注入事件总线中。是 Forge 总线还是 Mod 总线?

  • Mod 总线:负责游戏的生命周期事件,也就是初始化过程的事件;注册方法:FMLJavaModLoadingContext.get().getModEventBus().register(<subscribed_event_obj>)

  • Forge 总线:负责的就是除了生命周期事件外的所有事件;注册方法:MinecraftForge.EVENT_BUS.register(<subscribed_event_obj>)

显然,上面 “实体捡起物品” 的事件在初始化过程之外,所以加入 Forge 总线:

MinecraftForge.EVENT_BUS.register(new TestEventHandler());

显然这样注册的方法比较麻烦,被称为 “实例注册方式”。还有一种:

@Mod.EventBusSubscriber(modid = "mymod", bus = Bus.FORGE, value = Dist.CLIENT)
public class MyStaticClientOnlyEventHandler {
@SubscribeEvent
public static void drawLast(RenderWorldLastEvent event) {
System.out.println("Drawing!");
}
}

不管传给注解的参数(都是可选的),我们发现,这个类中的事件处理器是静态的(另注:由于是渲染事件,所以仅客户端)。这里,我们就可以在整个类前加上注解 @Mod.EventBusSubscriber([modid, bus, value]),表示将类中的所有事件处理器都加入指定 modid、总线、端中。

这里补充,如果是 Mod 总线,那么 bus = Bus.MOD,更多信息可以转到 @Mod.EventBusSubscriber() 的源码查看。

更多事件、Mod 声明周期相关的信息,我们将在有这方面需求之后再介绍(毕竟如果一口气全讨论完但是不用,那很快就会忘记)。