本篇教程由作者设定未经允许禁止转载。

前言

如题,本篇教程将从 0 开始,使用 Intellij IDEA,一步步带你开发属于自己的 Forge Mod(基于 Minecraft 1.20.1)。

在正式开始之前,需要先说明本教程的定位与阅读预期。

本教程不会系统讲解 Java 基础知识也不会详细介绍 Java 的语言特性或语法原理。因此,在阅读本教程前,你应当已经具备一定的 Java 基础,例如理解面向对象编程思想、基本语法结构,以及常见开发流程。

本教程的核心目标,并不是从编程入门开始教学,而是提供一套可直接运行、可扩展的 Mod 开发框架。教程中展示的代码更偏向“框架级实现”。我将给出最小可运行的结构;展示 Forge Mod 的整体组织方式;提供具有可复用性的开发骨架。

你可以在此基础上自由扩展内容,而不是从空工程开始反复试错。

换句话说,本教程关注的是如何搭建一个能够持续开发与维护的 Mod 工程,而不是逐条解释 Java 本身如何工作。

对于没有编程基础的读者来说,直接照抄代码可能依然能够运行示例内容,但在后续修改、扩展或排错时往往会遇到困难,只能停留在模仿阶段。因此,建议将本教程视为一份面向已有基础开发者的实践指南,而非零基础编程课程。

我将学到什么?

阅读完本教程后,你将能够:

(1)理解一个 Mod 的基础工程结构与模块划分;

(2)掌握常见注册系统(物品、方块等)的组织方式;

(3)建立可扩展、可维护的代码框架;

(4)在已有框架上持续添加自己的功能内容。

本教程的重点是建立开发思维工程结构,而不仅仅是实现单个功能。

本篇教程不会教什么?

为了保持教程的聚焦性,以下内容不会进行系统教学:

* Java 基础语法

* 面向对象编程入门

* Java 开发环境基础使用

* 编程零基础启蒙内容

因此,如果你尚未接触过 Java,建议先学习基础课程后再阅读本教程,否则可能难以理解代码结构设计的原因。

PS:个人建议 0 基础学习者每天学习 1-2h,持续学习两个月左右,重点不是学完所有语法,而是学会理解代码结构

推荐受众

本教程适合下列四类人群阅读:

(1)已具备基础 Java 知识的开发者;

(2)想进入 Minecraft Mod 开发但缺少整体框架认知的人;

(3)希望建立规范工程结构,而不是零散写代码的开发者;

(4)想要一套可复用开发模板的人。

如果你已经会写 Java,但打开 Forge 工程时不知道从哪里开始,那么本教程正是为你准备的。

我该怎么学?

建议不要单纯复制代码,而是:

(1)先理解每一步“为什么这样组织”;

(2)再尝试在框架中加入自己的内容;

(3)尽量主动修改示例代码进行实验。

Mod 开发真正的门槛不在于“让代码运行”,而在于能够修改、扩展并长期维护它。

希望本教程能够成为你构建自己 Mod 世界的起点。

创建一个新项目

关于如何从 0 开始搭建一个模组的开发环境,社区有很多教程已经有详细解释,这里就不多说了。如图,我打算创建一个新的项目(左上角工具栏:文件->新建->项目...),来作为本篇教程的示例 Mod。

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第1张图片

这里只用关注图中方框标注的部分。生成器选择 Minecraft,JDK 选合适的即可,然后版本是 1.20.1,Forge 可以选择最新版本,也可以选择其它稳定版本。

这里有两个重点,一个是紫框标注的主类,另一个是红框标注的三个命名区。

主类

你会发现我在图中写了:

org.kdvcs.tutorial.Tutorial

它实际对应的目录结构为:

org(反向域名前缀)
└── kdvcs(作者标识,这里就是我的昵称)
    └── tutorial(Mod ID)
        └── Tutorial.java(主类)

也就是说,这一串看似复杂的名称,本质上只是文件夹层级 + 类名的组合。

在创建项目时,你也可以按照这种方式组织主类的位置。这是一种较为标准、工程化的项目结构,能够在后续开发中保持清晰与可维护性。

需要注意的是,这一层目录位于:

src/main/java

关于 src 目录的整体作用,会在后续的项目结构章节中详细说明。

在主类输入框中的这一串内容,我们称之为包结构(Package Structure),它对应 Java 代码中的声明:

package org.kdvcs.tutorial;

Java 规定:包名必须与文件夹结构完全一致。因此,如果你移动或重命名这些文件夹,但没有同步修改 package 声明,项目将无法正常编译。

其实你真的这样做的话,IDEA 是会自动帮你更新声明的,但即便如此,也不建议移动和修改

常见问题:

Q:为什么要这样设计?

A:除 Java 本身的规定之外,这样做还能够避免不同作者的类名冲突、方便组织大型项目结构,以及让编译器能够正确定位类。

Q:为什么包名看起来像网址?org.kdvcs.tutorial 看上去像一个倒过来的域名。

A:这是 Java 社区的惯例。使用“反向域名”作为包名前缀,以保证全球唯一性。类似地,你也可以使用 net,com 等反向域名前缀。

名称、Mod ID、Mod 名称

然后再说三个命名区,我会用一张表来区分它们——即名称、Mod ID、Mod 名称三者的区别:

项目给谁看的命名规则是否影响游戏是否可以随意修改
Mod IDMinecraft / 程序只能使用小写字母及下划线是,且影响极大一旦确立,基本不要改
Mod 名称玩家可以包含空格和大写字母
名称(即项目名称)开发者同上

因此,命名 modid 时,要非常谨慎,一旦模组发布,就不要修改 modid,否则会引起相当严重的问题。

如果你实在分不清,那就三个名称统一遵循 Mod ID 的命名规则,这样一定不会错的。

最后,点击创建按钮,就可以得到这么一个东西:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第2张图片

其实 IDEA 在此时已经为你写好了一个可以运行的 mod,你已经可以跑这个项目了,以确认它已经加载。

运行方法很简单,你只需要按照下图的内容依次点击展开就行:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第3张图片双击 runClient,运行游戏。

然后你就可以在 Mod 列表看见你刚刚创建的 mod:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第4张图片

这样就说明加载成功了,接下来我们就可以开始对其进行开发了!

开发前的准备:环境配置与必知架构(建议阅读)

在正式进入 Mod 开发之前,本章将介绍项目结构与开发环境的基础配置。

本章内容较多,但都属于一次性设置。完成后,后续开发过程中将很少再次涉及这些内容。之所以将其集中放在开头,是为了避免在编写功能时频繁被环境问题打断。

如果你希望尽快进入代码编写,可以先浏览本章结构,在遇到相关问题时再回到对应小节查阅。

对于有经验的开发者,这部分内容并不是很必要。

认识项目架构

本节会介绍一些基础的 Mod 架构知识,只讲解最主要的部分,方便开发者快速对照。

项目根目录

项目根目录(Project Root Directory),指的是整个 Mod 工程的最顶层文件夹,也就是包含项目全部内容的起始位置。识别项目根目录有一个最简单的方法,即看该目录下是否包含 build.gradle、gradle.properties、settings.gradle、gradlew 等文件。如在图中,根目录就是最顶层的 Tutorial 文件夹。

Q:为什么要认识项目根目录?

A:在 Mod 开发中,很多路径都是相对于项目根目录而言的。例如:Gradle 构建路径、数据生成输出位置、资源文件定位、Git 仓库管理范围等。多数在终端执行的指令也应该在项目根目录执行。

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第5张图片

build/ 构建输出目录

在项目根目录下,有一个 build 目录,这是 Gradle 自动生成的目录。它的主要功能是存放构建产物。这里只用清楚 Mod Jar 的存放位置即可。我们可以先对目前的 mod 进行一次 jar 输出,方法如图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第6张图片

双击 jar 按钮,等待一段时间后,控制台会出现 BUILD SUCCESSFUL 的提示:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第7张图片

jar 是构建产物,自然进入了 build 文件夹,具体位置:build/libs/tutorial-1.0.0.jar:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第8张图片以后自己发布 mod 的时候,就去这个位置寻找构建的 jar 即可。

src/ 源代码入口

src 目录就是你真正工作的地方了。它的结构通常是这样的:

src/
└── main/
    ├── java/
    └── resources/

注:Mod 的实际内容几乎全部位于 src/main 内。

然后拆开解释一下:

(1)src/main/java:

作用:存放所有 Java 源代码(Mod 主类的位置、注册类、Block / Item / Event 等)

我们可以把它叫做“程序逻辑层”。

本教程中的主类位置:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第9张图片

(2)src/main/resources:

作用:存放游戏资源与数据文件(贴图、音效、配方等)

run/ Minecraft 运行目录

作用:Minecraft 运行目录,runClient 启动后的游戏文件(运行日志、游戏截图、游戏存档...)都在这里。你可以把它看作一个普通的 Minecraft 版本文件夹

注意:run 目录下虽然有 mods 文件夹,但一定不要直接将 Mod 文件手动放入其中!Forge 开发环境具有自己的 Mod 注入与加载机制,Mod 应通过项目构建与运行配置自动加载,否则可能导致加载异常或调试环境不一致。(相关内容会在后续提及)

构建环境初始化

了解完基本的项目架构之后,我们需要对构建环境进行一些必要的配置(主要涉及 build.gradle 与 gradle.properties)。这些配置的目的并不是改变 Mod 的功能,而是优化开发体验,减少在开发过程中可能遇到的环境问题,例如 IDE 警告干扰、构建阶段的异常检查,以及运行任务时不必要的资源重复下载等情况。

通过提前完成这些调整,可以使后续的开发流程更加稳定,也能够避免在编写功能时被环境问题频繁打断。

认识 build.gradle 与 gradle.properties

在接下来的配置过程中,我们将频繁修改两个文件:build.gradle 与 gradle.properties。因此,在正式开始之前,我们有必要先了解它们各自的作用。

build.gradle —— 构建脚本

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第10张图片

build.gradle 是整个项目的构建脚本文件,用于定义项目应当如何被构建与运行。它告诉 Gradle “如何编译这个 Mod”。

在 Forge 开发中,它主要负责:指定 Minecraft 与 Forge 版本、配置依赖库、定义编译行为、设置运行任务(runClient、runData 等)以及调整构建参数与编译选项。

需要注意的是,build.gradle 本质上是一份 Groovy 脚本,而不是普通配置文件。因此我们可以在其中编写逻辑代码,而不仅仅是做键值配置。

gradle.properties —— 构建参数配置

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第11张图片

与 build.gradle 不同,gradle.properties 是一个纯配置文件,用于存放构建时使用的参数与全局设置。

它提供给 Gradle 使用的“全局变量与运行参数”。

在 Forge 项目中,它通常用于:定义 Mod 版本号、设置 JVM 参数、控制 Gradle 行为配置、构建性能选项以及存放可复用的项目变量。

用一个表总结,就是:

文件类型作用
build.gradle构建脚本(Groovy)定义“如何构建”
gradle.properties配置文件定义“构建使用的参数”

简单来说,build.gradle 决定做什么,gradle.properties 决定用什么参数去做

如果对这两个文件进行修改,必须重新加载 gradle。点击下图中所展示的图标即可加载 gradle 更改。(任意对这两个文件进行修改后,就会在右上角弹出此图标)

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第12张图片

了解完它们的基础功能后,我们就可以开始修改配置了。

屏蔽 @Removal 标记的警告

在高版本环境下,一部分旧 API 已被标记为弃用,因此 IDEA 会提示“将在未来版本中移除”的警告信息。该提示不会影响当前运行,但在 Forge 的 runData 执行过程中可能触发额外检查,导致任务中断并需要重新执行。

为避免此类非关键警告干扰开发流程,通常建议在开发环境中屏蔽相关 warning,以获得更加稳定的构建体验。

打开 build.gradle,在底部加入以下内容:

tasks.withType(JavaCompile).configureEach {
    options.encoding = 'UTF-8'
    options.compilerArgs += ["-Xlint:-removal"]
}

如图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第13张图片

这段配置完成了两件事:

* 统一 Java 编译编码为 UTF-8

* 屏蔽 @Removal 相关警告,避免开发阶段干扰

具体解释如下:

tasks.withType(JavaCompile).configureEach

含义:

* JavaCompile 是 Gradle 中负责编译 Java 源码的任务类型

* withType(...) 表示选中所有此类型的任务

* configureEach 表示为每一个任务应用配置

也就是说,无论是运行游戏还是构建 jar,只要涉及 Java 编译,都会使用上面的设置。

options.encoding = 'UTF-8'

作用:指定 Java 源代码的编译编码为 UTF-8。

Q:为什么需要编码为 UTF-8?

A:三点原因:

(1)默认编码可能随操作系统变化;

(2)中文注释或字符串可能出现乱码;

(3)不同开发环境编译结果不一致。

在设置编码为 UTF-8 后,所有机器编译结果一致、中文文本不会乱码,也避免了跨平台问题。

options.compilerArgs += ["-Xlint:-removal"]

作用:关闭 Java 编译器中关于 @Removal(即将移除 API) 的警告提示。

在较新的 Java 版本中,一些旧方法会被标记为“将在未来版本中移除(marked for removal)”。IDEA 会对此产生 warning。

这些警告虽然不影响当前运行,也不影响 Mod 功能,但它可能在 runData 等任务中触发额外检查,导致任务中断,需要手动重启。

参数含义解释:

-Xlint            启用编译警告系统
-removal      禁用 removal 类型警告

这样做可以有效减少无关提示对开发流程的干扰。

补充说明 —— @Removal 和 @Deprecated 的区别

你可能会问,既然选择屏蔽 @Removal 警告,那么是否需要顺手也屏蔽 @Deprecated 警告呢?

一句话回答:通常不建议屏蔽 @Deprecated 警告。

因为两种警告类型并不相同,如下表:

标记含义是否建议屏蔽
@Deprecated不推荐使用,但仍长期存在
@Removal即将在未来版本删除

具体解答:

Q:什么是 @Deprecated?为什么不建议屏蔽?

A:当一个方法被标记为 @Deprecated 时,表明这个 API 有更好的替代方案,但当前版本仍然支持使用。它的本质是一种迁移提醒,而不是错误。很多 Forge 或 Minecraft API 会长期保留 @Deprecated 注解的方法以维持兼容性。这类警告往往是有价值的,因为它能告诉你,方法有更新的写法、未来你可能需要迁移代码,以及当前写法并不是最佳实践。如果直接屏蔽,你可能错过重要的 API 变化信息。

Q:为什么 @Removal 可以屏蔽?

A:因为此注解表示,该 API 已确定将在未来版本删除。它们对当前版本开发没有实际影响,且数量可能较多,容易干扰 runData 等任务流程。因此在开发阶段屏蔽属于一种环境优化

Q:我的项目并不打算考虑 API 变化问题,如果我确实想屏蔽 @Deprecated,我该怎么办?

A:只需要添加:

options.compilerArgs += ["-Xlint:-deprecation"]

关闭 Forge 证书校验

Forge 在构建过程中会对远程依赖执行证书校验,用于验证下载资源的可信性。在某些网络环境下,该机制可能导致依赖解析失败,从而影响项目构建。对于开发者来说,证书校验并不是一个必要环节。因此,关闭相关校验,可以提升依赖下载的稳定性,以保证构建流程顺利进行。

在 gradle.properties 顶部 org.gradle.jvmargs (默认是第一行)部分添加:

-Dnet.minecraftforge.gradle.check.certs=false

也就是说,改完之后的第一行是长这样的:

org.gradle.jvmargs=-Xmx3G -Dnet.minecraftforge.gradle.check.certs=false

这样就可以关闭烦人的证书校验了。

运行任务的离线资源配置

在默认情况下,Forge 开发环境在运行 runClient / runData 等任务时,会尝试从官方服务器下载 Minecraft 的原版资源(assets),例如音效、语言文件与部分基础资源。当本地缓存未被正确识别时,后续运行过程中也可能重复触发资源下载,从而导致启动速度变慢,甚至在网络环境不稳定时直接导致运行失败。

为避免这一问题,我们可以通过指定本地资源路径,使开发环境直接读取已有的 assets,实现离线运行

该配置位于 build.gradle 的 minecraft → runs → configureEach 区块中,找到:

minecraft {
    runs {
        configureEach {

            workingDirectory project.file('run')     

        }
    }
}

然后在 workingDirectory ... 后添加一行(其中路径应该指向你有完整 assets 资源的位置。我的资源位于 D:/Minecraft/assets,所以我才像下边那样写):

environment 'assets_root', file('D:/Minecraft/assets').absolutePath

解释:

assets_root 是 Forge 启动开发环境时读取的一个环境变量,用于指定 Minecraft 原版资源(assets)的加载位置。将资源路径定向到本地,即可避免运行时联网下载资源。

只有被这玩意折磨过的才知道离线环境是多么重要

注意:该配置仅影响开发环境,不会被打包进最终生成的 Mod,也不会影响玩家运行你的 Mod。

配置 Mapping(开发映射)

接下来,我们将修改项目中的 Mapping 配置。Mapping 决定了在开发过程中 Minecraft 源码的命名方式,也就是你在 IDE 中看到的类名、方法名与参数名称。

默认情况下,Forge 使用的是官方映射(official mapping),虽然已经具备基本可读性,但缺少方法参数名称与部分补充信息,在实际开发中阅读体验较差。因此,这里我们将切换为社区维护的 Parchment Mapping,以获得更加完整且易读的开发环境。

在 settings.gradle 中,在 pluginManagement → repositories 区块处添加:

maven {
    url = 'https://maven.parchmentmc.org'
}

如图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第14张图片

这里简单提一下 settings.gradle 的作用:它负责定义 Gradle 项目的仓库解析规则,包括插件与依赖的下载来源。也就是说,我们在这里告诉 Gradle,去哪下载需要的文件。

在 gradle.properties 中,找到:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第15张图片把它们修改成:

mapping_channel=parchment

mapping_version=2023.06.26-1.20.1

这一步用于指定 Mapping 来源以及使用指定版本的映射数据

随后,在 build.gradle 的 plugins 区块底部添加:

id 'org.parchmentmc.librarian.forgegradle' version '1.+'

如图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第16张图片

这一步让 ForgeGradle 能够加载并应用 Parchment Mapping。

为什么我们要配置 Mapping?因为在使用 Parchment Mapping 后:方法参数将拥有可读名称,这使得 Minecraft 源码更易理解,且后续教程中的代码你更容易跟随。

因此,在正式开始编写 Mod 内容之前,强烈建议先完成 Mapping 的切换。

你可能好奇不做这步的话实际编写体验会是什么样的,这里有一张截图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第17张图片看着图上那堆混淆后的参数名(红框部分),即使你能根据常识推出其实际的参数名,但在编写的时候是不是仍然感觉一头雾水?这就是配置 Mapping 的重要性。

IntelliJ IDEA 设置:关闭 “使用 API 标记为已删除” 检查

在 Forge 开发过程中,你可能会发现 IDEA 会在部分方法上持续显示红色或黄色波浪线提示,例如 ResourceLocation (自 1.21.6 起移除)等在 1.20.1 版本仍然可以正常使用的 API。

这些提示通常来源于 IDEA 的代码检查项 “使用 API 标记为已删除(Marked for removal)”。该检查用于提醒开发者某些 API 将在未来 Java 版本中被移除,但在当前 Minecraft 与 Forge 所使用的 Java 版本中,这些方法仍然是可正常使用的。因此,在 Mod 开发环境中,这类提示往往只会增加视觉干扰,而不会带来实际帮助。为了避免无关警告影响阅读与编写代码,建议关闭该检查项。

设置方法,打开 IDEA 顶部工具栏,找到:

文件
 → 设置
 → 编辑器
 → 检查
 → Java
 → 代码成熟度

然后取消勾选:

□ 使用 API 标记为删除

如图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第18张图片注意:该设置仅影响 IDEA 的代码提示行为,并不会改变 Java 编译结果。

做完上述内容,你就完成了构建环境的初始化。接下来在模组开发的过程中就会十分舒适,再也不会遇到各种烦人的编译问题啦!

使用 GitHub 管理项目代码(建议阅读)

在完成开发环境配置后,接下来我们将为项目创建一个 GitHub 仓库,并将当前 Mod 工程推送至远程仓库进行版本管理。

虽然 GitHub 并不是 Mod 开发的必需步骤,但在实际开发中,使用版本控制工具几乎是工程化开发的基础。通过远程仓库管理代码,可以有效记录项目的修改历史,避免误操作导致代码丢失,同时也方便后续的版本迭代、协作开发与发布管理。

创建 GitHub 仓库

登录你的 GitHub 账户,并新建一个仓库,推荐设置如图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第19张图片仓库名称(Repository Name):
用于指定你的项目仓库名称,可以自由命名,但建议与本地项目名称保持一致,便于后续管理与识别。

重点关注下方的 Configuration(配置) 部分。首先可以看到我用黄框标注了 Visibility(可见性) 选项:默认设置为 Public(公开仓库),你也可以根据需要设置为 Private(私有仓库),仅自己可见。

Add README:

建议开启。README 是仓库的说明文档,用于介绍项目内容。当他人访问你的仓库时,GitHub 会自动将 README 展示为首页内容,作为项目的基本说明与入口文档。后续可以在其中补充项目介绍、使用方法或开发说明。

Add .gitignore:

建议添加。.gitignore 用于告诉 Git 哪些文件不应被版本控制跟踪。

在 Mod 开发过程中,需要频繁进行调试(如运行客户端、创建存档等),这一过程会产生大量自动生成的文件,例如:

build 输出、Gradle 缓存、IDE 配置文件、本地运行数据等。

这些内容均可自动生成,不应上传至仓库。版本控制的目标是管理源码与项目配置,而不是作为文件备份空间,因此 .gitignore 是工程化开发中非常重要的一步。

Add Licence:

建议添加。许可证(License)用于说明他人是否可以使用、修改或再发布你的代码。

如果仓库没有附带 License,从法律意义上来说,默认他人没有权利使用你的代码

常见许可证说明如下表:

许可证类型特点
MIT最宽松,允许自由使用
GPL修改后必须开源
Apache 2.0类似 MIT,但更正式
None保留所有权利

如果没有特殊需求,一般推荐选择 MIT License。其特点是规则简单、限制较少、社区接受度高,非常适合学习项目与示例工程使用。

最后点击 Create Repository 按钮,完成你的仓库创建。

将项目关联到 GitHub 仓库

建立了仓库之后,我们要将本地项目与其创建关联,后续才能进行代码的提交和推送。

检测项目是否已是 Git 仓库

对于新项目来说,它一般不会是 Git 仓库,但你也可以手动检测一次。在项目根目录执行命令:

git status

输出结果应该是:

fatal: not a git repository (or any of the parent directories): .git

说明项目还不是一个 Git 仓库,这正是我们想要的。

可能有人会问,怎么在项目根目录执行命令?最简单的一种方法,右键你的项目根目录,然后选择:打开于 -> 终端,之后在终端输入命令即可。后续在项目根目录执行的命令,都可以按照此方法进行。如图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第20张图片初始化

接下来我们要进行初始化,在终端继续执行:

git init

控制台应该输出:

Initialized empty Git repository in D:/Mod Development/Tutorial/.git/ (你项目的实际路径)

然后你会发现根目录下的文件变成了红色,这表明项目已经成为了 Git 仓库,初始化完毕。

绑定到 GitHub 仓库

打开你新建的 GitHub 仓库,跳转到其首页(即 Code 页面),然后点击右上角的 Code,在展开的下拉框里找到 HTTPS,复制你的仓库地址。如图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第21张图片

然后在项目根目录执行:

git remote add origin (你刚刚复制的仓库地址)

这步执行没有任何输出,因此你需要检查一下状态。在终端继续执行:

git remote -v

然后你应该看到这样的输出:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第22张图片

这样就说明绑定成功了。

.gitignore

在项目根目录新建 .gitignore 文件,内容如下:

/run-data/
/run/
/gradle/
/.idea/
/build/
/.gradle/
/src/generated/resources/.cache/

这样的话这些目录的内容就不会被上传到仓库,从而保证只有 src 下的内容进入仓库,干净且规范。

提交并推送代码至 Git 仓库

在项目正确绑定仓库后,IDEA 左侧会出现一个“提交”按钮,单击它,然后选择所有“未进行版本管理的文件”,并编写一条提交消息(一般都会写 Initial Commit ),之后点击“提交并推送”。如下图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第23张图片

静待片刻,就会弹出推送成功的提示:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第24张图片

进入 GitHub 仓库,会发现代码已经成功被推送:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第25张图片

至此,开发环境初始化与项目版本管理配置已经全部完成。我们的项目已具备稳定的开发环境、规范的项目结构以及完整的版本控制基础。

从下一节开始,我们就正式进入 Mod 的开发阶段。久等了!

第一章:工程骨架构建

重构主类

重构主类结构的必要性

在完成开发环境与项目配置后,我们现在正式进入 Mod 开发的核心部分。

然而,在开始编写任何功能之前,第一件要做的事情并不是新增内容,而是清理并重构 IDEA 自动生成的主类(下图)。

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第26张图片

这是一个示例主类,其中包含大量演示性质的注册代码与事件监听示例。它的作用仅仅是向开发者展示 Forge 的基本功能用法,而不是作为实际项目的长期结构。也就是说,现在的主类是一个“功能展示模板”,而不是一个“工程入口”。

如果我们直接在这个类上继续开发,随着功能增加,很快就会出现以下问题——主类体积膨胀、注册逻辑与业务逻辑混杂、后期维护困难、不同系统之间耦合严重等。

因此,我们的目标就是将主类从“功能集合体”改造为“统一入口”

具体来说,我们将进行以下五个调整:

(1)移除模板自带的示例方块、物品与创造模式标签注册;

(2)删除演示用事件监听代码;

(3)清理仅用于示例输出的日志内容;

(4)将注册逻辑逐步拆分至独立的初始化类中;

(5)保留主类作为 Mod 的加载入口与注册调度中心。

清理干净之后,我们的主类就只负责两件事:获取 Mod Event Bus 和调用各模块的注册入口。

在开始清理之前,我们先来分析一下目前主类的实际结构,以方便我们对点清理。示例主类一共由六个部分拼接而成,接下来我会对它们进行系统性地阐述。

示例主类的结构分析

1. 基本入口信息

@Mod(Tutorial.MODID)
public class Tutorial {
    public static final String MODID = "tutorial";
    private static final Logger LOGGER = LogUtils.getLogger();
}

这部分是 Forge 识别 Mod 的入口:@Mod(MODID) 告诉 Forge 要加载哪个类,MODID 是命名空间根。LOGGER 是日志工具。入口必须保留。

2. 注册系统的演示实现

// 方块注册表
public static final DeferredRegister<Block> BLOCKS =
        DeferredRegister.create(ForgeRegistries.BLOCKS, MODID);

// 物品注册表
public static final DeferredRegister<Item> ITEMS =
        DeferredRegister.create(ForgeRegistries.ITEMS, MODID);

// 创造标签注册表
public static final DeferredRegister<CreativeModeTab> CREATIVE_MODE_TABS =
        DeferredRegister.create(Registries.CREATIVE_MODE_TAB, MODID);

这三组是注册器本体,负责把方块(BLOCKS)/物品(ITEMS)/创造标签(CREATIVE_MODE_TAB)登记到注册表。

注册机制本身会保留,但工程化项目不会把它们长期写在主类里。我们后续会把它们放进 init 类中,以后主类仅调用 XXX.register(modEventBus)。

3. 示例内容本体

// 示例方块
public static final RegistryObject<Block> EXAMPLE_BLOCK =
        BLOCKS.register("example_block",
                () -> new Block(BlockBehaviour.Properties.of().mapColor(MapColor.STONE)));

// 示例方块对应的 BlockItem(物品栏里的方块形态)
public static final RegistryObject<Item> EXAMPLE_BLOCK_ITEM =
        ITEMS.register("example_block",
                () -> new BlockItem(EXAMPLE_BLOCK.get(), new Item.Properties()));

// 示例物品
public static final RegistryObject<Item> EXAMPLE_ITEM =
        ITEMS.register("example_item",
                () -> new Item(new Item.Properties()
                        .food(new FoodProperties.Builder().alwaysEat().nutrition(1).saturationMod(2f).build())));

// 示例创造标签
public static final RegistryObject<CreativeModeTab> EXAMPLE_TAB =
        CREATIVE_MODE_TABS.register("example_tab", () ->
                CreativeModeTab.builder()
                        .withTabsBefore(CreativeModeTabs.COMBAT)
                        .icon(() -> EXAMPLE_ITEM.get().getDefaultInstance())
                        .displayItems((parameters, output) -> {
                            output.accept(EXAMPLE_ITEM.get());
                        }).build());

这些就是真正的演示方块/物品/食物/创造标签,这些内容也不适合留到主类里,后续我们会替换成实际的内容,并且放到各自模块里。

4. Mod 构造器里的“注册挂载”与生命周期监听

public Tutorial() {
    IEventBus modEventBus = FMLJavaModLoadingContext.get().getModEventBus();

    modEventBus.addListener(this::commonSetup);

    BLOCKS.register(modEventBus);
    ITEMS.register(modEventBus);
    CREATIVE_MODE_TABS.register(modEventBus);

    modEventBus.addListener(this::addCreative);

    MinecraftForge.EVENT_BUS.register(this);

    ModLoadingContext.get().registerConfig(ModConfig.Type.COMMON, Config.SPEC);
}

这一段展示了“把东西挂到总线上”。然而,工程化的做法是:主类只保留“挂载入口”,而不是在主类里直接维护三套注册器与示例内容。

最后一行 registerConfig 是 Config 示例,会随 Config.java 一起移除,后续章节再引入。

5. 事件订阅示例

MinecraftForge.EVENT_BUS.register(this);

@SubscribeEvent
public void onServerStarting(ServerStartingEvent event) {
    LOGGER.info("HELLO from server starting");
}

这是 Forge 的全局事件总线示例——注册监听器,然后用 @SubscribeEvent 接收事件。

该机制会应用,但主类不应长期当“事件监听器”,否则所有系统的事件都会挤进主类。

我们后续会专门做一个事件包,把不同功能的事件监听分到不同类里再统一注册。

6. 客户端专用事件示例

@Mod.EventBusSubscriber(modid = MODID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
public static class ClientModEvents {

    @SubscribeEvent
    public static void onClientSetup(FMLClientSetupEvent event) {
        LOGGER.info("HELLO FROM CLIENT SETUP");
        LOGGER.info("MINECRAFT NAME >> {}", Minecraft.getInstance().getUser().getName());
    }
}

这段展示了“只在客户端执行的初始化事件”。这种写法在真实项目里很常见,示例模板里把它直接塞在主类内部,只是为了演示方便。

工程化结构里通常会把客户端相关内容放在 client 包或 ClientSetup 类中,避免服务端/通用代码与客户端代码混在一个文件里。

重构主类结构

了解清楚主类究竟在“示例”什么之后,我们就要开始对主类进行重构。

我们的目标不是“删掉功能”,而是把模板里展示用的内容拆开,具体来说,就是以下四点:

(1)主类只做入口调度

(2)注册器与具体注册内容移到 init 模块(此处不展开,后续会涉及);

(3)事件监听与客户端初始化移到对应包;

(4)Config 示例整体移除,等后续需要时再以真实需求方式引入。

(也就是说,你现在可以先把 Config.java 文件完全移除,移除之后你会发现主类报了几个错,但别担心,我们会在接下来的重构中修复主类)

这样项目从第一天起就是可扩展、可维护的工程结构。

按照以上四个要点清理主类之后,最后主类应该像这样(导包省略):

@Mod(Tutorial.MODID)
public class Tutorial {
    public static final String MODID = "tutorial";
    private static final Logger LOGGER = LogUtils.getLogger();

    public Tutorial() {
        IEventBus modEventBus = FMLJavaModLoadingContext.get().getModEventBus();

        //region ModEventBus


        //end region

        modEventBus.addListener(this::commonSetup);
        MinecraftForge.EVENT_BUS.register(this);
    }

    private void commonSetup(final FMLCommonSetupEvent event) {}
    
}

经过重构,主类现在只保留真正属于“工程入口”的内容:

(1)模组身份(@Mod 与 MODID)

(2)生命周期入口(构造函数 + commonSetup)

(3)事件总线注册(ModEventBus 与 Forge Event Bus)

也就是说,现在的主类已经从“功能实现类”变成了一个启动与调度中心。它本身几乎不做事,只负责让别的系统开始工作。

另外,我展示的代码中的区块:

//region ModEventBus

//end region

它并不是 Forge 语法,而是一个工程化约定。这块区域专门预留给所有需要注册到 Mod Event Bus 的内容。你可以把它理解为主类里的一个“配电箱”,所有注册内容从这里接入 Forge,但真正的设备都在别的包里运行。

PS:上述的所有内容(即对示例主类的结构分析)是 Mod 开发的基础,一定要理解明白!

至此,我们就拥有了一副良好的工程骨架!

写在内容开发之前——编者的话

从下一章开始,我们将正式进入实际内容的开发阶段。

此前的章节主要完成了工程准备工作,包括环境配置、项目结构整理以及加载流程的梳理。之所以花费较大篇幅讲解这些看似抽象的内容,目的只有一个:为后续持续扩展建立稳定、可维护的开发基础。这正是工程化开发的核心所在。

接下来,本教程将不再以零散示例的方式分别介绍 Forge 的各项功能,而是通过逐步构建一个完整模组的过程来展开学习。后续出现的方块、物品与系统,不再是彼此独立的演示案例,而是同一工程中不断演进的组成部分。每一项新内容,都会建立在已有结构之上,并对既有代码进行扩展与整合。

因此,从这一阶段开始,我们关注的重点也将发生变化:
不再只是“如何从 0 实现某个单一功能”,而是如何在已有工程中引入新系统,并保持整体结构的清晰、稳定与可扩展。

本教程中的 Tutorial Mod,将被设计为一个围绕一台名为工业处理单元(Industrial Processing Unit)的机器展开的小型科技模组。我们将从最基础的方块与物品开始,实现工业处理单元的初始形态,并在后续章节中不断为其添加能力:逐步引入方块实体、自定义配方、菜单系统、数据同步,再到能力系统的构建、BlockEntityRenderer(BER)的使用等内容,最终形成一套完整且可扩展的工业处理系统。

换句话说,对工业处理单元不断“添砖加瓦”的过程,本身就是本教程的学习主线。随着设备功能的逐步扩展,我们也将依次理解 Forge 各个核心系统之间是如何协同工作的。

部分较为独立、难以自然融入该体系的内容(例如实体生成或世界生成)将被安排在教程后期作为扩展章节进行讲解。

从现在开始,我们的目标不再只是让代码“能够运行”,而是构建一个能够持续成长的 Mod 工程。

重要:项目源码会随着每章内容同步推送至 GitHub 仓库。在每一个小节末尾都会提供对应分支链接,方便读者对照学习与查看阶段成果。

第二章:注册系统与 init 模块

在前面的章节中,我们已经完成了项目环境与主类结构的整理,主类成为了我们真正的“工程入口”。而从这一节开始,我们将正式为模组建立“内容入口”。

在 Minecraft Forge 中,游戏内容并不是在运行时随意创建的对象,而必须在特定的加载阶段进行注册。只有被正确注册的内容,才会被游戏识别,并参与后续的资源加载、数据同步与世界运行流程。

这意味着,当模组规模逐渐扩大时,如果缺乏统一的注册入口,内容往往会分散在各个类中,不仅难以维护,也会给后续扩展带来很大困难。

因此,我们要建立一个统一、可扩展的注册体系。后续所有方块、物品以及相关内容,都将通过这一入口接入模组。

当然,在这部分内容里,肯定一直会有人疑惑:所谓的“注册”,到底是什么?什么东西需要注册,什么东西不需要注册?我会简要解释一下这个概念。(可选读)

对“注册”的解读

“注册”是什么?

在 Forge 模组开发中,注册(Registration)是一个几乎无处不在的概念,很多初学者会把注册理解为“创建一个对象”,但实际上两者并不相同。

注册看起来像是在创建一个方块、物品或其它内容,但在 Forge 的设计中,注册其实只是向游戏声明:模组中存在这样一种内容,请在启动时将它加入游戏的内容列表

也就是说,注册并不是“创建对象”,而是“提前登记”,让模组知道接下来需要添加什么内容。

为什么我们需要“注册”?

Minecraft 并不是一个在运行过程中随意添加内容的程序。在游戏启动阶段,它会完成一系列固定流程,如:

收集所有模组提供的内容 -> 为每一种内容分配唯一标识 -> 建立内部索引与映射关系 -> 加载资源文件与数据 -> 进入游戏

因此,游戏必须在开始运行之前就知道模组到底有什么内容。如果它不知道,那么就无法正确加载资源

所以,“注册”的本质就是把模组内容加入 Minecraft 的全局内容表中,让游戏能够认识并管理它们。

我们到底在“注册”什么?

凡是“游戏内容”的类型,通常都需要注册。而与之相对的,只在代码运行过程中使用的对象通常不需要注册。(见下表)

类型是否需要注册类型是否需要注册
方块工具类
物品算法逻辑
方块实体GUI 内部数据结构
配方类型临时变量
流体网络数据包实例
声音事件渲染计算过程
实体....
创造模式标签

附魔、药水效果


小结

在接下来的章节中,我们将频繁使用“注册”这一操作。务必牢记:

(1)注册不是创建对象;

(2)注册是向游戏声明内容的存在;

(3)只有注册过的内容,才能真正成为游戏的一部分。

理解了“注册”这一概念之后,我们就可以进一步学习 Forge 到底是如何帮助我们完成这一过程的了。

DeferredRegister

在上一节中我们提到,注册并不是简单地创建对象,而是向游戏声明“模组中存在这样一种内容”,并由 Minecraft 在启动阶段统一加载。

Forge 提供的 DeferredRegister,就是用来完成这一过程的标准工具。

为什么叫 DeferredRegister(延迟注册)?所谓延迟,是将注册行为交由 Forge 在正确的加载阶段执行。我们只需要提前声明需要注册的内容,Forge 会在合适的时机自动完成真正的注册。

使用 DeferredRegister 有两个直接好处:一是避免在错误的加载阶段注册内容;二是能让不同类型的内容被集中管理,保持工程结构清晰。

通常来说,每一种内容类型都会对应一个独立的注册器,例如方块使用一个注册器,物品使用另一个注册器。

接下来,我们将从最基础的部分开始,为本模组建立方块、物品与创造模式标签的注册入口。

init 包

在前面的步骤中,我们已经创建了模组主类,其位置类似于:

src/main/java/org/kdvcs(作者标识符)/tutorial(Mod ID)/Tutorial.java(主类名称)

接下来,请在与主类同级的位置新建一个名为 init 的包。这里的 init 是 initialization 的缩写,它用于集中放置模组的注册入口类,例如方块、物品以及创造模式标签的注册代码。如图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第27张图片

建立 ModBlocks

在 init 包里创建类 ModBlocks,内容如下:

public class ModBlocks {

    // 创建一个方块注册器。
    // 第一个参数指定注册类型(方块),
    // 第二个参数指定本模组的 MODID。
    public static final DeferredRegister<Block> BLOCKS =
            DeferredRegister.create(ForgeRegistries.BLOCKS, Tutorial.MODID);

    // 将本类中的注册器挂载到 Mod 事件总线。
    // 只有调用此方法后,方块才会在加载阶段被真正注册到游戏中。
    public static void register(IEventBus eventBus) {
        BLOCKS.register(eventBus);
    }

}

然后挂到主类里:

//region ModEventBus
ModBlocks.register(modEventBus);


//end region

以后完成了新的注册类,都要像这样挂到主类。

建立 ModItems

在 init 包下新建类 ModItems,内容如下:

public class ModItems {
    public static final DeferredRegister<Item> ITEMS =
            DeferredRegister.create(ForgeRegistries.ITEMS, Tutorial.MODID);

    public static void register(IEventBus eventBus) {
        ITEMS.register(eventBus);
    }
}

然后挂到主类。

建立 ModCreativeModeTabs

在 init 包下新建 ModCreativeModeTabs,内容如下:

public class ModCreativeModeTabs {
    public static final DeferredRegister<CreativeModeTab> CREATIVE_MODE_TABS =
            DeferredRegister.create(Registries.CREATIVE_MODE_TAB, Tutorial.MODID);

    // 注册本模组的创造模式标签。
    // "tutorial" 为该标签的注册名,会作为内部 ID 使用。
    public static final RegistryObject<CreativeModeTab> TUTORIAL =
            CREATIVE_MODE_TABS.register("tutorial",
                    () -> CreativeModeTab.builder()

                            // 设置创造标签在界面中显示的图标。
                            // 这里使用石头作为示例图标,后续可以替换为模组物品。
                            .icon(() -> new ItemStack(Items.STONE))

                            // 设置标签的显示名称。
                            // 使用可本地化文本(语言文件中定义)。
                            .title(Component.translatable("tab.tutorial"))

                            // 定义该标签中显示的物品内容。
                            // output.accept(...) 用于向标签中添加物品。
                            .displayItems((itemDisplayParameters, output) -> {

                            })

                            // 构建最终的 CreativeModeTab 实例。
                            .build());

    public static void register(IEventBus eventBus){
        CREATIVE_MODE_TABS.register(eventBus);
    }
}

然后挂到主类。

注册第一个物品

我们可以开始注册第一个物品啦!在本教程中,这个物品我会把它叫做粗材料(Raw Material),在以后,工业处理单元将能够处理它。

在 ModItems 类里,注册 Raw Material 物品,如下:

// 注册本教程中的第一个示例物品:raw_material。
// "raw_material" 为物品的注册名(Registry Name),
// 同时也会作为资源文件与模型文件的命名基础。
public static final RegistryObject<Item> RAW_MATERIAL =
        ITEMS.register("raw_material",
                // 创建一个基础物品实例。
                // 当前不添加任何特殊属性,仅用于演示注册流程,
                // 后续章节中将作为工业处理单元的加工原料使用。
                () -> new Item(new Item.Properties()));

注册第一个方块

在 ModBlocks 类里,注册一个简单的粗材料块(Raw Material Block),如下:

// 注册一个示例方块:raw_material_block。
// "raw_material_block" 为方块的注册名,
// 同时用于资源文件(模型、贴图等)的命名基础。
public static final RegistryObject<Block> RAW_MATERIAL_BLOCK =
        BLOCKS.register("raw_material_block",
                // 创建方块实例。
                // 这里通过 copy(Blocks.IRON_BLOCK) 复制铁块的基础属性,
                // 使该方块拥有类似的硬度、抗爆性等行为,
                // 作为当前阶段的简单示例方块使用。
                () -> new Block(BlockBehaviour.Properties.copy(Blocks.IRON_BLOCK)));

将方块和物品加入创造模式标签栏

在 ModCreativeModeTabs 里,添加我们刚刚写的方块和物品:

.displayItems((itemDisplayParameters, output) -> {
    output.accept(ModItems.RAW_MATERIAL.get());    // 粗材料
    output.accept(ModBlocks.RAW_MATERIAL_BLOCK.get());    // 粗材料块
})

第三章:第一个内容——最小方块与物品的实现

其实完成了注册之后,你就已经可以在游戏里看到刚刚添加的方块、物品和创造标签栏了,但它们现在都没有自己的贴图,没有自己的模型。接下来,我们做的就是“实装”。

Block 与 BlockItem

在 Minecraft 中,一个可放置的方块实际上由两个不同对象组成:

Block —— 表示世界中的方块本体;

BlockItem —— 表示玩家背包中的物品形式。

Block 负责定义方块在世界中的行为与属性,而玩家手中持有的并不是方块本身,而是对应的物品(BlockItem)。当玩家右键放置方块时,本质上是 BlockItem 在世界中创建了对应的 Block。

因此,一个能够被玩家获取并放置的方块,通常需要同时注册 Block 和与之对应的 BlockItem。

如果只注册了 Block,而没有注册 BlockItem,它就会在玩家背包里呈现出贴图丢失的状态,持有时的贴图大小也十分诡异。

在理解这一点后,我们接下来将关注方块在游戏中的实际表现方式。

注册 BlockItem

我们之前注册了 Raw Material Block,它是一个实实际际的 Block,接下来我们为它注册一个 BlockItem。在 ModItems 里,添加:

// 为 raw_material_block 注册对应的物品形式(BlockItem)。
// 该注册名与方块注册名保持一致,
public static final RegistryObject<Item> RAW_MATERIAL_BLOCK =
        ITEMS.register("raw_material_block",
                // 创建 BlockItem,将其与已注册的方块绑定。
                // ModBlocks.RAW_MATERIAL_BLOCK.get() 获取对应的方块实例,
                // 这样玩家在背包中持有该物品时,才能放置出对应的方块。
                () -> new BlockItem(
                        ModBlocks.RAW_MATERIAL_BLOCK.get(),
                        new Item.Properties()));

基础资源目录结构

在前面的步骤中,我们完成了代码层面的注册工作。但 Minecraft 是一个高度数据驱动的游戏,很多展示的内容(如贴图等)并不写在 Java 代码中,而是通过资源文件进行控制。

所有资源文件都位于:

src/main/resources/

而对于一个真正的模组,其资源文件位于:

src/main/resources/assets/modid(Mod ID)/

其中的 modid 必须与你在主类中定义的 MODID 保持一致
这一级目录就是模组的资源命名空间,所有贴图、模型、语言文件等都会放在这里。

接下来我会介绍在 assets/modid 下常见的子目录:

目录名称作用
blockstates用于定义方块在不同状态下使用哪个模型
models/block用于定义方块的模型结构
models/item用于定义物品的模型结构
textures存放实际贴图文件。也可以继续分类,如:textures/block,textures/item 等
lang存放语言文件。如 en_us.json,zh_cn.json

我们可以根据这个结构,提前建立好需要的文件夹:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第28张图片

准备 textures

我们需要准备一张物品(Item)贴图(raw_material.png),和一张方块(Block)贴图(raw_material_block.png)。

贴图的命名是有讲究的,必须和你物品/方块的注册名一致,才能被游戏读取,否则就是紫黑块。

注册名是哪个?举个例子:

ITEMS.register("raw_material", ...)

这里的字符串 raw_material 就是注册名。所以你的贴图名字也得和它保持一致才行。

我这里准备好了 raw_material.png 和 raw_material_block.png。我们把物品的贴图放在 textures/item,方块的贴图放在 textures/block,这样分类清晰,防止混乱。

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第29张图片

编写 model

有了贴图之后,接下来就是编写 model。model 的命名也必须是注册名。

物品模型

普通物品模型

在 models/item 下创建 raw_material.json:

{
  "parent": "minecraft:item/generated",
  "textures": {
    "layer0": "tutorial:item/raw_material"
  }
}

这里我们逐项解释一下:

"parent": "minecraft:item/generated"

parent 表示当前模型继承自哪个基础模型

minecraft:item/generated 是 Minecraft 提供的默认物品模型模板,适用于大多数普通物品。它会自动将贴图渲染为一个二维平面,并在背包与手持时显示为标准物品形态。

"textures": {
  "layer0": "tutorial:item/raw_material"
}

这里定义了该模型所使用的贴图。

layer0 表示第一层贴图。对于普通物品来说,一般只需要这一层。

tutorial:item/raw_material 表示贴图路径,对应的实际文件位置为:

assets/tutorial(Mod ID)/textures/item/raw_material.png

需要注意的是,这里不需要写 .png 后缀

BlockItem 模型

继续创建 raw_material_block.json,它为 BlockItem 提供模型。

与普通物品不同,对于 BlockItem,我们的模型只需要写:

{
  "parent": "tutorial:block/raw_material_block"
}

意思就是,背包里显示的方块图标,直接用方块的 Block Model。也就是说,它指向的实际路径是:

assets/tutorial/models/block/raw_material_block.json

这样在物品栏里,你才会看到一个 3D 的微缩方块模型。

方块模型

在 models/block 下创建 raw_material_block.json:

{
  "parent": "minecraft:block/cube_all",
  "textures": {
    "all": "tutorial:block/raw_material_block"
  }
}

逐项解释:

"parent": "minecraft:block/cube_all"

cube_all 是 Minecraft 提供的一个基础方块模型模板。它是一个标准的立方体,并且六个面使用同一张贴图。(最简单)

"textures": {
  "all": "tutorial:block/raw_material_block"
}

这里的 "all" 表示六个面统一使用同一张贴图。该贴图实际路径为:

assets/tutorial/textures/block/raw_material_block.png

我们目前先做最简单的六面相同方块,以后我们会讲解到更加复杂的模型状态。

编写 blockstate

blockstate 用于指定方块使用的模型(model)。

在 blockstates 目录下新建 raw_material_block.json:

{
  "variants": {
    "": {
      "model": "tutorial:block/raw_material_block"
    }
  }
}

这里的 model 指向的是:

assets/tutorial/models/block/raw_material_block.json

这样的话我们就完成了资源创建的工作。

提示

很多人经常容易把 blockstate,model,texture 三者的关系搞混。其实它们的指向关系非常简单:

blockstate 指定需要的模型 model,而 model 里定义再模型所使用的 texture。

PS:关于模型、贴图的这部分内容对于初学者而言可能比较难以理解(尤其是方块部分),建议反复实践。

本地化

我们做好了方块和物品的注册、贴图,但它们目前还没有自己的名字,在游戏里显示的会是一串注册键名。接下来我们需要为它们做本地化。

在 lang 目录下,找到 en_us.json,写:

{
  "item.tutorial.raw_material": "Raw Material",
  "block.tutorial.raw_material_block": "Raw Material Block",
  "tab.tutorial": "Tutorial"
}

对于物品和方块,标准键格式是:

item/block.<modid>.注册名

而创造模式标签栏的键名,是我们在注册的时候规定的,把构建实例时的字符串照抄过来做翻译就可以了。

对应地,做 zh_cn.json 的本地化。

PS:你也可以不做多语言本地化,但如果你希望国际化强一些,建议同时做中英双语本地化。

游戏内测试

双击 runClient 进入游戏,新开一个存档,测试我们的成果:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第30张图片

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第31张图片

做到这步都没问题的话,恭喜你!真正开始着手了 Mod 的开发。

Lesson-01 GitHub 源码

本章的 GitHub 源码(包含资源文件):Lesson-01

第四章:代码重构——简化重复注册逻辑

目前我们的示例模组内容还很少,注册代码看起来也并不复杂。但随着后续章节不断加入新的物品与方块,类似的注册写法将会频繁出现。

在工程实践中,提前建立清晰、统一的注册结构,比在内容堆积之后再进行整理更加稳妥。

因此,在本节中,我们将对现有注册代码进行一次轻量级封装,将常见的注册逻辑提取为辅助方法。

Block 注册方法

在 ModBlocks 类里,新增通用注册方法 registerBlock:

// 通用方块注册方法。
// name 为注册名,block 为方块的创建方法(Supplier)。
// 使用泛型 <T extends Block>,使该方法可以注册任意 Block 子类。
private static <T extends Block> RegistryObject<T> registerBlock(String name, Supplier<T> block){

    // 向方块注册器中声明该方块
    RegistryObject<T> toReturn = BLOCKS.register(name, block);

    // 同时为该方块自动注册对应的 BlockItem,
    // 使方块能够出现在背包中并被玩家放置。
    registerBlockItem(name, toReturn);    // 我们会在稍后补全 registerBlockItem 方法,目前会正常报错

    // 返回注册结果,方便在其他地方引用该方块
    return toReturn;
}

新增 registerBlockItem 方法:

// 为已注册的方块创建并注册对应的 BlockItem。
// name 为注册名(应与方块注册名保持一致),
// block 为方块的 RegistryObject,用于获取方块实例。
private static <T extends Block> RegistryObject<Item> registerBlockItem(String name, RegistryObject<T> block){

    // 在物品注册器中注册一个 BlockItem。
    // block.get() 获取已经声明的方块实例,
    // 这样玩家在背包中持有该物品时,才能放置出对应的方块。
    return ModItems.ITEMS.register(
            name,
            () -> new BlockItem(block.get(), new Item.Properties())
    );
}

然后就可以修改刚刚的 Block 注册为:

public static final RegistryObject<Block> RAW_MATERIAL_BLOCK =
        registerBlock("raw_material_block",
                () -> new Block(BlockBehaviour.Properties.copy(Blocks.IRON_BLOCK)));

这样的好处是,以后我们就不用再手动注册 BlockItem 了。

最后记得删除在 ModItems 里注册的 BlockItem,避免重复注册!

Item 注册方法

新增这三个重载方法:

// 注册一个最基础的物品。
// 仅需要提供注册名,使用默认 Item.Properties。
// 适用于没有特殊属性的简单物品。
private static RegistryObject<Item> registerItem(String name) {
    return ITEMS.register(name, () -> new Item(new Item.Properties()));
}


// 注册一个可自定义物品实例的通用方法。
// factory 接收 Item.Properties 并返回一个 Item,
// 用于创建自定义 Item 子类或具有特殊构造逻辑的物品。
private static RegistryObject<Item> registerItem(String name, Function<Item.Properties, Item> factory) {
    return ITEMS.register(name, () -> factory.apply(new Item.Properties()));
}


// 注册一个仅修改属性的简单物品。
// propertiesModifier 用于修改 Item.Properties(如堆叠数、食物属性等),
// 但仍使用基础 Item 类型,不需要创建新的 Item 子类。
private static RegistryObject<Item> registerSimplePropItem(
        String name,
        Consumer<Item.Properties> propertiesModifier) {

    return ITEMS.register(name, () -> {
        Item.Properties props = new Item.Properties();

        // 对默认属性进行修改
        propertiesModifier.accept(props);

        // 使用修改后的属性创建物品
        return new Item(props);
    });
}

后两个重载方法目前还没有用上,但是以后会用上。这样的话,我们就可以修改刚刚的 raw_material 注册为:

public static final RegistryObject<Item> RAW_MATERIAL = registerItem("raw_material");

十分简洁,只用提供注册名即可。

Lesson-02 GitHub 源码

本章的 GitHub 源码:Lesson-02

第五章:方块方向与多面贴图——以工业处理单元方块的创建为例

这一章我们会实现工业处理单元的“外壳”—— 即承载它的方块。在本章,我们先把它做成一个没有实际行为的外壳方块,但与普通方块不同的是,它有正面,且正面会随放置方向旋转。

此外,这个方块的六个面都会使用不同的贴图,这样看上去立体感更强,也不单调。它是我们未来机器的基础,在后续章节中,我们会在这块外壳上逐步叠加方块实体、GUI、配方与能力系统等内容。

因此,这一章我们只完成工业处理单元方块的外观与朝向,不涉及任何机器逻辑。

多面贴图方块的方块模型

在前边的课程中,我们知道了,方块在世界中的外观是由模型文件决定的,而之前我们写的方块非常简单,是六面纹理均相同的方块,所以我们用了 cube_all 模板。
然而,如果你希望机器每个面长得不同,那么模型就不能再用 cube_all 这种“六面同贴图”的模板,而需要为方块的每个面(north/east/south/west/up/down)分别指定纹理。

在这里,我使用了 Blockbench 制作工业处理单元的方块模型。

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第32张图片

为什么选择用 Blockbench?原因很简单,在我们要制作多面的方块贴图时,最容易写错的是“哪张图对应哪一面”。而使用 Blockbench,我们能直观看到每个面的贴图效果(尤其是 Blockbench 为你标识出来了 north 方向的位置),这样可以大大减小我们的试错成本。不过我们的教程并不是 Blockbench 使用教程,因此关于 Blockbench 怎么用,还麻烦大家自行上网搜索~

好了,说了半天,让我们正式开始:

首先准备好你需要用的六面贴图材质,然后我建议把它们放在:

assets/modid/textures/block/machine

为什么不直接放在 block 目录下呢?因为一般来说,block 目录是存放一些普通方块用的。对于机器这类复杂方块,在其下单独建一个子目录存放其贴图更好,防止以后贴图多了就什么也找不到。(下图是我准备好的贴图)

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第33张图片

仔细看图会发现,从贴图命名上,我做了一个刻意的区分:除了正面贴图之外,其它面的贴图都统一使用 machine_ 前缀。这样做的好处是,当你后续制作多台风格一致的机器时,外壳、顶面、底面、侧面这类通用部件可以直接复用同一套贴图,而不需要为每台机器重复绘制一遍。而相比之下,正面贴图更像机器的身份标志,它通常与机器功能相关。因此我才为工业处理单元单独命名为 industrial_processing_unit_idle

准备好了贴图,我们就可以写方块模型了。我这里使用的是 Blockbench,所以我只需要导出模型即可。模型的格式类似于:

{
    "format_version": "1.21.11",
    "credit": "Made with Blockbench",
    "textures": {
       "0": "tutorial:block/machine/industrial_processing_unit_idle",
       "1": "tutorial:block/machine/machine_back",
       "3": "tutorial:block/machine/machine_left",
       "4": "tutorial:block/machine/machine_right",
       "6": "tutorial:block/machine/machine_top",
       "7": "tutorial:block/machine/machine_bottom",
       "particle": "tutorial:block/machine/industrial_processing_unit_idle"
    },
    "elements": [
       {
          "from": [0, 0, 0],
          "to": [16, 16, 16],
          "faces": {
             "north": {"uv": [0, 0, 16, 16], "texture": "#0"},
             "east": {"uv": [0, 0, 16, 16], "texture": "#3"},
             "south": {"uv": [0, 0, 16, 16], "texture": "#1"},
             "west": {"uv": [0, 0, 16, 16], "texture": "#4"},
             "up": {"uv": [0, 0, 16, 16], "texture": "#6"},
             "down": {"uv": [0, 0, 16, 16], "texture": "#7"}
          }
       }
    ]
}

把这个模型文件输出在:

assets/modid/models/block/

这个模型文件本身并不难理解,你只需要把 textures 和 elements 对照着看就明白哪面贴图对应什么材质了,所以在这里我就不展开细说了。

有人可能会疑惑:在模型文件的 textures 段,那堆数字编号“0”、“1”、“3”... 到底是什么?它们其实是贴图变量值,一个变量就对应一面贴图,在 elements 段里它们会被统一调用。

不过,你得把贴图变量后边的贴图路径改成你的实际贴图路径才能生效。举个例子:

"0": "tutorial:block/machine/industrial_processing_unit_idle"

贴图变量 0 对应的贴图位于:

assets/tutorial/textures/block/machine/industrial_processing_unit_idle.png

这步做完之后,你的方块模型就完成了!

PS:即使你没有使用 Blockbench,实际模型文件的组织方式也和此基本相同。所以如果你想手搓,那照葫芦画瓢就可以。但是用 Blockbench 可以帮你省去手搓模型 json 的时间,何乐而不为呀!

多面贴图方块的物品模型

有了方块模型,那自然还得有对应的物品模型,不然在物品栏里就紫黑块了!

对于这种非六面相同贴图的方块,我们不能像之前一样在模型里直接写:

{
  "parent": "modid:block/..."
}

因为它的父级模型已经不是一个简单模型了,而是一个有多贴图的复杂模型,直接这样写一句话,游戏根本不知道你说的父级模型到底是什么形态的。

所以,我们得为方块的物品模型也同样指定多面贴图才行。在:

assets/modid/models/item/

创建 industrial_processing_unit.json,内容如下:

{
  "parent": "minecraft:block/cube",
  "textures": {
    "north": "tutorial:block/machine/industrial_processing_unit_idle",
    "south": "tutorial:block/machine/machine_back",
    "east":  "tutorial:block/machine/machine_left",
    "west":  "tutorial:block/machine/machine_right",
    "up":    "tutorial:block/machine/machine_top",
    "down":  "tutorial:block/machine/machine_bottom",
    "particle": "tutorial:block/machine/industrial_processing_unit_idle"
  }
}

这样就为每面都指定了单独的贴图,和方块模型里的贴图对应好就行,防止面错乱。

底部有一个额外的参数“particle”,它的作用是指定这个方块应用的粒子贴图。

做到这步,可能有人就会发出疑问了:为什么我们之前做简单六面方块的时候,在 BlockItem 模型里我们指定的父级模型是一个干净的 model,没有任何 textures 出现,而为什么在这里我们却写了一堆 textures 呢?

其实,我们依然是在指定父级模型为一个方块模型,只是这个方块比较特殊,叫做“minecraft:block/cube”(普通六面体方块),textures 字段在做的事实际上可以理解为是在给这个六面体上色,它给每一面上不同的色。这样上完色后,我们在物品栏里才会看到一个正常的 3D 微缩方块模型。而简单的六面方块(贴图全相同),之所以可以直接指定父级模型为我们的自定义方块模型,是因为其没有多余的贴图属性,且父级模型也很简单,因此它可以直接被渲染出正确的 3D 微缩形态。

创建工业处理单元的方块类

现在,我们的工业处理单元有了贴图(虽然还没写 blockstate),有了物品栏形态,但它现在放置在世界中时,还不知道自己其实是具有方向属性的。这显然不是我们能在注册方块时就能做的,为此,我们得新建一个方块类来做这些事情。

创建一个与 init 包同级的包,命名为 block,表明这是用于存放方块的包,然后再创建一个子包 machine。接下来新建 IndustrialProcessingUnitBlock 类。如图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第34张图片有人会问,为什么还要在 block 包下再建一个子包?这是多此一举吗?显然不是,我们实际上依然在实践一种工程规范。未来,我们的工业处理单元方块是有对应的方块实体的,尽管它现在很普通,但它的职责也要与普通方块区分开来。因此我们才要把它放到 block.machine 里,表明它的身份是工业处理单元方块实体的方块载体

接下来,我们来完成这个类(框架实现,不要复制粘贴,一定要理解清楚之后重新自己做一遍):

类实现

// 工业处理单元方块。
// 继承 HorizontalDirectionalBlock,使方块天然支持水平四方向(N/S/E/W)朝向。
public class IndustrialProcessingUnitBlock extends HorizontalDirectionalBlock {

    // 方块的朝向属性(水平四方向)。
    // 直接复用 Minecraft 已有的 FACING 定义,而不是重新创建一个属性。
    public static final DirectionProperty FACING = HorizontalDirectionalBlock.FACING;

    public IndustrialProcessingUnitBlock() {
        // 定义方块基础属性(硬度、声音等后续可在这里扩展)
        super(Properties.of());

        // 注册默认方块状态。
        // 当方块尚未被放置或没有额外信息时,默认朝向 NORTH。
        this.registerDefaultState(
                this.stateDefinition.any()
                        .setValue(FACING, Direction.NORTH)
        );
    }

    // 玩家放置方块时调用,用于确定最终的方块状态。
    @Nullable
    @Override
    public BlockState getStateForPlacement(BlockPlaceContext pContext) {

        // 获取玩家当前面朝方向,并取反方向。
        // 这样机器的“正面”会朝向玩家,
        // 实现“放下去就看到正面”的直觉效果。
        return this.defaultBlockState()
                .setValue(FACING, pContext.getHorizontalDirection().getOpposite());
    }

    // 向方块状态系统注册我们新增的属性。
    // 如果不在这里添加 FACING,游戏就无法获取到方块状态,进而在启动阶段崩溃。
    @Override
    protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> pBuilder) {
        pBuilder.add(FACING);
    }
}

接下来我会讲解一些相关的要点。

HorizontalDirectionalBlock

你会发现,我们的工业处理单元方块继承自 HorizontalDirectionalBlock,你可以把它理解成 Minecraft 给你准备好的水平朝向方块模板只要继承它,你的方块就天然拥有一个标准的水平朝向属性,并且自动支持旋转与镜像。

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package net.minecraft.world.level.block;

import net.minecraft.core.Direction;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.DirectionProperty;

public abstract class HorizontalDirectionalBlock extends Block {
    public static final DirectionProperty FACING;

    protected HorizontalDirectionalBlock(BlockBehaviour.Properties pProperties) {
        super(pProperties);
    }

    public BlockState rotate(BlockState pState, Rotation pRot) {
        return (BlockState)pState.setValue(FACING, pRot.rotate((Direction)pState.getValue(FACING)));
    }

    public BlockState mirror(BlockState pState, Mirror pMirror) {
        return pState.rotate(pMirror.getRotation((Direction)pState.getValue(FACING)));
    }

    static {
        FACING = BlockStateProperties.HORIZONTAL_FACING;
    }
}

通过阅读代码,我们可以发现 HorizontalDirectionalBlock 的特点:

首先,它自带一个 FACING 属性,但在源码里,我们看不出来这个 FACING 具体是什么。

因为源码只写了一句:

FACING = BlockStateProperties.HORIZONTAL_FACING;

FACING 到底在哪定义?仔细观察你会发现,HORIZONTAL_FACING 本质上是一个 DirectionProperty,而 DirectionProperty 是基于 Direction 枚举创建的:

protected DirectionProperty(String pName, Collection<Direction> pValues) {
    super(pName, Direction.class, pValues);
}

因此,打开 Direction.class 你会发现,Minecraft 一共有六个方向:

DOWN, UP,
NORTH, SOUTH,
WEST, EAST

同时,Direction 里对“水平”的定义是 X/Z 轴方向,也就是:

NORTH, SOUTH, WEST, EAST

因此,HORIZONTAL_FACING 实际上是一个经过筛选的 DirectionProperty,只允许取这四个值,而排除了 UP 和 DOWN。

用一句话来说,HorizontalDirectionalBlock 只关心水平四向旋转,而不关心 up 和 down 的情况。这对于机器类方块、工作台类方块、面朝玩家的装置来说已经非常合理,也足够使用了。除非你要做一些像什么插在天花板上的奇怪机器,那你就不能用这个了

其次,它最省事的地方来了 —— 这个类已经替你实现了 rotate 和 mirror 的行为。这意味着当方块旋转、镜像变换时,你的方块朝向会自动正确变换,而不需要额外写根据 Rotation 改变 FACING 的逻辑。

重新声明 FACING 的必要性

你会发现我写了:

public static final DirectionProperty FACING = HorizontalDirectionalBlock.FACING;

通过看 HorizontalDirectionalBlock 的源码,你会发现,父类已经有 FACING 了,那为什么还要写一遍?

首先给结论,继承来的静态字段确实能用,但如果我们后续在别的地方引用到它,那可读性会非常差。

做这一步并不是在重复定义,而是把父类能力显式地绑定到当前方块语义上

重要方法解读:方块状态的三个关键方法

registerDefaultState —— 定义方块的初始状态

this.registerDefaultState(
    this.stateDefinition.any().setValue(FACING, Direction.NORTH)
);

registerDefaultState 为这个方块在“注册阶段”确定了一份完整、合法的初始状态。

因为 Minecraft 并不会等玩家放置方块时才去想它有哪些属性、它的朝向是什么,它必须在游戏加载、注册表构建、世界数据读写之前,搞清楚该方块的默认状态。

因此,BlockState 不是运行时临时拼出来的对象,而是注册时就确定结构的状态模板。stateDefinition.any() 会生成一个包含全部已注册属性的基础状态,而 setValue(FACING, Direction.NORTH) 则为这些属性提供一个默认值。没有默认状态,方块的状态空间就是不完整的;引擎就无法构造实例。

getStateForPlacement —— 生成放置时的实际状态

@Nullable
    @Override
    public BlockState getStateForPlacement(BlockPlaceContext pContext) {
        return this.defaultBlockState()
                .setValue(FACING, pContext.getHorizontalDirection().getOpposite());
    }

getStateForPlacement 在玩家放置方块时调用,其作用是基于默认状态生成世界中的最终 BlockState。通过 defaultBlockState().setValue(...) 修改属性,例如根据玩家朝向决定方块方向。

PS:你会发现,我写了个 .getOpposite() 。为什么要取反方向呢?原因很简单,你可以想一想,机器的正面要对着玩家的正面,所以机器正面和玩家正面实际上是相反的。所以取反方向之后,才能使方块正面朝向玩家。

createBlockStateDefinition —— 注册可用状态属性

@Override
    protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> pBuilder) {
        pBuilder.add(FACING);
    }

createBlockStateDefinition 用于向引擎注册该方块拥有的状态属性,例如 FACING。只有在这里通过 pBuilder.add(...) 声明过的属性,才能被 BlockState 系统存储与修改。如果省略这一步,即使代码中调用 setValue(FACING, ...),游戏也会因为属性未注册而在运行时崩溃。本方法本质上是在定义方块的“状态字段列表”,是 BlockState 系统的声明阶段。

编写 blockstate

在 blockstates 目录下创建 industrial_processing_unit.json,内容如下:

{
  "variants": {
    "facing=north": { "model": "tutorial:block/industrial_processing_unit"},
    "facing=south": { "model": "tutorial:block/industrial_processing_unit", "y": 180},
    "facing=west": { "model": "tutorial:block/industrial_processing_unit", "y": 270 },
    "facing=east": { "model": "tutorial:block/industrial_processing_unit", "y": 90 }
  }
}

这才是 blockstate json 的完整形态,我们来简单讲解一下:

首先,这个 blockstate 根据方块 facing 的不同取值,来决定模型是否需要在 Y 轴上旋转。

variants 表示“当状态满足某个条件时,模型该如何渲染”。在本例中,就是:

* facing=north:使用模型原始朝向(等同于 y=0)

* facing=east:在 Y 轴旋转 90°

* facing=south:旋转 180°

* facing=west:旋转 270°

这里的 "y" 表示模型绕竖直轴旋转的角度。它只可以接受 22.5 的倍数,例如 0、22.5、45 等(如果你写 30 之类的,会被视为非法数值从而导致方块直接紫黑)。不过在大多数水平朝向方块中,我们一般都只使用 90 的倍数,因为 HorizontalDirectionalBlock 只提供四个方向状态。

PS:这些旋转的角度值都是靠测试得来的,如果你的方块模型和我不太一样,那你就自己调整一下旋转的数值,直到正面总是朝向玩家即可。

然后这里我们补充一个点:为什么方块方向旋转不在代码里做,反而是在 blockstate json 里做?

先给结论:Minecraft 的方块外观不是由代码实时渲染出来的,而是由“状态驱动的模型系统”自动决定的。

也就是说,当你在代码中给方块添加 FACING 属性时,你实际上只是告诉游戏:“这个方块有一个叫 facing 的状态变量,它可能是 north、south、east、west。” 但代码只负责产生状态,并不负责“怎么画出来”。画出来的逻辑,完全是交给资源系统处理的。

举个例子,如果你对有方向属性的方块(如熔炉)打开 F3,把鼠标光标置于之上,你会发现在右侧有这么两条信息(黄框部分):

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第35张图片

信息显示 minecraft:furnace[facing=north],这既不是渲染逻辑,也不是模型,而是一个实实在在的状态(blockstate)。在游戏渲染方块时,会读取这个 blockstate,然后去查找对应的 blockstate json。

所以,blockstate json 会根据状态匹配规则,决定方块应该使用哪个模型、是否旋转模型、旋转多少角度。之后,游戏才自动把模型按照这些规则渲染出来,我们也才在世界里看见方块。

因此,我们把方向旋转在 blockstate json 里做,是因为渲染系统需要知道“当 facing 是不同值时该显示什么”。

用一句话总结 —— 代码决定“方块是什么状态”,blockstate 决定“这个状态长什么样”,游戏决定“把这个状态的样子正确地画出来”。

易混淆点:方块朝向与模型朝向的区别

很多新手开发者在这里可能会产生一个典型疑问:既然我们之前说 HorizontalDirectionalBlock 会随旋转自动变更朝向,那为什么我们还要在 blockstate json 里用 "y": 90 这种角度值去手动旋转方块的朝向?

其实,这两者属于完全不同的层级。FACING 表示的是方块在世界中的逻辑朝向,它是 blockstate 的一部分,会被世界保存,用于决定方块的行为、交互方向以及在结构旋转或镜像时如何变化;而 blockstate json 中的 "y" 旋转只是渲染阶段的模型变换,用于让模型外观与当前状态对齐,本身不会改变方块状态

换句话说,FACING 决定“方块实际上朝哪”,而 blockstate json 里规定的旋转角度决定“模型应该怎么画才能看起来朝那边”。因此,即使我们删除模型旋转规则,方块依然拥有正确的 facing,只是外观不再匹配。

这也是 Minecraft 数据驱动设计的核心:代码负责状态,JSON 负责表现,渲染系统负责把两者对应起来。

注册工业处理单元方块

做完这些之后,就去注册我们的工业处理单元方块,注册的地方稍微有点不同,我们应该这样写:

public static final RegistryObject<Block> INDUSTRIAL_PROCESSING_UNIT =
        registerBlock("industrial_processing_unit", IndustrialProcessingUnitBlock::new);

这是因为我们的方块使用的是无参构造函数。在方块类里,我们写了:

public IndustrialProcessingUnitBlock() {
    super(Properties.of());
}

方块的基础属性(Properties)已经在构造器内部确定好了,因此注册时不需要再从外部传入参数,只需要告诉注册系统:“需要这个方块时,直接 new 一个出来即可”。

IndustrialProcessingUnitBlock::new 本质上就是一个构造器引用,等价于:

() -> new IndustrialProcessingUnitBlock()

从工程角度看,使用无参构造意味着将方块的属性定义与行为逻辑统一封装在方块类内部,使方块本身成为一个“自我完整”的对象,而注册代码只负责提供名称并完成登记。后续也方便我们维护和扩展。

然后就是做本地化、加入创造标签栏,这些我就不演示了。

游戏内测试

runClient,看看我们刚刚的成果:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第36张图片这样就大功告成喽~

Lesson-03 GitHub 源码

本章的 GitHub 源码(包含资源文件):Lesson-03

第六章:有生命的方块——方块实体

在前面的章节中,我们已经创建了一个能够旋转、能够正确显示模型的工业处理单元方块。但到目前为止,它仍然只是一个“外壳”——它没有记忆,不会运行,也无法保存任何属于自己的数据。无论世界如何变化,它本身始终是静态的。

本章的目标,就是让这个方块第一次拥有数据。

我们将把工业处理单元升级为一个最小可运行的 BlockEntity,让它能够存储变量、随时间变化,并在世界保存与加载时保持状态。

在本章不会引入 GUI、菜单或复杂交互,我们只专注于理解 BlockEntity 的本质。了解它是如何附着在方块上,又如何参与游戏循环,以及它为什么让方块“活”了起来。

因此,本章的重点不是做功能,而是理解一个事实:
从这一刻开始,我们的方块不再只是一个模型,而是一个会“存在”和“运行”的“有生命”的对象。

方块实体简述

什么是方块实体?用 Forge 文档里的话来说,就是:

BlockEntity 可以理解为一种“简化版的实体(Entity)”,但它是绑定在某个方块上的。它们用于存储动态数据、执行基于 tick 的逻辑任务,以及进行动态渲染。
在原版 Minecraft 中的典型例子包括:箱子用于处理物品栏数据,熔炉用于执行熔炼逻辑,信标用于产生范围效果等。

总的来说,Block 负责定义“这个方块是什么”,而 BlockEntity 负责决定“这个方块现在在做什么、记住了什么”。

当一个方块需要拥有持续变化的数据或行为时,它就必须拥有自己的 BlockEntity。这也是所有机器类方块的基础所在。

注册 BlockEntityType

与方块一样,BlockEntity 也需要注册。但我们注册的不是实例,而是BlockEntityType

在 init 包下创建 ModBlockEntities 类:

public class ModBlockEntities {

    public static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITIES =
            DeferredRegister.create(ForgeRegistries.BLOCK_ENTITY_TYPES, Tutorial.MODID);

    public static void register(IEventBus eventBus) {
        BLOCK_ENTITIES.register(eventBus);
    }
}

然后注册工业处理单元的方块实体类型:

/**
 * 工业处理单元的 BlockEntityType 注册。
 *
 * 这里做了三件事:
 *  1. 指定构造函数(如何创建 BlockEntity)
 *  2. 指定可绑定的方块(哪个 Block 使用它)
 *  3. 构建并注册到游戏
 */
public static final RegistryObject<BlockEntityType<IndustrialProcessingUnitBlockEntity>>
        INDUSTRIAL_PROCESSING_UNIT_BE =
        BLOCK_ENTITIES.register("industrial_processing_unit_be", () ->

                // Builder.of(构造器引用, 绑定的方块...)
                BlockEntityType.Builder.of(
                        IndustrialProcessingUnitBlockEntity::new, // 如何创建 BE
                        ModBlocks.INDUSTRIAL_PROCESSING_UNIT.get() // 哪些方块可以拥有它
                ).build(null)
        );

创建工业处理单元方块实体骨架

现在我们来创建真正的方块实体类。新建一个 blockentity 包,然后创建 IndustrialProcessingUnitBlockEntity:

public class IndustrialProcessingUnitBlockEntity extends BlockEntity {

    public IndustrialProcessingUnitBlockEntity(BlockPos pPos, BlockState pBlockState) {
        super(ModBlockEntities.INDUSTRIAL_PROCESSING_UNIT_BE.get(), pPos, pBlockState);
    }
    
}

此时它还什么都不会做,但已经可以被世界创建。

将方块实体绑定到方块

方块实体之所以为方块实体,意思就是这个“实体”必须由方块提供。所以我们得把方块实体绑定到方块上。

在 IndustrialProcessingUnitBlock 类中,新增:

@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos blockPos, BlockState blockState) {
    return new IndustrialProcessingUnitBlockEntity(blockPos, blockState);
}

为了使用这个方法,我们得让 Block 类实现 EntityBlock,因此我们需要这样修改:

public class IndustrialProcessingUnitBlock extends HorizontalDirectionalBlock implements EntityBlock { ... }

做完这步之后,每当方块被我们放置时,游戏都会在方块的位置创建一个 BlockEntity

引入 progress 变量

现在我们在方块实体类里引入一个变量:

private int progress = 0;

这里的 progress 不是机器逻辑,它只是我教学用的计数器。我们将用它验证三件事:

(1)BlockEntity 能存储此数据;

(2)此数据会变化;

(3)此数据能被保存、读取。

以后它可以被能量值、实际配方进度等替代。

数据持久化

BlockEntity 的核心能力之一,是数据持久化。

saveAdditional —— 将数据写入存档

@Override
protected void saveAdditional(CompoundTag pTag) {
    super.saveAdditional(pTag);
    pTag.putInt("Progress", progress);    // 键名 "Progress",在 load 里也必须与此保持一致
}

saveAdditional 是方块实体的“写入存档”入口。当世界保存、区块卸载时,引擎会调用它,让你把需要持久化的数据写进 NBT(CompoundTag)。这样我们的 progress 数值才会在保存游戏时也得到保存,否则就会丢失。

load —— 从存档读取数据

@Override
public void load(CompoundTag pTag) {
    super.load(pTag);
    progress = pTag.getInt("Progress");    // 键名必须与 saveAdditional 里的键名相同
}

load 是与 saveAdditional 配套的“从存档读取”入口。当区块加载、方块实体被重建时,引擎会把之前保存的 NBT 交给你,你需要在这里把数据读回成员变量。

这里的核心原则是:saveAdditional 写什么键,load 就读同样的键,否则数据就对不上,表现出来就是“进度不保存 / 重进世界归零”。

做好这步,我们的 progress 数值才会在加载游戏时也得到正确加载,否则 saveAdditional 就没有作用。

需要 saveAdditional & load 的情况

我们刚刚使用了 saveAdditional 和 load 方法来存储/读取我们的 progress 数值。那么肯定有人会疑惑:所有数据都需要通过这两个方法来存储/读取吗?有没有什么例外?什么时候应该用这两个方法,什么时候没必要呢?

先给结论:并不是所有 BlockEntity 中的变量都需要写入 saveAdditional 与 load。

需不需要用两个方法存储/读取数值的判断标准只有一个:这个数据是否应该在世界重新加载后依然存在。

如果某个值代表的是世界状态的一部分,例如机器进度、能量储量、物品栏内容或绑定信息,那么它必须被保存到 NBT,否则玩家重新进入世界时这些状态就会丢失。而对于运行过程中的临时数据、缓存结果或可以随时重新计算出来的变量,则不需要保存。也就是说,BlockEntity 只负责保存“应该被世界记住的结果”,而不保存“为了计算这些结果而产生的过程”。

这里给一些具体的例子:

类型是否需要写入 NBT类型是否需要写入 NBT
机器当前进度(如熔炉熔炼进度)临时缓存变量
机器当前能量值计算得到的中间值
机器内部内容物每 tick 重新计算的结果
冷却时间客户端显示用的临时字段
多方块结构绑定坐标日志统计计数

赋予方块实体生命——tick 方法

现在我们让方块实体开始“运行”,运行方法很简单,就是每 tick 都增加 progress 数值。

public void tick() {
    progress++;
    setChanged();
}

PS:setChanged() 表示数据已修改,需要保存。只要方块实体内部的数据发生了变化,就必须调用 setChanged(),否则游戏不知道你需要保存哪些数据。

然后我们要在 Block 类里接入 ticker:

@Nullable
@Override
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level pLevel, BlockState pState, BlockEntityType<T> pBlockEntityType) {
    return pBlockEntityType == ModBlockEntities.INDUSTRIAL_PROCESSING_UNIT_BE.get()
            ? (lvl, p, st, be) -> ((IndustrialProcessingUnitBlockEntity) be).tick()
            : null;
}

之所以要在 Block 里接入 ticker,而不是让 BlockEntity 自己“自动 tick”,是因为 Minecraft 的设计里,世界每个位置存的是 blockstate,游戏循环只认识“这个方块现在是什么类型”,并不会无差别地让所有 BlockEntity 都运行。

具体来说,游戏在每个 tick 里会先看当前位置的方块,然后问这个方块一句话:这个位置如果有 BlockEntity,需要每 tick 更新吗?如果需要,请给我一个 BlockEntityTicker,我就按你提供的方式去调用;如果不需要,那我就当它是静态的,不再做额外工作。

所以从工程角度来说,把 ticker 放在 Block 里,就只有确实需要更新的方块才参与 tick,避免无意义遍历,从而减少性能开销。其实说半天,最直接的原因还是因为 BlockEntity 不会自己执行 tick

无 GUI 验证——观察方块实体的行为

做好方块实体的逻辑后,我们就要观察它是否真的在做我们想要的事情。

在本章我不引入 GUI 等可视化层,所以我们会用最简单的方法观察方块实体内部的数据变化情况。

在 BlockEntity 类中增加一个方法:

public Component getDebugMessages() {
    return Component.literal("Progress: " + progress);
}

然后在 Block 类里增加 use 方法(不展示整个方法,可尝试自己完善,做不出来再去参考 GitHub 源码),右键时调用我们的 getDebugMessages():

player.sendSystemMessage(machine.getDebugMessages());

游戏内测试

runClient,放下我们的工业处理单元方块,然后对着它右键,聊天栏应该要弹出信息:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第37张图片说明 progress 确实在随 tick 发生变化,也就是我们的方块实体“在 tick”。

PS:如果要验证 progress 的数值是否真的能得到保存/能被正确读取,可以先让方块实体运行一段时间,然后重新进入存档。只要 progress 没有归零,就说明保存/读取正常。

Lesson-04 GitHub 源码

本章的 GitHub 源码(带详细注释):Lesson-04

第七章:让方块开口说话——第一个 Menu 与 Screen

在上一章中,我们已经让工业处理单元拥有了自己的方块实体。现在,它能够存储数据、执行 tick,并在世界中持续存在。但到目前为止,我们与它的交互方式仍然非常原始,即通过右键在聊天栏里输出调试信息。

而这一章,我们要迈出下一步,我们要让这个“活着的方块”第一次拥有自己的界面。

不过本章的目标非常简单,我们不会立刻实现物品栏、配方输入、按钮点击或复杂渲染,而是只完成以下链路:

玩家右键方块 -> 服务端打开 Menu -> 客户端显示 Screen -> 结束

因此,你可以把这一章理解为我们在搭建 GUI 系统的脚手架。只要这条链路跑通,后续无论是进度条、槽位、按钮,还是完整的机器交互逻辑,都只需要往这个骨架上继续添东西即可。

Menu 和 Screen 的职责

在正式写代码前,我们要先说明清楚 Menu 与 Screen 的职责。

通常情况下,当我们打开一个方块实体的界面(如熔炉),屏幕上会立刻出现一个可以操作的 GUI。你既能看到它的外观,也能直接与它交互,比如放入物品、查看进度、触发配方。乍看之下,这似乎像是一个整体在工作,但实际上,在 Forge 中,这套“可见且可交互”的界面机制是由两个部分共同完成的——即 Menu 和 Screen,它们的职责如下:

Menu(AbstractContainerMenu):它运行在逻辑层,负责服务端与客户端之间的数据同步,以及界面与 BlockEntity 的绑定关系。

Screen(AbstractContainerScreen):它运行在客户端,负责把界面绘制到屏幕上。

也就是说,我们可以简单理解为,Menu 决定界面的逻辑(放入物品、存储数据等),而 Screen 决定界面的外观(渲染加工进度条、界面标题等)。

虽然在本章中,我们的 Menu 不会有很复杂的交互逻辑,但我们也必须先搭好它。因为客户端 Screen 必须依附于一个 Menu 才能存在。

注册 MenuType

和 Block、BlockEntityType 一样,MenuType 也需要先注册。

在 init 包下创建 ModMenuTypes:

public class ModMenuTypes {
    public static final DeferredRegister<MenuType<?>> MENUS =
            DeferredRegister.create(ForgeRegistries.MENU_TYPES, Tutorial.MODID);

    //region 注册区
    
    //endregion

    private static <T extends AbstractContainerMenu> RegistryObject<MenuType<T>> registerMenuType(String name, IContainerFactory<T> factory) {
        return MENUS.register(name, () -> IForgeMenuType.create(factory));
    }

    public static void register(IEventBus eventBus) {
        MENUS.register(eventBus);
    }
}

然后注册工业处理单元的菜单类型:

public static final RegistryObject<MenuType<IndustrialProcessingUnitMenu>>
        INDUSTRIAL_PROCESSING_UNIT_MENU =
        registerMenuType("industrial_processing_unit_menu",
                IndustrialProcessingUnitMenu::new);

这里完成了一个简单但重要的工作,即告诉游戏存在一种新的菜单类型。

以后当玩家打开工业处理单元界面时,游戏就会创建这个 Menu。

使 BE 实现 MenuProvider

接下来我们需要让工业处理单元方块实体实现 MenuProvider,这样 BlockEntity 就可以负责提供界面:

public class IndustrialProcessingUnitBlockEntity extends BlockEntity implements MenuProvider {...}

之后实现其需要的两个方法:

// 返回界面标题,决定 GUI 显示的标题名称
@Override
public Component getDisplayName() {
    return Component.literal("be.title.industrial_processing_unit");
}

// 当玩家打开界面时创建 Menu
@Nullable
@Override
public AbstractContainerMenu createMenu(int id, Inventory inventory, Player player) {
    return new IndustrialProcessingUnitMenu(id, inventory, this, data);    // 目前还没有 Menu 类,报错是正常情况,后续会补全
}

ContainerData 数据同步

ContainerData 简介

在 Menu 系统中,如果需要把 BlockEntity 中的数据同步到界面,最基础的方式就是使用 ContainerData。它本质上是一个非常简单的数据接口,用来在服务端 Menu 与客户端 Menu 之间同步整数数据

如果你翻阅 ContainerData 的源码,就可以看出,它只定义了三个方法:

int get(int index);
void set(int index, int value);
int getCount();

这三个方法其实描述了一件很简单的事情——把一组整数当作一个“数组”进行同步

get(int index) —— 用来读取指定位置的数据。当 Menu 需要同步数据到客户端时,就会调用这个方法获取当前值。

set(int index, int value) —— 用来写入数据。当客户端收到同步数据时,会通过这个方法把值写回 Menu。

getCount() —— 告诉系统这个数据容器需要同步的整数数量。

因此我们才说,ContainerData 并不是一个真正的数据结构,而是一个简单的数据接口

它本身并不保存数据,而是定义了一种方式,让 Menu 能够通过 get 和 set 访问实际存储在 BlockEntity 里的变量。

将变量接入 ContainerData

了解完 ContainerData 的作用,我们接下来就要把需要的变量接入其中。

在上一章中,我们在 BE 里有一个教学用的变量 progress,现在我们将它接入 ContainerData:

protected final ContainerData data = new ContainerData() {

    @Override
    public int get(int index) {
        return switch (index) {
            case 0 -> progress;
            default -> 0;
        };
    }

    @Override
    public void set(int index, int value) {
        if (index == 0) progress = value;
    }

    @Override
    public int getCount() {
        return 1;
    }
};

由于在工业处理单元里,我们只需要同步一个变量 progress,因此 getCount() 返回 1,而 index == 0 时就对应 progress。这样以后如果需要同步多个值(例如最大进度、能量储量等),只需要继续增加新的 index 即可。

需要使用 ContainerData 的情况

有人可能会问:我如何知道哪些数据需要做同步?哪些数据不需要呢?还是说所有数据都需要进行同步?

一句话结论:在实际开发中,并不是所有数据都需要通过 ContainerData 同步。我们的判断标准很简单——如果某个值需要在 GUI 中被客户端看到,并且会随时间变化,那么它就需要同步。

举例来说,像机器进度、燃料剩余时间、能量储量、加工时间等,这些都是典型的需要同步到界面的数值。

相反,如果某些数据只在服务端逻辑中使用,或者客户端完全不需要知道它们的具体数值,那么就不需要通过 ContainerData 同步。例如配方计算中的临时变量、缓存结果、内部状态标记等,这些都只属于机器运行过程,不需要出现在界面上。

也就是说,ContainerData 应该用来同步界面需要看到的数据,而不是机器内部所有的数据。

最小 Menu 实现

创建 IndustrialProcessingUnitMenu

创建 container.menu 包,之后在其下创建 IndustrialProcessingUnitMenu 类:

public class IndustrialProcessingUnitMenu extends AbstractContainerMenu {

    /**
     * 当前菜单绑定的方块实体。
     * Menu 本身并不存储机器逻辑,它只是作为界面逻辑层,
     * 因此需要持有 BlockEntity 的引用来访问真实数据。
     */
    public final IndustrialProcessingUnitBlockEntity blockEntity;

    /**
     * 当前菜单所在的世界。
     * 主要用于 stillValid 检查玩家是否仍然可以访问该方块。
     */
    private final Level level;

    /**
     * 用于同步简单整数数据的容器。
     * 这里主要用于同步 BlockEntity 中的 progress 等数值。
     */
    private final ContainerData data;


    /**
     * 客户端构造器。
     *
     * 当服务端要求客户端打开界面时,
     * Forge 会通过网络发送一个 FriendlyByteBuf,
     * 其中包含方块的位置等信息。
     *
     * 客户端通过读取这个位置,
     * 再从世界中获取对应的 BlockEntity。
     */
    public IndustrialProcessingUnitMenu(int id, Inventory inv, FriendlyByteBuf buf) {

        // 从网络数据中读取方块位置,并找到对应的 BlockEntity
        this(id, inv,
                inv.player.level().getBlockEntity(buf.readBlockPos()),
                new SimpleContainerData(1));
    }


    /**
     * 服务端构造器。
     *
     * 当玩家真正打开界面时,服务端会创建 Menu,
     * 并把 BlockEntity 与 ContainerData 传入。
     */
    public IndustrialProcessingUnitMenu(int id, Inventory inv, BlockEntity entity, ContainerData data) {

        // 指定该菜单对应的 MenuType
        super(ModMenuTypes.INDUSTRIAL_PROCESSING_UNIT_MENU.get(), id);

        // 保存方块实体引用
        this.blockEntity = (IndustrialProcessingUnitBlockEntity) entity;

        // 保存世界引用
        this.level = inv.player.level();

        // 保存数据同步容器
        this.data = data;

        // 注册数据同步槽
        // 这样 ContainerData 中的数据就会在服务端和客户端之间同步
        addDataSlots(data);
    }


    /**
     * Shift 点击快速移动物品的逻辑。
     *
     * 由于当前菜单还没有任何物品槽位,
     * 因此这里暂时返回 null。
     * 在后续实现物品槽时,这里会被完善。
     * 因为没有槽位,所以目前返回 null 是安全的
     *
     */
    @Override
    public ItemStack quickMoveStack(Player player, int i) {
        return null;
    }


    /**
     * 检查玩家是否仍然可以使用该界面。
     *
     * 如果玩家距离方块太远,或者方块已经被破坏,
     * 菜单就会自动关闭。
     */
    @Override
    public boolean stillValid(Player player) {

        return stillValid(
                ContainerLevelAccess.create(level, blockEntity.getBlockPos()),
                player,
                ModBlocks.INDUSTRIAL_PROCESSING_UNIT.get()
        );
    }


    /**
     * 提供对 BlockEntity 的访问。
     * Screen 或其他逻辑可以通过 Menu 获取对应的机器实例。目前暂时没有使用此方法。
     */
    public IndustrialProcessingUnitBlockEntity getBlockEntity() {
        return this.blockEntity;
    }
}

这样就实现了一个最小 Menu,接下来我会对一些重点方法进行讲解。

Menu 类重点方法讲解

addDataSlots —— 将 ContainerData 接入同步系统

addDataSlots 的作用是将 ContainerData 中的每一个整数值,逐个包装成 DataSlot,并注册到当前 Menu 里。其源码如下:

protected void addDataSlots(ContainerData pArray) {
    for(int i = 0; i < pArray.getCount(); ++i) {
        this.addDataSlot(DataSlot.forContainer(pArray, i));
    }

}

从源码中可以看到,它会遍历 pArray.getCount(),然后对每一个索引调用 DataSlot.forContainer(pArray, i) 再加入菜单。

虽然我们已经在 BlockEntity 中写好了 ContainerData,但如果不在 Menu 中调用 addDataSlots(data),这些数据依然不会参与菜单同步。也就是说,ContainerData 只是定义了有哪些数据可以读写,而 addDataSlots 才是真正告诉 Menu 需要与界面一起同步的数据。如果没有这一步,客户端 Menu 拿到的仍然只是默认值,界面就看不到真实数据。

stillValid —— 检测界面是否能够继续开启

stillValid 的作用是在界面打开后持续检查玩家是否仍然有资格使用它。其源码如下:

protected static boolean stillValid(ContainerLevelAccess pAccess, Player pPlayer, Block pTargetBlock) {
    return (Boolean)pAccess.evaluate((p_38916_, p_38917_) -> {
        double reachOld = 64.0;
        double reach = pPlayer.getAttributeValue((Attribute)ForgeMod.BLOCK_REACH.get()) + 3.5;
        return !p_38916_.getBlockState(p_38917_).is(pTargetBlock) ? false : pPlayer.distanceToSqr((double)p_38917_.getX() + 0.5, (double)p_38917_.getY() + 0.5, (double)p_38917_.getZ() + 0.5) <= reach * reach;
    }, true);
}

通过源码可以看到,它做了两件事:第一,确认当前位置的方块仍然是目标方块;第二,确认玩家与这个方块之间的距离没有超出允许范围。

只有这两个条件都满足,Menu 才会继续保持打开状态;否则界面就会自动关闭。这样设计的原因非常直观:如果方块已经被挖掉,或者玩家已经离开很远,那么这个界面就不应该继续悬空存在。

FriendlyByteBuf

在构造器里,你会看到这么一行代码:

IndustrialProcessingUnitMenu(int id, Inventory inv, FriendlyByteBuf buf)

里边的 FriendlyByteBuf 是做什么的?直接给结论:在这种情况下,FriendlyByteBuf 的作用就是把方块位置从服务端传给客户端。这是因为客户端在打开界面时,并不知道自己应该绑定哪一个方块实体,所以必须由服务端通过网络把坐标发送过来,客户端再根据这个坐标找到对应的 BlockEntity。

关于 FriendlyByteBuf 的源码有 1500+ 行,我们不需要在这里详细阅读它,只需要知道它的作用即可。

最小 Screen 实现

先准备好我们的 GUI 贴图(256x256)。在这里我推荐大家制作一个有液体槽的 GUI,后续讲到流体能力的时候就可以直接使用。如下图是我制作的工业处理单元的 GUI:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第38张图片

PS:个人习惯是把 Screen 的绘制元素全部塞到统一的一张图里,比如说我的 GUI 贴图就包含了加工进度条和液体刻度槽。你也可以把这些额外渲染元素分开到其他地方。但本章暂时不涉及这些元素的渲染。

然后我推荐把 GUI 的贴图放在 textures/container 下,以后有新的贴图就往里边塞即可。

创建 container.screen 包,然后在其下创建 IndustrialProcessingUnitScreen 类:

public class IndustrialProcessingUnitScreen extends AbstractContainerScreen<IndustrialProcessingUnitMenu> {

    /**
     * GUI 背景贴图的位置。
     * ResourceLocation 的格式为:modid:path
     * 这里对应的实际文件路径是:
     * assets/tutorial/textures/container/industrial_processing_unit.png
     */
    private static final ResourceLocation GUI =
            new ResourceLocation(Tutorial.MODID, "textures/container/industrial_processing_unit.png");
    
    /**
     * Screen 构造器。
     *
     * menu:当前界面绑定的 Menu(逻辑层)
     * playerInventory:玩家物品栏
     * title:界面标题
     *
     * Screen 只负责渲染界面,并不直接处理机器逻辑,
     * 真正的数据来源仍然是 Menu → BlockEntity。
     */
    public IndustrialProcessingUnitScreen(IndustrialProcessingUnitMenu menu,
                                          Inventory playerInventory,
                                          Component title) {
        super(menu, playerInventory, title);

        // GUI 的宽度与高度(像素)
        // 这些值通常需要与背景贴图尺寸保持一致
        this.imageWidth = 176;
        this.imageHeight = 174;
    }
    
    /**
     * 渲染 GUI 背景。
     *
     * 该方法负责绘制界面的底层贴图。
     * 在这里我们只绘制一张固定的 GUI 背景图。
     */
    @Override
    protected void renderBg(GuiGraphics guiGraphics, float partialTick, int mouseX, int mouseY) {

        // 设置渲染使用的 Shader
        RenderSystem.setShader(GameRenderer::getPositionTexShader);

        // 设置颜色(RGBA),1 表示不改变原贴图颜色
        RenderSystem.setShaderColor(1F, 1F, 1F, 1F);

        // 绑定要绘制的纹理
        RenderSystem.setShaderTexture(0, GUI);

        // 计算 GUI 左上角的位置,使界面居中显示
        int x = (width - imageWidth) / 2;
        int y = (height - imageHeight) / 2;

        // 绘制贴图
        // 参数含义:
        // GUI:纹理
        // x,y:屏幕上的绘制位置
        // 0,0:纹理起始坐标
        // imageWidth,imageHeight:绘制区域大小
        guiGraphics.blit(GUI, x, y, 0, 0, imageWidth, imageHeight);
    }
    
    /**
     * 整个界面的渲染入口。
     *
     * 渲染顺序通常是:
     * 1. 绘制背景
     * 2. 绘制 GUI
     * 3. 绘制按钮、槽位等组件
     */
    @Override
    public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {

        // 绘制界面背景(灰色遮罩)
        renderBackground(graphics);

        // 调用父类渲染 GUI 元素
        super.render(graphics, mouseX, mouseY, partialTick);
    }
}

这里没有过多的重点方法需要讲解,基本上看注释就能明白了,因为 Screen 负责的事情相当简单——也就是画画。

创建 client 包并注册 Screen

在上一节中我们已经创建了 IndustrialProcessingUnitScreen。接下来还需要告诉游戏:当某个 MenuType 被打开时,客户端应该显示哪个 Screen。

在工程化开发中,我们这一步通常会放在一个专门的客户端初始化类中。我们先在项目中创建一个新的包,位置类似于:

org.kdvcs.tutorial.client

然后在这个包里创建 ClientSetup 类(连带 Screen 注册):

@Mod.EventBusSubscriber(modid = Tutorial.MODID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
public class ClientSetup {

    @SubscribeEvent
    public static void onClientSetup(FMLClientSetupEvent event) {
        registerScreens();
    }

    private static void registerScreens() {
        MenuScreens.register(
                ModMenuTypes.INDUSTRIAL_PROCESSING_UNIT_MENU.get(),
                IndustrialProcessingUnitScreen::new
        );
    }

}

这里有一行关键代码:

MenuScreens.register(MenuType, Screen 构造器)

它建立了这样的一条关系:MenuType  →  Screen。也就是说,当服务端打开 INDUSTRIAL_PROCESSING_UNIT_MENU 时,客户端就会创建 IndustrialProcessingUnitScreen 来显示界面。

client 包的必要性

你可能会问:为什么不把这些代码直接写在主类里?

结论非常简单,因为 Screen 只属于客户端

Minecraft 的很多代码(如 Block、BlockEntity、Menu)会同时在客户端和服务器运行,但 Screen 是纯客户端内容,它只负责界面的绘制。如果服务器尝试加载这些客户端类,就会因为找不到 net.minecraft.client.* 包而直接崩溃

因此 Forge 提供了 Dist.CLIENT 机制:

value = Dist.CLIENT

它表示这个类只在客户端环境加载。这样在服务器启动时,这个类会被完全忽略。

修改方块 use 方法

之前我们右键方块(即 use 时),聊天栏会调出调试信息,现在我们有界面了,于是我们希望在右键方块时就打开这个界面。修改 Block 类的 use 方法为:

@Override
public InteractionResult use(BlockState state, Level level,
                             BlockPos pos, Player player,
                             InteractionHand hand, BlockHitResult hit) {

    if (!level.isClientSide()) {
        BlockEntity entity = level.getBlockEntity(pos);
        if (entity instanceof IndustrialProcessingUnitBlockEntity juicer) {
            NetworkHooks.openScreen((ServerPlayer) player, juicer, pos);
        } else {
            throw new IllegalStateException("Missing Container!");
        }
    }
    return InteractionResult.sidedSuccess(level.isClientSide());

}

在这里,我们使用 NetworkHooks.openScreen() 打开界面。它的作用就是由服务端发起一次“打开菜单”的网络流程,并把必要的额外数据(例如方块位置)发给客户端。

关于 NetworkHooks 的源代码,我们不在这里做详细解析。

游戏内测试

runClient,进入存档,右键我们的工业处理单元方块,它应该要能够弹出一个界面。如下图:

[持续更新 1.20.1 Forge]从0搭建可维护的Mod开发工程(工程化Forge开发指南)-第39张图片这样的话就成功了,我们成功打开了一个 GUI!虽然现在的界面还空空如也,什么也干不了。标题也是歪的,但我们后边会继续完善 Screen 类,因此不用担心。但我们迈出了第一步,不是吗?

Lesson-05 GitHub 源码

本章的 GitHub 源码(含资源文件、代码详细注释):Lesson-05


工作状态与模型变体

working 属性设计

状态组合

多模型切换

运行状态可视化


处理逻辑

工作流程设计

progress

服务端逻辑控制


自定义配方类型

RecipeType

RecipeSerializer

JSON 配方结构

加工匹配逻辑


网络同步

网络同步简介

S2C 同步流程

GUI 数据同步

常见错误解读


Forge Capability 能力系统

Capability 综述

ItemHandler

LazyOptional 生命周期

I/O 系统


流体系统

流体类型

流体块与流体桶

流体能力

GUI 流体展示


Block Entity Renderer (BER) 方块实体渲染器

BER 的应用场景

BER 工作原理

渲染基础流程

动态渲染元素

注意事项


自定义音效

SoundEvent

添加自定义音效

应用自定义音效


Worldgen 世界生成

注册新矿物

Placed Feature

Biome Modifier

世界结构生成

战利品表


成就系统

成就 JSON

触发条件


进阶篇 —— DataGen 数据生成器

DataGen 综述