Mission · 先看最终效果
这不是“贴一张魔法棒图片”,而是让一次玩家操作真正改变游戏世界。
想象一下:你走到草地上,手里拿着一根橙红色的小魔杖,瞄准脚边的方块轻点左键。代码先像门卫一样确认“拿的是不是魔法棒”,再检查玩家是否有修改这里的权限。只有全部通过,服务器才把目标方块替换成火焰,并让附近玩家听见火焰弹的声音。这个小效果同时串起了 Fabric 模组里最重要的四条线:注册表、玩家事件、客户端与服务器、资源文件。
认识一件物品
注册 maik:magic_wand,让游戏、命令、物品栏和资源系统使用同一个唯一身份。
听见玩家动作
使用 AttackBlockCallback 监听“攻击方块”,理解为什么本课是左键,而不是右键使用物品。
安全修改世界
先判断旁观模式和修改权限,再只在服务器端放置火焰、同步方块并广播声音。
让它看起来像魔法棒
用翻译 JSON、手持模型 JSON 和透明 PNG,把一段注册代码变成玩家看得见、拿得起的道具。
./gradlew.bat build 显示 BUILD SUCCESSFUL;开发客户端里能用 /give @s maik:magic_wand 获得“魔法棒”;手持它左键可修改的方块时,方块变成火焰并播放声音;普通物品仍保持原来的攻击或挖掘逻辑。Version truth · 以模板为准
先读工程的版本声明,再决定装哪个 JDK;不要凭“1.20 应该是……”来猜。
本教程逐行核对了 C:\TeacherMa\Mod\maik-template-1.20。它的目标游戏是 Minecraft 1.20,使用 Yarn 1.20+build.1、Fabric Loader 0.18.4、Fabric API 0.83.0+1.20 和 Loom 1.14.10。更值得注意的是,模板在 build.gradle 中明确设置 release = 21,并在 fabric.mod.json 中要求 java >= 21,所以本课使用 JDK 21。
| 组件 | 模板中的值 | 在哪个文件确认 | 作用 |
|---|---|---|---|
| Minecraft | 1.20 | gradle.properties | 决定游戏类、资源格式和可用 API |
| Yarn mappings | 1.20+build.1 | gradle.properties | 给混淆后的 Minecraft 类和方法提供可读名称 |
| Fabric Loader | 0.18.4 | gradle.properties | 发现并加载模组入口 |
| Fabric API | 0.83.0+1.20 | gradle.properties | 提供物品组和玩家交互事件等常用 API |
| Fabric Loom | 1.14.10 | gradle.properties | 配置开发环境、映射、运行和重映射 JAR |
| Gradle Wrapper | 9.2.1 | gradle/wrapper/gradle-wrapper.properties | 确保每台电脑使用同一 Gradle 版本 |
| JDK | 21 | build.gradle 与 fabric.mod.json | 编译和运行本模板 |
打开 IntelliJ 后,在 Project Structure 把 Project SDK 设为 JDK 21;在 Settings → Build Tools → Gradle 中,把 Gradle JVM 也指向同一套 JDK 21,并保持 Distribution 使用 Wrapper。然后在工程根目录执行:
java -version
javac -version
./gradlew.bat build本模板已实际执行干净构建,10 个 Gradle 任务全部完成,最终显示 BUILD SUCCESSFUL。先让原工程成功,再开始改代码,是最省时间的调试方法。
Flow · 一眼看懂施法链
左键之后并不会立刻放火,代码要经过三道“关卡”。
流程图里的每一个出口都有意义。普通物品走 PASS,等于告诉 Fabric:“这不是我的业务,请继续执行 Minecraft 原本的逻辑。”旁观者或没有修改权限的玩家走 FAIL,表示这次动作被明确拒绝。只有手持魔法棒、权限允许并且来到服务器端时,才真正改变世界。这样的分层能防止魔法棒抢走所有物品的左键行为,也能避免客户端制造只存在一瞬间的“假火焰”。
File map · 先认路再写代码
这个项目很小,但 Java、显示资源和游戏数据各有自己的房间。
src/main/java 放运行逻辑;assets/maik 放名称、模型和贴图;data/maik 放合成配方。这里的 maik 是 Mod ID,也是资源命名空间。只要路径里不小心写成 magic、Maik 或别的名字,代码仍可能编译,但游戏会找不到对应资源。
maik-template-1.20/
├─ build.gradle
├─ gradle.properties
├─ gradlew.bat
└─ src/main/
├─ java/com/malaoshi/maik/
│ ├─ Maik.java
│ ├─ item/
│ │ └─ ModItems.java
│ └─ event/
│ └─ MagicWandEvents.java
└─ resources/
├─ fabric.mod.json
├─ assets/maik/
│ ├─ lang/
│ │ ├─ zh_cn.json
│ │ └─ en_us.json
│ ├─ models/item/magic_wand.json
│ └─ textures/item/magic_wand.png
└─ data/maik/recipes/magic_wand.jsonJava 决定行为
Maik 启动注册,ModItems 创造物品,MagicWandEvents 处理玩家左键。
assets 决定“看起来怎样”
名称、手持显示方式和像素图都在这里。紫黑错误纹理通常就是这一层断开。
data 决定玩法数据
配方不需要写进 Java。JSON 正确后,数据包系统会自动读取。
命名空间负责连接
maik:magic_wand 像物品的完整地址,所有文件最终都要回到这个地址。
Entrypoint · 总开关
Maik.java 不负责施法,它负责把两个系统接上电源。
fabric.mod.json 把 com.malaoshi.maik.Maik 声明为主入口。Fabric Loader 创建模组时会调用 onInitialize()。这里先注册物品和物品栏,再注册攻击方块事件。把入口类想成剧场的舞台监督:它不亲自扮演魔法师,但要确认演员、灯光和音效都已经到位。
package com.malaoshi.maik;
import com.malaoshi.maik.event.MagicWandEvents;
import com.malaoshi.maik.item.ModItems;
import net.fabricmc.api.ModInitializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Maik implements ModInitializer {
public static final String MOD_ID = "maik";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
@Override
public void onInitialize() {
ModItems.register();
MagicWandEvents.register();
LOGGER.info("Maik mod initialized");
}
}MOD_ID 只定义一次,其他类通过 Maik.MOD_ID 使用它,可以减少手写字符串造成的拼写分叉。日志也使用同一个 ID;将来读 run/logs/latest.log 时,很容易看出哪一行来自自己的模组。
ModItems.MAGIC_WAND。先完成物品注册,再安装事件监听,阅读上更符合依赖顺序。真正的事件只会在玩家进入世界并攻击方块时触发,不会在初始化阶段直接放火。Registry · 给魔法棒发“身份证”
ModItems.java 做两件事:注册物品,并把它放进创造模式分类。
package com.malaoshi.maik.item;
import com.malaoshi.maik.Maik;
import net.fabricmc.fabric.api.itemgroup.v1.ItemGroupEvents;
import net.minecraft.item.Item;
import net.minecraft.item.ItemGroups;
import net.minecraft.registry.Registries;
import net.minecraft.registry.Registry;
import net.minecraft.util.Identifier;
public final class ModItems {
public static final Item MAGIC_WAND = Registry.register(
Registries.ITEM,
new Identifier(Maik.MOD_ID, "magic_wand"),
new Item(new Item.Settings().maxCount(1))
);
private ModItems() {
}
public static void register() {
ItemGroupEvents.modifyEntriesEvent(ItemGroups.TOOLS)
.register(entries -> entries.add(MAGIC_WAND));
}
}选择物品注册表
Registries.ITEM 表示我们要登记的是物品。方块、实体、声音都有各自的注册表,不能混用。
组合唯一 ID
new Identifier("maik", "magic_wand") 得到完整 ID maik:magic_wand。命令、翻译、模型和配方都围绕它工作。
设置最多堆叠一个
maxCount(1) 让魔法棒像工具一样单独占一格。它现在还没有耐久度,因此使用不会损耗。
加入工具分类
ItemGroups.TOOLS 让创造模式玩家能在工具与实用物品分类找到它。这只影响展示,不等于注册本身。
如果漏掉物品组代码,/give @s maik:magic_wand 仍可能成功;如果漏掉 Registry.register,那么翻译、模型和配方写得再漂亮也没有对象可以连接。排错时一定要分清“游戏里有没有这个物品”和“创造物品栏有没有展示它”是两件事。
Spell logic · 魔法发生的地方
真正的咒语不是一句 setBlockState,而是前后完整的安全检查。
package com.malaoshi.maik.event;
import com.malaoshi.maik.item.ModItems;
import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.minecraft.block.Block;
import net.minecraft.block.Blocks;
import net.minecraft.sound.SoundCategory;
import net.minecraft.sound.SoundEvents;
import net.minecraft.util.ActionResult;
public final class MagicWandEvents {
private MagicWandEvents() {
}
public static void register() {
AttackBlockCallback.EVENT.register((player, world, hand, pos, direction) -> {
if (!player.getStackInHand(hand).isOf(ModItems.MAGIC_WAND)) {
return ActionResult.PASS;
}
if (player.isSpectator() || !world.canPlayerModifyAt(player, pos)) {
return ActionResult.FAIL;
}
if (!world.isClient) {
world.setBlockState(pos, Blocks.FIRE.getDefaultState(), Block.NOTIFY_ALL);
world.playSound(
null,
pos,
SoundEvents.ITEM_FIRECHARGE_USE,
SoundCategory.BLOCKS,
1.0F,
1.0F
);
}
return ActionResult.SUCCESS;
});
}
}第一步:监听的是“攻击方块”,所以操作是左键
AttackBlockCallback 在玩家尝试攻击方块时触发。回调把五份现场信息交给我们:player 是谁在操作,world 是哪个世界,hand 是哪只手,pos 是被瞄准方块的位置,direction 是点击到的面。当前模板没有使用 direction,因为它直接替换被点击的方块,而不是在方块旁边生成火。
第二步:不是魔法棒就马上 PASS
player.getStackInHand(hand) 取得触发这次动作的手持堆栈,isOf(ModItems.MAGIC_WAND) 判断它是不是注册过的那根魔法棒。条件前的感叹号表示“不是”。返回 PASS 后,Fabric 会允许其他监听器或原版游戏继续处理,玩家拿镐子挖矿、空手打方块都不会被魔法逻辑劫持。
第三步:旁观者和无权限位置不能施法
player.isSpectator() 防止旁观者改世界;world.canPlayerModifyAt(player, pos) 会结合游戏规则和位置权限判断玩家能否修改目标。任一条件不满足就返回 FAIL。这不是多余的礼貌检查:服务器出生点保护、冒险玩法或其他权限系统都可能依赖它。
第四步:世界修改只在服务器端执行
Minecraft 单人世界内部也同时有客户端和逻辑服务器。客户端负责画面和输入,服务器拥有权威世界状态。if (!world.isClient) 意味着只有服务器端执行方块替换和声音广播。如果两边都改,可能出现重复动作;如果只在客户端改,玩家会先看见火,等服务器同步后又突然恢复,像一个失败的幻术。
第五步:把目标方块本身替换为火焰
world.setBlockState(pos, Blocks.FIRE.getDefaultState(), Block.NOTIFY_ALL) 使用的正是 pos,所以原方块会被替换,而不是在表面上方多放一格火。Block.NOTIFY_ALL 要求相关方块更新并把变化同步给客户端。火焰能否持续存在还要看周围方块和原版火焰规则;如果目标位置不适合,火可能很快熄灭。
第六步:声音由服务器广播
playSound 的第一个参数是 null,表示没有需要排除的玩家,附近玩家都能听见。声音选择火焰弹使用音效,分类是 BLOCKS,音量和音调都是 1.0F。画面和声音同时出现,魔法棒的反馈会比静悄悄换方块更有“施法感”。
pos 替换成火焰。请先在新建测试世界使用,不要对重要建筑试验。教程后面的扩展会演示如何改成在点击面旁边放火,从而保留原方块。Texture pipeline · 从 ID 走到像素
Java 只告诉游戏“有一根魔法棒”;它长什么样,要由三层资源接力完成。
Original texture · 原工程贴图
32×32 透明 PNG,像素小但信息完整
原图只有 32×32 像素,文件名是 magic_wand.png。橙红色杖身沿对角线展开,尖端有明亮高光,透明区域让它能自然叠在物品栏和玩家手中。预览区使用最近邻方式放大,因此每个像素仍保持清晰方块,而不是被浏览器抹成模糊色块。
制作自己的版本时,请保留透明背景,使用 PNG,并避免把文件保存成隐藏的 magic_wand.png.png。Minecraft 支持更大贴图,但 16×16、32×32、64×64 这类尺寸更容易保持原版像素风格。
maik:magic_wandmodels/item/magic_wand.jsonmaik:item/magic_wandtextures/item/magic_wand.png模型:告诉游戏按“手持工具”方式绘制
{
"parent": "minecraft:item/handheld",
"textures": {
"layer0": "maik:item/magic_wand"
}
}minecraft:item/handheld 适合剑、斧、魔杖等斜着拿的工具。layer0 的值不写 assets、不写 textures、也不写 .png;资源系统会把 maik:item/magic_wand 自动翻译成 assets/maik/textures/item/magic_wand.png。
翻译:让注册 ID 变成读者看得懂的名字
{
"item.maik.magic_wand": "魔法棒"
}{
"item.maik.magic_wand": "Magic Wand"
}物品翻译键的格式是 item.命名空间.路径。如果游戏显示原始键 item.maik.magic_wand,说明物品注册已经成功,问题只在语言文件的路径、文件名、JSON 语法或当前游戏语言,不要回头乱改 Java 注册代码。
Recipe · 给玩家一条获得路线
一份烈焰粉、两根木棍,竖着摆出一根带火焰核心的法杖。
B 木棍
S 木棍
S
maik:magic_wand{
"type": "minecraft:crafting_shaped",
"pattern": [
" B ",
" S ",
" S "
],
"key": {
"B": { "item": "minecraft:blaze_powder" },
"S": { "item": "minecraft:stick" }
},
"result": {
"item": "maik:magic_wand"
}
}pattern 中的空格也算格子,所以三行字符串必须保持三个字符。B 和 S 只是配方内部代号,可以换字母,但必须与 key 对应。结果使用完整注册 ID。这个配方属于 data,因为服务器需要知道玩家放入哪些材料以及产出什么;贴图则属于 assets,因为它负责显示。
Run & verify · 分层验收
不要只看“游戏启动了”;用四个证据确认每一层都真的连通。
先编译
在工程根目录执行 ./gradlew.bat build。这一步会编译 Java、处理 JSON 与资源并生成重映射 JAR。看到 BUILD SUCCESSFUL 才进入游戏测试。
启动开发客户端
在 IntelliJ 的 Gradle 工具窗口运行 Tasks → fabric → runClient,或者执行 ./gradlew.bat runClient。第一次启动会准备依赖,控制台暂时停在下载信息不等于卡死。
取得物品
进入允许作弊的测试世界,输入 /give @s maik:magic_wand。中文语言下名称应为“魔法棒”,物品栏中显示橙红色像素贴图。
验证施法
把魔法棒拿在手里,瞄准一块允许修改、下方有支撑的测试方块并按左键。观察目标变成火焰,同时听见火焰弹声音。请远离木屋、树林和重要建筑。
./gradlew.bat build
./gradlew.bat runClient
/give @s maik:magic_wand真实运行效果:魔法棒已经在游戏里把方块点燃
下面不是概念图,也不是后期合成的效果图。我们用本课的 maik-template-1.20 工程启动了真实的 Fabric 1.20 开发客户端,进入创造模式测试世界,把 maik:magic_wand 放到快捷栏第一格,再对中央的下界岩方块按下左键。事件回调通过手持物与权限检查后,由服务器把目标方块替换成火焰;截图是在效果仍然运行时直接按游戏的 F2 键保存的。
F2。原始 PNG 会保存在项目的 run/screenshots 目录;这比系统截屏更干净,因为不会把 IntelliJ、窗口边框或其他桌面内容一起拍进去。证据 A:命令有自动补全
输入 maik: 后能看到 magic_wand,说明注册表里确实存在这件物品。
证据 B:名称不是原始键
显示“魔法棒”,说明 zh_cn.json 已被读取且键名正确。
证据 C:图像不是紫黑格
手中和物品栏都显示法杖,说明模型 JSON 与 PNG 路径连接成功。
证据 D:普通物品不施法
换成木棍左键方块,不应触发放火,证明 PASS 分支工作正常。
证据 E:火焰同步稳定
方块变化对服务器和客户端一致,不会闪一下又恢复,说明修改发生在服务器端。
证据 F:能听到声音
附近玩家听见火焰弹音效,说明服务端声音广播已经执行。
Debug clinic · 按症状找层级
一次只追第一条根因;不要同时改 Java、JSON、版本和目录。
Gradle 报 Java 版本或 release 21 错误
执行 java -version 和 javac -version,再检查 IntelliJ 的 Project SDK 与 Gradle JVM。本模板的 build.gradle 和 fabric.mod.json 都要求 21,只把编辑器语言级别改成 21、但让 Gradle 继续使用 JDK 17,并不能解决构建问题。
我右键方块,什么都没有发生
这是预期行为。本模板注册的是 AttackBlockCallback,对应攻击方块,也就是左键。右键要使用 UseBlockCallback 或自定义 Item#use 等另一套入口,不能只把教程里的“左键”三个字理解成右键。
/give 找不到 maik:magic_wand
先看 fabric.mod.json 的 main 入口是否仍指向 com.malaoshi.maik.Maik,再看 onInitialize() 是否调用 ModItems.register()。检查控制台有没有 Maik mod initialized。如果入口都没加载,先不要查贴图。
有物品,但它是紫黑错误贴图
Java 注册已经通过。依次检查 assets/maik/models/item/magic_wand.json、其中的 maik:item/magic_wand、以及 assets/maik/textures/item/magic_wand.png。大小写、下划线和扩展名必须准确,JSON 不能有注释或尾逗号。
名称显示 item.maik.magic_wand
这是翻译层失联。简体中文必须放在小写的 zh_cn.json,键必须是 item.maik.magic_wand,文件保存为 UTF-8。游戏使用英语时会读取 en_us.json,所以也要检查当前语言。
拿到魔法棒,但左键仍在普通挖掘
检查 MagicWandEvents.register() 是否在 onInitialize() 中调用;再确认回调里的比较对象是同一个 ModItems.MAGIC_WAND。如果新建了第二个 Item 实例来比较,即使外观看起来一样,也不是注册表中的同一个对象。
火焰出现一下就熄灭
事件很可能已经成功。原版火焰还会检查所在位置是否能存活,周围没有可燃物、下方不合适或方块更新都可能让它熄灭。用一块独立的地面方块测试,并记住当前代码是替换目标方块本身。
在某些区域完全不能施法
模板主动调用 canPlayerModifyAt。旁观模式、服务器出生点保护、冒险规则或权限插件都可能拒绝修改。这是安全保护,不一定是 bug。换到自己拥有权限的测试区域再试。
客户端看到火,随后又恢复
这种现象通常说明世界修改错误地发生在客户端。确认 setBlockState 和 playSound 仍位于 if (!world.isClient) 内。世界状态应由服务器决定,再同步给客户端。
build 判断 Java 与 JSON;再查日志确认入口;再用 /give 判断注册;随后查名称、模型和贴图;最后才测试事件、权限、服务端同步和火焰规则。每一步只回答一个问题。Creative extensions · 把范例变成玩法
先做一个最温和的改进:保留目标方块,只在点击的那一面放火。
回调已经提供 direction,它表示玩家点中了方块的哪一面。把目标位置沿这个方向移动一格,就得到贴着表面的相邻位置。例如点击地面顶面,pos.offset(direction) 就是地面上方的空气格。加入空气检查后,魔法棒不会直接吞掉石头、箱子或建筑方块。
BlockPos firePos = pos.offset(direction);
if (world.getBlockState(firePos).isAir()) {
world.setBlockState(
firePos,
Blocks.FIRE.getDefaultState(),
Block.NOTIFY_ALL
);
world.playSound(
null,
firePos,
SoundEvents.ITEM_FIRECHARGE_USE,
SoundCategory.BLOCKS,
1.0F,
1.0F
);
}使用这段代码时要导入 net.minecraft.util.math.BlockPos。扩展后再次按“编译 → 启动 → 测试世界 → 普通物品对照”的顺序验证。不要一次同时加入冷却、耐久和粒子,否则出错时很难判断是哪一步造成的。
给魔法棒加耐久
研究 Item.Settings().maxDamage(...),施法成功后损耗 1 点。思考为什么有耐久物品不能再设置普通堆叠数量。
增加冷却时间
施法后用玩家的物品冷却管理器限制连续触发,让魔法棒不再像打火机一样高速连点。
加入粒子轨迹
从玩家视线到目标位置生成少量火焰或末地烛粒子,但世界修改仍必须以服务器为准。
三分钟自测
为什么拿木棍左键不会触发魔法?
回调首先比较手持物是否为 ModItems.MAGIC_WAND。不是就返回 PASS,把操作交还给其他监听器和原版逻辑。
为什么不能删掉 !world.isClient?
真正的世界状态由服务器维护。两端同时修改容易重复,只在客户端修改又会被服务器状态覆盖。
模型中的纹理为什么不写 .png?
Minecraft 资源位置使用 命名空间:目录/文件名 的逻辑 ID,加载器会自动到 assets/命名空间/textures 下寻找 PNG。
PASS、FAIL、SUCCESS 有什么区别?
PASS 表示本监听器不处理,让后续逻辑继续;FAIL 明确拒绝动作;SUCCESS 表示已经处理完成。正确返回值能避免魔法棒干扰其他物品。
Build & deliver · 生成可安装模组
测试世界通过后,执行一次干净构建,把源码变成最终 JAR。
./gradlew.bat clean build构建成功后,成品位于 build/libs/maik-1.0.0.jar。同目录里的 maik-1.0.0-sources.jar 是源码包,方便阅读或开发,不是普通玩家要安装的模组文件。将成品放入匹配 Minecraft 1.20、Fabric Loader 和 Fabric API 的客户端实例 mods 文件夹,并先用备份过的测试世界启动。
下载本课完整 Fabric 魔法棒工程
下载包来自本教程分析的 maik-template-1.20,包含 Gradle Wrapper、Java 源码、Fabric 元数据、中文与英文翻译、模型、32×32 魔法棒贴图和合成配方,不包含本机的运行日志与缓存目录。
Evidence · 本课依据
每一个版本号、路径和行为都能回到模板中的具体文件。
gradle.properties:Minecraft、Yarn、Loader、Loom、Fabric API 和模组版本。build.gradle:Loom 配置、依赖、Java 21 源码级别、资源处理和重映射构建。fabric.mod.json:Mod ID、入口类、运行环境、Java 与 Fabric 依赖。ModItems.java:maik:magic_wand注册、最大堆叠数量和创造模式分类。MagicWandEvents.java:左键事件、手持物检查、权限、服务端放火和音效。assets/maik与data/maik:翻译、模型、原始 32×32 PNG 贴图和工作台配方。
Lesson complete
现在你看到的不是一根魔法棒,而是一条完整的事件编程链。
玩家的左键是入口,注册 ID 是身份,回调参数是现场信息,权限判断是安全门,服务器端是最终裁判,模型与贴图负责把逻辑变成视觉反馈。掌握这条链以后,你可以把“放火”替换成种花、结冰、召唤闪电、生成粒子或触发机关。每次只换一个环节,先预测结果,再运行验证,你就从复制代码走向真正设计玩法了。