跳转至

原创教程 · 目标版本:Forge 1.20.1 · 场景:落英枪战服务器 FFGS

自定义命令(Commands)

枪战服务器(FFGS)运营中有一堆高频操作:给玩家发枪、查库存、重载枪械配置、治疗玩家、踢人、调试。总不能每次都用控制台敲指令、或者让玩家自己开创造模式。正确的做法是——为模组注册自定义命令,把运营逻辑封装成 /ffgs ... 这样一串漂亮的命令树。

本教程从零开始,带你完整走一遍 Forge 1.20.1 的命令开发流程:注册事件、Brigadier 命令树、参数类型、建议(补全)、权限控制、反馈消息、客户端命令,最后给出一个可直接运行的 /ffgs 完整示例。

目录

  1. 命令的本质:Brigadier 命令树
  2. 注册命令:RegisterCommandsEvent
  3. 参数类型全览
  4. 执行回调与反馈消息
  5. 建议(Suggestion):Tab 补全
  6. 权限控制
  7. 完整示例:FFGS 枪战命令
  8. 客户端命令
  9. 常见坑
  10. 小结

1. 命令的本质:Brigadier 命令树

Minecraft 的命令系统由 Mojang 自研的 Brigadier 库驱动(com.mojang.brigadier)。Brigadier 的核心思想是命令树(command tree):

  • 树的每个节点要么是字面量(literal),要么是参数(argument);
  • 从根节点到叶子节点的一条路径,就是一个完整的命令;
  • 叶子节点挂执行回调(executes),输入匹配成功时触发;
  • 参数节点可以挂建议(suggests),用于 Tab 补全。

比如 /ffgs weapon give Steve ak47 这棵树的形状是:

ffgs (literal)
 └─ weapon (literal)
     └─ give (literal)
         ├─ player (argument: 玩家选择器)
         │    └─ gun (argument: 枪械 ID) → executes(发枪逻辑)
         └─ list (literal) → executes(列出枪械)

在 Forge 1.20.1 中,命令树的构建入口是一个叫做命令调度器(CommandDispatcher)的对象,泛型参数是 CommandSourceStack(命令源栈,即“谁在什么环境下执行了这条命令”)。它从命令注册事件(RegisterCommandsEvent)里拿。

2. 注册命令:RegisterCommandsEvent

Forge 在服务器(以及单人游戏的内部服务器)启动、构建命令树时,会触发 RegisterCommandsEventnet.minecraftforge.event.RegisterCommandsEvent)。我们的任务就是订阅这个事件,往事件总线(EventBus)提供的命令调度器里塞命令。

订阅方式:在任意类上标注 @Mod.EventBusSubscriber,写一个静态方法加 @SubscribeEvent

package com.example.ffgs.command;

import com.mojang.brigadier.CommandDispatcher;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraftforge.event.RegisterCommandsEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;

@Mod.EventBusSubscriber(modid = FFGSMod.MODID) // 默认挂在 FORGE 总线
public class FFGSCommands {

    @SubscribeEvent
    public static void register(RegisterCommandsEvent event) {
        // 命令调度器:一切命令树的起点
        CommandDispatcher<CommandSourceStack> dispatcher = event.getDispatcher();

        dispatcher.register(
            Commands.literal("ffgs")              // 根节点:字面量
                .executes(ctx -> {                // 叶子:执行回调
                    ctx.getSource().sendSuccess(
                        net.minecraft.network.chat.Component.literal("你好,FFGS!"), false);
                    return 1;
                })
        );
    }
}

几个关键点:

  • 总线必须正确@Mod.EventBusSubscriberbus 参数默认是 Bus.FORGE(Forge 总线),而 RegisterCommandsEvent 恰好就发在 Forge 总线上,所以不写也行。如果误写成 bus = Bus.MOD,事件永远收不到,命令永远不会注册——这是新手第一大坑。
  • 主类(Mod Main Class)定义 MODID:上面代码里的 FFGSMod.MODID 来自你的主类:
package com.example.ffgs;

import net.minecraftforge.fml.common.Mod;

@Mod(FFGSMod.MODID)
public class FFGSMod {
    public static final String MODID = "ffgs";

    public FFGSMod() {
        // 初始化逻辑
    }
}
  • 执行时机:该事件只在服务端逻辑(专用服务器、单人游戏内置服务器)触发一次,命令因此对所有玩家可见。想只在客户端生效的命令请参考第 8 节
  • 单机测试:单人游戏需要开启“对命令方块开放”/局域网作弊,或者直接跑专用服务器测试;否则玩家(包括你自己)没有权限执行。

3. 参数类型全览

Brigadier 自带一套参数类型,Forge/Minecraft 又包装了一批游戏相关的参数。表格里统一用「参数类型(English)→ 获取方式」列出 1.20.1 常用款:

参数类型 构建方式 取值方式 说明
字面量 Commands.literal("give") 固定字符串,无值可取,只做分支路由
单词字符串 StringArgumentType.word() StringArgumentType.getString(ctx, "名") 不含空格的单词语,如枪械 ID
常规字符串 StringArgumentType.string() 同上 可带引号包裹的字符串
贪婪字符串 StringArgumentType.greedyString() 同上 吃掉命令行剩下的所有内容(可含空格)
整数 IntegerArgumentType.integer() / integer(min, max) IntegerArgumentType.getInteger(ctx, "名") 可限范围,越界自动报错
浮点数 FloatArgumentType.floatArg() / DoubleArgumentType.doubleArg() FloatArgumentType.getFloat(ctx, "名") 同理可限范围
布尔值 BoolArgumentType.bool() BoolArgumentType.getBool(ctx, "名") true / false
单个玩家 EntityArgumentType.player() EntityArgumentType.getPlayer(ctx, "名")ServerPlayer 必须精确指定一名在线玩家
多个玩家 EntityArgumentType.players() EntityArgumentType.getPlayers(ctx, "名")Collection<ServerPlayer> 支持 @a@p 等选择器
任意实体 EntityArgumentType.entity() / entities() EntityArgumentType.getEntity(ctx, "名") 玩家、生物、掉落物都算
物品 ItemArgumentType.item(Commands.getRegistryAccess()) ItemArgumentType.getItem(ctx, "名")ItemInput 支持物品 ID,如 minecraft:diamond
方块坐标 BlockPosArgumentType.blockPos() BlockPosArgumentType.getLoadedBlockPos(ctx, "名") ~ ~ ~ 相对坐标也能解析
资源路径 ResourceLocationArgumentType.id() ResourceLocationArgumentType.getResourceLocation(ctx, "名") 命名空间 ID,如 ffgs:ak47
NBT 标签 CompoundTagArgument.compoundTag() CompoundTagArgument.getCompoundTag(ctx, "名") 1.20.1 可用,直接解析 NBT

为什么 ItemArgumentType 需要传 Commands.getRegistryAccess() 1.19.4 之后物品参数需要访问注册表(Registry)来校验物品 ID 是否存在,Commands.getRegistryAccess() 返回当前世界冻结的注册表访问器(RegistryAccess.Frozen)。这是 1.20.1 的固定写法:

Commands.argument("item", ItemArgumentType.item(Commands.getRegistryAccess()))

拿到的 ItemInput 不是物品本体,需要进一步转换:

ItemInput input = ItemArgumentType.getItem(ctx, "item");
Item item = input.getItem();                                  // 物品类型
ItemStack stack = input.createItemStack(1, false);            // 转成物品堆(数量, 是否允许超堆叠上限)

参数名的作用Commands.argument("player", ...) 里的 "player" 是这个名字的“变量名”,执行回调里用同一个字符串取回解析结果。同一节点下的参数名不能重复,不同分支之间可以重名。

4. 执行回调与反馈消息

每个叶子节点通过 .executes(Command) 挂执行逻辑,Command 是一个函数式接口:

@FunctionalInterface
public interface Command<S> {
    int run(CommandContext<S> context) throws CommandSyntaxException;
}

也就是说,回调返回一个 int返回值约定:惯例返回 1 表示命令成功,0 表示失败;返回负值可用于条件分支场景,日常开发用 1/0 就够。配合游戏内的反馈消息,玩家就能明确知道命令到底成没成。

成功回执:sendSuccess

ctx.getSource().sendSuccess(Component.literal("枪械已发放"), true);

sendSuccess(Component, boolean) 有两个参数:

  • 第一个是消息本体(Component);
  • 第二个 boolean 表示是否记录到服务器日志(并同步给在线管理员)。运营类命令(发枪、治疗)建议填 true 留审计痕迹;纯查询类命令填 false 避免刷屏日志。

失败回执:sendFailure

ctx.getSource().sendFailure(Component.literal("玩家不在线"));

sendFailure(Component) 只有消息一个参数,玩家会看到红色的错误反馈。这是 1.20.1 的标准失败反馈 API。

消息组件

反馈消息用 Componentnet.minecraft.network.chat.Component)构建:

  • Component.literal("纯文本") —— 快速测试用;
  • Component.translatable("commands.ffgs.give.success", playerName, gunId) —— 正式项目推荐,配合语言文件(lang/zh_cn.json)做国际化,还能带占位符。

完整最小范例

把 2、3、4 节串起来:一个带整数参数、带成功/失败反馈的命令。

// /ffgs heal <amount> —— 给执行者回血
dispatcher.register(
    Commands.literal("ffgs")
        .then(Commands.literal("heal")
            .then(Commands.argument("amount", IntegerArgumentType.integer(1, 20))
                .executes(ctx -> {
                    int amount = IntegerArgumentType.getInteger(ctx, "amount");
                    ServerPlayer player = ctx.getSource().getPlayerOrException();
                    player.heal(amount);
                    ctx.getSource().sendSuccess(
                        Component.literal("已恢复 " + amount + " 点生命值"), false);
                    return 1;
                })))
);

注意 IntegerArgumentType.integer(1, 20) 自带范围校验:玩家输入 099 时,Brigadier 会直接拒绝输入并提示参数不合法,根本走不到你的执行回调——不需要手写范围判断

5. 建议(Suggestion):Tab 补全

参数节点挂 .suggests(...) 后,玩家按 Tab 就能看到可选项。两种做法:

5.1 内联 Lambda(简单场景)

.then(Commands.argument("gun", StringArgumentType.word())
    .suggests((ctx, builder) ->
        SharedSuggestionProvider.suggest(GunRegistry.allGunIds(), builder))
    .executes(...))

SharedSuggestionProvider.suggest(Iterable<String>, SuggestionsBuilder) 是 1.20.1 最常用的建议工具方法,把字符串集合直接喂给补全器。

5.2 独立建议提供者类(推荐复用)

package com.example.ffgs.command;

import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.suggestion.SuggestionProvider;
import com.mojang.brigadier.suggestion.Suggestions;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.SharedSuggestionProvider;

import java.util.concurrent.CompletableFuture;

/**
 * 枪械 ID 建议提供者:为 /ffgs weapon give <玩家> <枪械> 提供 Tab 补全
 */
public class GunSuggestionProvider implements SuggestionProvider<CommandSourceStack> {

    @Override
    public CompletableFuture<Suggestions> getSuggestions(
            CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) {
        return SharedSuggestionProvider.suggest(GunRegistry.allGunIds(), builder);
    }
}

用法:.suggests(new GunSuggestionProvider())。建议是动态的——每次 Tab 都会重新执行,所以枪械配置热重载后,补全列表立刻就是新的,不用重启服务器。

5.3 使用原版自带的建议器

物品参数可以直接复用原版的“可用物品”建议:

Commands.argument("item", ItemArgumentType.item(Commands.getRegistryAccess()))
    .suggests(SuggestionProviders.AVAILABLE_ITEMS)   // net.minecraft.commands.synchronization.SuggestionProviders

6. 权限控制

运营命令当然不能人人可敲。Brigadier 的节点支持 .requires(Predicate<S>)访问门控

Commands.literal("weapon")
    .requires(src -> src.hasPermission(2))   // 需要 2 级权限
    .then(Commands.literal("give")...)

src.hasPermission(level)CommandSourceStack 的权限判断方法,它对比的是操作员等级(op permission level):

等级 典型命令 说明
0 普通玩家命令 所有玩家默认
1 /tell 绕过出生点保护
2 /give/effect/tp 大多数管理命令的门槛,推荐运营命令用这档
3 /ban/kick/whitelist 封禁级管理
4 /op/stop 服务器最高权限

给玩家提权的方式:

  • 控制台或管理员执行 /op 玩家名(服务器 server.properties 里的 op-permission-level 默认 4,被 op 的人默认 4 级);
  • 模组服务器可搭配 LuckPerms 之类的权限插件做细粒度权限组,插件会把权限桥接给 hasPermission 判断(Forge 原生没有权限组概念)。

两个要点

  1. requires 不仅阻止执行,还影响 Tab 补全——没权限的玩家在输入时根本看不到、补不出这些分支。
  2. 建议做双重校验requires 管“能不能进这个分支”,执行回调里再检查一次权限(尤其是你打算把命令分支组合复用的时候),防止逻辑被绕过:
private static int giveWeapon(CommandContext<CommandSourceStack> ctx) throws CommandSyntaxException {
    if (!ctx.getSource().hasPermission(2)) {
        ctx.getSource().sendFailure(Component.literal("你没有权限执行此命令"));
        return 0;
    }
    // ... 发枪逻辑
    return 1;
}

7. 完整示例:FFGS 枪战命令

把前面所有知识整合成一个可直接复制运行的完整示例。场景:落英枪战服务器(FFGS)的运营命令 /ffgs,包含:

  • /ffgs weapon give <玩家> <枪械ID> —— 给指定玩家发枪(2 级权限,枪械 ID 带 Tab 补全);
  • /ffgs weapon list —— 列出当前可用枪械;
  • /ffgs reload —— 热重载枪械配置(2 级权限,全服广播);
  • /ffgs heal <目标> [数值] —— 治疗玩家,数值可省略(省略则回满)。

7.1 枪械注册表(示意)

package com.example.ffgs;

import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;

/**
 * 枪械注册表(示意实现)。
 * 正式项目中:枪械数据通常来自配置文件 / 数据包,reload() 里做热重载。
 */
public final class GunRegistry {

    private static final Map<String, Item> GUNS = new LinkedHashMap<>();

    static {
        // 示意:用原版物品代替自定义枪械物品
        GUNS.put("ak47", Items.IRON_AXE);
        GUNS.put("m4a1", Items.DIAMOND_SWORD);
        GUNS.put("awp", Items.BOW);
        GUNS.put("deagle", Items.GOLDEN_SWORD);
    }

    private GunRegistry() {
    }

    public static Set<String> allGunIds() {
        return GUNS.keySet();
    }

    public static boolean contains(String id) {
        return GUNS.containsKey(id);
    }

    public static ItemStack createGunStack(String id) {
        return new ItemStack(GUNS.getOrDefault(id, Items.AIR));
    }

    public static void reload() {
        // 示意:实际项目中在这里重新读取配置文件 / 数据包
    }
}

7.2 命令主类

package com.example.ffgs.command;

import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.event.RegisterCommandsEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import com.example.ffgs.FFGSMod;
import com.example.ffgs.GunRegistry;

import java.util.Collection;

/**
 * FFGS 服务端命令。
 * 挂在 FORGE 总线上(@Mod.EventBusSubscriber 默认总线),接收 RegisterCommandsEvent。
 */
@Mod.EventBusSubscriber(modid = FFGSMod.MODID)
public final class FFGSCommands {

    private FFGSCommands() {
    }

    @SubscribeEvent
    public static void register(RegisterCommandsEvent event) {
        CommandDispatcher<CommandSourceStack> dispatcher = event.getDispatcher();

        dispatcher.register(
            Commands.literal("ffgs")

                // ---- /ffgs weapon ... ----
                .then(Commands.literal("weapon")
                    .requires(src -> src.hasPermission(2))   // 整棵 weapon 子树都需要 2 级权限

                    // /ffgs weapon give <player> <gun>
                    .then(Commands.literal("give")
                        .then(Commands.argument("player", net.minecraft.commands.arguments.EntityArgumentType.player())
                            .then(Commands.argument("gun", StringArgumentType.word())
                                .suggests(new GunSuggestionProvider())   // Tab 补全枪械 ID
                                .executes(FFGSCommands::giveWeapon))))

                    // /ffgs weapon list
                    .then(Commands.literal("list")
                        .executes(FFGSCommands::listWeapons)))

                // ---- /ffgs reload ----
                .then(Commands.literal("reload")
                    .requires(src -> src.hasPermission(2))
                    .executes(FFGSCommands::reloadGuns))

                // ---- /ffgs heal <target> [amount] ----
                .then(Commands.literal("heal")
                    .then(Commands.argument("target", net.minecraft.commands.arguments.EntityArgumentType.players())
                        // 不带数值:回满
                        .executes(ctx -> healPlayers(ctx, -1))
                        // 带数值:恢复指定生命值(1~20)
                        .then(Commands.argument("amount", IntegerArgumentType.integer(1, 20))
                            .executes(ctx -> healPlayers(ctx, IntegerArgumentType.getInteger(ctx, "amount"))))))
        );
    }

    /** /ffgs weapon give <player> <gun> */
    private static int giveWeapon(CommandContext<CommandSourceStack> ctx) throws CommandSyntaxException {
        ServerPlayer target = net.minecraft.commands.arguments.EntityArgumentType.getPlayer(ctx, "player");
        String gunId = StringArgumentType.getString(ctx, "gun");

        // 枪械不存在 -> 失败回执
        if (!GunRegistry.contains(gunId)) {
            ctx.getSource().sendFailure(Component.literal("枪械不存在:" + gunId));
            return 0;
        }

        ItemStack stack = GunRegistry.createGunStack(gunId);
        if (!target.getInventory().add(stack)) {
            // 背包满了就掉在地上,避免物品凭空消失
            target.drop(stack, false);
        }

        ctx.getSource().sendSuccess(
            Component.literal("已将枪械 [" + gunId + "] 发放给 " + target.getGameProfile().getName()),
            true);   // true:写入服务器日志,留审计痕迹
        return 1;
    }

    /** /ffgs weapon list */
    private static int listWeapons(CommandContext<CommandSourceStack> ctx) {
        ctx.getSource().sendSuccess(
            Component.literal("当前可用枪械:" + String.join(", ", GunRegistry.allGunIds())),
            false);
        return 1;
    }

    /** /ffgs reload */
    private static int reloadGuns(CommandContext<CommandSourceStack> ctx) {
        GunRegistry.reload();
        // 全服广播,让在线玩家知道配置已热更新
        ctx.getSource().getServer().getPlayerList().broadcastSystemMessage(
            Component.literal("[FFGS] 枪械配置已重载"), false);
        return 1;
    }

    /** /ffgs heal <target> [amount],amount < 0 表示回满 */
    private static int healPlayers(CommandContext<CommandSourceStack> ctx, int amount) throws CommandSyntaxException {
        Collection<ServerPlayer> targets = net.minecraft.commands.arguments.EntityArgumentType.getPlayers(ctx, "target");
        for (ServerPlayer player : targets) {
            player.setHealth(amount < 0 ? player.getMaxHealth() : amount);
        }
        ctx.getSource().sendSuccess(
            Component.literal("已治疗 " + targets.size() + " 名玩家"), true);
        return 1;
    }
}

7.3 完整文件清单

src/main/java/com/example/ffgs/
├── FFGSMod.java                 # 主类,定义 MODID
├── GunRegistry.java             # 枪械注册表
└── command/
    ├── FFGSCommands.java        # 服务端命令注册 + 执行逻辑
    └── GunSuggestionProvider.java  # 枪械 ID Tab 补全

把 7.1、7.2、以及第 5 节的 GunSuggestionProvider 放进工程,运行游戏,试试:

/ffgs weapon list
/ffgs weapon give Steve ak47       # Tab 可补全 ak47
/ffgs heal Steve
/ffgs heal @a 5
/ffgs reload

没 op 的玩家敲 /ffgs weapon ... 时,连补全都看不到这些分支——requires 生效了。

8. 客户端命令

8.1 服务端命令 vs 客户端命令

维度 服务端命令 客户端命令
注册事件 RegisterCommandsEvent RegisterClientCommandsEventnet.minecraftforge.client.event
事件订阅 @Mod.EventBusSubscriber(FORGE 总线) 同上,但必须加 value = Dist.CLIENT
命令源 CommandSourceStack ClientCommandSourceStacknet.minecraftforge.client
执行线程 服务端主线程 客户端主线程(渲染线程)
可见范围 全服玩家(命令由服务端下发) 仅本机客户端
能碰的 API 服务端世界、ServerPlayer、存档 客户端世界(ClientLevel)、本地玩家(LocalPlayer)、渲染
权限 requires + hasPermission 一般无权限概念(只影响本机)

核心区别一句话:服务端命令的操作是“真”的(改世界、改玩家数据),因为命令执行在服务端;客户端命令只作用于本机表现(改 HUD、播放音效、发自定义数据包请求),任何需要影响世界的逻辑都必须走网络通道通知服务端。

8.2 注册客户端命令

package com.example.ffgs.command;

import com.mojang.brigadier.CommandDispatcher;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.ClientCommandSourceStack;
import net.minecraftforge.client.event.RegisterClientCommandsEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import com.example.ffgs.FFGSMod;

/**
 * 客户端命令示例。
 * 注意:必须 value = Dist.CLIENT,且事件类来自 net.minecraftforge.client.event。
 */
@Mod.EventBusSubscriber(modid = FFGSMod.MODID, value = Dist.CLIENT)
public final class FFGSClientCommands {

    private FFGSClientCommands() {
    }

    @SubscribeEvent
    public static void register(RegisterClientCommandsEvent event) {
        CommandDispatcher<ClientCommandSourceStack> dispatcher = event.getDispatcher();

        dispatcher.register(
            Commands.literal("ffgsping")
                .executes(ctx -> {
                    ClientCommandSourceStack src = ctx.getSource();
                    src.sendSuccess(Component.literal("pong! 客户端命令执行成功"), false);
                    return 1;
                })
        );
    }
}

ClientCommandSourceStack 常用方法:getPlayer()LocalPlayer,可能为 null)、getEntity()getLevel()ClientLevel)、sendSuccess / sendFailure。注意它没有 getServer(),也没有 getPlayerOrException()——客户端根本没有 MinecraftServer。

客户端命令的坑

  • 客户端命令拿不到 CommandSourceStack,因此 EntityArgumentTypeItemArgumentType 这类面向服务端命令源的参数类型无法直接使用(类型不匹配,编译都过不了)。客户端命令一般只用字面量、字符串、整数等纯 Brigadier 参数。
  • 不要与服务端命令重名:客户端和服务端的命令树会合并,同名命令可能互相覆盖、行为不可预测。客户端命令请用独立命名空间(如上例 ffgsping 而不是 ffgs)。
  • 客户端命令要影响服务端世界时,唯一正道是发送数据包(Packet)给服务端处理,绝不能在客户端直接改世界数据。

9. 常见坑

  1. 事件总线挂错RegisterCommandsEvent 在 Forge 总线上,订阅类 bus 参数必须是默认的 Bus.FORGE。写成 Bus.MOD 命令永远不注册,且没有任何报错——排查时先看这里。

  2. 在服务端命令里引用客户端类:命令类里 import net.minecraft.client.* 的任何类(MinecraftScreen 等),专用服务器一加载就 ClassNotFoundException/NoClassDefFoundError 崩溃。服务端命令只能碰 net.minecraft.server.* 和公共 API。反过来,客户端命令也不能碰 MinecraftServer。拿不准就用 Dist 拆分到不同类。

  3. 反馈消息用错 API:1.19 之后 player.sendMessage(Component, UUID) 已废弃,更别提 player.chat()。统一用 CommandSourceStack#sendSuccess(Component, boolean) / #sendFailure(Component)。要广播用 server.getPlayerList().broadcastSystemMessage(Component, boolean)

  4. sendSuccess 的两个参数:第二参 boolean 是“是否写日志/通知管理员”,不是“是否广播给所有人”。想全服广播要用 broadcastSystemMessage,想发给特定玩家用 player.sendSystemMessage(Component)

  5. 1.20.1 CommandSourceStack 方法速记

  6. getPlayerOrException() —— 执行者必须是玩家,否则抛异常(适合玩家专属命令);
  7. getEntityOrException() —— 执行者可以是任意实体;
  8. getServer() —— MinecraftServer
  9. getLevel() —— ServerLevel(服务端世界);
  10. getPosition() —— 执行位置;
  11. hasPermission(int) —— 权限判断;
  12. sendSuccess(Component, boolean) / sendFailure(Component) —— 反馈。

  13. 命令在服务端主线程执行executes 里做文件 IO、网络请求、重型计算会卡服(所有玩家一起卡)。重活请丢到异步线程或 tick 里做,命令回调只做轻量校验和调度。

  14. 字符串参数选错类型StringArgumentType.string() 不接受未加引号的空格;要“吃掉剩余全部输入”(比如一条留言),用 greedyString();枪械 ID 这类无空格标识符用 word() 最合适。

  15. 整数/浮点范围IntegerArgumentType.integer(min, max) 越界输入会被 Brigadier 直接拒绝并自动报错,别自己再写一套范围判断。但注意:范围判断只发生在解析阶段,别依赖它做权限校验。

  16. 同层同名节点重复注册.then(Commands.literal("give")) 在同一个父节点下只能出现一次,否则 IllegalArgumentException。需要多个“同前缀不同后缀”的分支时,在同一个 .then(...) 里层层嵌套,而不是并列写两个。

  17. 建议回调要轻量suggests 的回调在命令树构建/补全时执行,不要阻塞、不要抛未捕获异常(会导致补全崩溃)。数据量小直接 SharedSuggestionProvider.suggest(...) 即可。

  18. 返回值约定:执行回调返回 int,惯例成功 1、失败 0。返回负值可用于“命令链”条件(then(...) 链式执行判断),日常用 1/0 足够,别随手 return 0 后还告诉玩家“成功了”。

  19. 权限只做门控requires 只控制“能否进入分支/补全”,不保证执行时一定安全。执行回调里再校验一次(见第 6 节),尤其是涉及给物品、改数据这类不可逆操作。

  20. 参数变量名前后一致Commands.argument("player", ...)EntityArgumentType.getPlayer(ctx, "player") 的字符串必须一字不差。改名只改一处 = 运行时 CommandSyntaxException

  21. 客户端命令别当服务端用RegisterClientCommandsEvent + ClientCommandSourceStack 只影响本机。想实现“玩家点一下按钮服务端发枪”,正确链路是:客户端命令 → 发送数据包(Packet)→ 服务端处理并执行发枪。命令本身只是 UI 层的入口。

10. 小结

  • 命令注册三步走:@Mod.EventBusSubscriber(FORGE 总线)→ @SubscribeEvent 接收 RegisterCommandsEventevent.getDispatcher().register(命令树)
  • 命令树 = 字面量(literal)路由 + 参数(argument)取值 + 建议(suggests)补全 + 执行回调(executes)干活。
  • 反馈统一 sendSuccess(Component, boolean) / sendFailure(Component),返回值 1 成功 / 0 失败。
  • 权限用 requires(src -> src.hasPermission(2)),op 等级 2 是运营命令的常用门槛,执行回调里再兜底校验一次。
  • 命令执行在服务端;客户端专属逻辑走 RegisterClientCommandsEvent + ClientCommandSourceStack,且只做本机表现,世界改动必须通过数据包回传服务端。
  • 遇到“命令没反应”,先查总线、再查权限、再查参数名,最后查客户端/服务端类混用。

现在,去给你的 FFGS 服务器写第一串 /ffgs 命令吧。别忘了先在控制台 /op 你自己