Sponge插件命令系统简介

概述

我们假设需要做一个传送命令(这里就姑且叫做TryTeleport吧),我们想要什么呢?

  • 输入/tryteleport notch 64 128 64

    将把玩家notch传送到坐标为(64,128,64)的位置
  • 输入/tryteleport 64 128 64

    将把自己传送到坐标为(64,128,64)的位置
  • 输入/tryteleport DIM-1 64 128 64

    将把自己传送到坐标为(64,128,64)的下界位置
  • 输入/tryteleport notch 64 128 64 0 0

    将把玩家notch传送到坐标为(64,128,64)的位置,同时后两个参数决定了传送时的姿态坐标(Yaw和Pitch)
  • 输入/tryteleport notch ~ ~ ~

    将把玩家notch传送到自己所在的位置
  • 输入/tryteleport @a ~ ~ ~

    将把所有玩家传送到自己所在的位置

需求其实挺复杂,但是还不止这些:

  • 如果试图传送自己的是服务端控制台,那么将出错
  • 如果试图在位置坐标处输入一些不合法的字符串,那么也会出错
  • 如果试图传送的玩家不在线,那么将出错

还有呢:

  • 输入/tryteleport后,按下Tab键会自动轮流弹出世界中所有玩家的名字

还有一项需求:

  • 输入/help tryteleport后,会得到相关的参数信息,这里我希望它是:

    Usage: /tryteleport [<source>] <location> [<yaw> <pitch>]

所以说,定义一个命令,主要定义的就是这四部分:功能错误处理自动补全、以及帮助信息

实际上我们可以注意到,很多情况下处理命令的行为都类似,比方说输入玩家:

  • 处理一个字符串作为玩家名称
  • 若字符串对应的玩家不在线则出错
  • 在按下Tab时自动从世界寻找已有玩家并轮流返回其名称
  • 输出的帮助信息中,玩家参数的相应位置应输出<player>

在软件工程中,遇到重复的代码就需要想想如何合并,开发Sponge插件亦是如此。而(至少据我所知)Bukkit插件没有提供行之有效的手段,所以开发者就需要一遍一遍地写重复的处理命令的代码,如果涉及到选择器等情况问题还会变得更加复杂。Sponge(至少目前看来)很好地解决了这一问题。

本篇Post的内容就是简要介绍Sponge插件的命令系统。

注册命令

注册命令首先需要在插件主类中监听GameStartingServerEvent。不熟悉插件主类如何书写的可以看这里

import com.google.inject.Inject;  
import org.slf4j.Logger;  
import org.spongepowered.api.Sponge;  
import org.spongepowered.api.command.CommandCallable;  
import org.spongepowered.api.event.Listener;  
import org.spongepowered.api.event.game.state.GameStartingServerEvent;

@Inject
private Logger logger;

@Listener
public void onStartingServer(GameStartingServerEvent event)  
{
    CommandCallable command = // Your command
    Sponge.getCommandManager().register(this, command, "tryteleport", "trytp", "ttp");
    this.logger.info("Command successfully registered. ");
}

我们首先需要定义一个CommandCallable,稍后我们会讲到如何生成一个CommandCallable。然后我们通过调用CommandManager#register方法完成命令的注册:

  • 该方法的第一个参数传入插件的主类对象,通常是this
  • 该方法的第二个参数传入用于执行插件命令的CommandCallable
  • 该方法的第三个参数开始传入插件的名称,通常名称从复杂到简略,第三个参数将作为该命令的主名称,后面的参数将作为该命令的别名或简写

所以说,这里我们设置/tryteleport/trytp/ttp三个命令的作用是一样的。在后面我们不会对三种命令加以区分。

定义命令

我们看看CommandCallable类的JavaDoc就会注意到有足足有六个方法需要实现,当然你也可以自己实现它以实现手动解析,不过这也不是本篇Post的目的。我们需要讲的是CommandCallable的一个实现:CommandSpec

通常情况下,生成一个CommandSpec的代码是这样子的:

import org.spongepowered.api.command.CommandCallable;  
import org.spongepowered.api.command.spec.CommandSpec;

CommandCallable command = CommandSpec.builder()  
        .executor(/*Command executor*/)
        .arguments(/*Command argument specification*/)
        .permission(/*Permission needed*/)
        .description(/*Command description*/)
        .build();

首先,我们生成一个CommandSpec.Builder用于生成CommandSpec,然后我们依次调用CommandSpec.Builder的各个方法用于设置描述(description)、权限(permission)、参数(arguments)、执行器(executor)等,最后调用build方法生成一个CommandSpec

我们先从命令参数的定义说起。

命令参数

刚刚说到,我们希望的ttp命令是这个样子的:

/ttp [<source>] <location> [<yaw> <pitch>]

我们分析一下这个命令应该如何解析:

  • 首先试着解析玩家,也就是<source>,不过这个玩家是可选的,也就是说如果没有解析到玩家(也就是试图解析<location>的第一个参数失败了),我们就使用输入命令的玩家当作<source>
  • 然后试着解析坐标,坐标的形式是连续的三个整数或者世界名称加上连续的三个整数
  • 然后试着解析姿态坐标,这里的姿态坐标是可选的,不过这里的可选不大一样,因为如果格式错误应该直接抛出错误,而不是像之前的玩家一样忽略,当然如果姿态坐标不存在,那么是没有问题的
  • 解析姿态坐标相当于解析两个浮点数

也就是说整个树形结构是这样的:

\---序列
    \---玩家或命令执行者---source
    |
    \---坐标---location
    |
    \---可选
        \---序列
            \---浮点数---yaw
            |
            \---浮点数---pitch

如果转换成代码是这样子的:

import org.spongepowered.api.command.args.GenericArguments;  
import org.spongepowered.api.text.Text;  
import org.spongepowered.api.text.format.TextColors;

GenericArguments.seq(  
        GenericArguments.playerOrSource(Text.of(TextColors.GREEN, "source")),
        GenericArguments.location(Text.of("location")),
        GenericArguments.optional(
                GenericArguments.seq(
                        GenericArguments.doubleNum(Text.of("yaw")),
                        GenericArguments.doubleNum(Text.of("pitch")))))

这实际上就和我们传入arguments方法的参数几乎一致了,这里唯一一点区别就是arguments方法支持变长参数数组,而对于传入的变长参数数组,Sponge会再使用GenericArguments#seq方法包装一层。

这里我们使用到了文本API中的Text对象。Text对象是SpongeAPI用来描述带有Minecraft特殊颜色、样式等的文本的利器。如果想要了解关于文本API更多,可参见这里的说明。这里作为示例,为了说明Minecraft特殊颜色、样式等是可调的,特意把source设置成了绿色。

GenericArguments类还提供了很多种处理参数的方式,及一些可能出现的组合,更多内容可以参见这里

命令执行

executor方法需要开发者提供一个org.spongepowered.api.command.spec.CommandExecutor类的对象。该对象只有一个方法需要实现,也就是说我们可以使用Lambda表达式解决这一问题。我们先在主类写一个相应的方法:

import org.spongepowered.api.command.CommandException;  
import org.spongepowered.api.command.CommandResult;  
import org.spongepowered.api.command.CommandSource;  
import org.spongepowered.api.command.args.CommandContext;

private CommandResult handleCommand(CommandSource src, CommandContext args) throws CommandException  
{
    // TODO
    return CommandResult.success();
}

有两个CommandResult对象作为返回值很常用:CommandResult.success()(用于表示命令执行成功)和CommandResult.empty()(用于表示命令压根没执行)。

如果命令执行失败怎么办?我们可以抛出CommandException来表明命令执行失败。Sponge会拦住这一异常并作以相应的处理并产生合适的输出。

很好,刚刚定义了sourcelocationyaw、和pitch作为命令的参数,那么现在我们怎么拿到这些参数呢?

import java.util.Collection;

import org.spongepowered.api.command.CommandException;  
import org.spongepowered.api.command.CommandResult;  
import org.spongepowered.api.command.CommandSource;  
import org.spongepowered.api.command.args.CommandContext;  
import org.spongepowered.api.entity.living.player.Player;  
import org.spongepowered.api.text.Text;  
import org.spongepowered.api.text.format.TextColors;  
import org.spongepowered.api.world.Location;  
import org.spongepowered.api.world.World;

private CommandResult handleCommand(CommandSource src, CommandContext args) throws CommandException  
{
    Collection<Player> players = args.getAll(Text.of(TextColors.GREEN, "source"));
    Location<World> target = args.<Location<World>> getOne(Text.of("location")).get();
    double yaw = args.<Double> getOne(Text.of("yaw")).orElse(0.0);
    double pitch = args.<Double> getOne(Text.of("pitch")).orElse(0.0);

    String template = "Player %s in %s will be teleported from (%d, %d, %d) to (%d, %d, %d) in %s, yaw %.1f, pitch %.1f";

    for (Player player : players)
    {
        Location<World> source = player.getLocation();
        String sourceWorldName = source.getExtent().getName();
        String targetWorldName = target.getExtent().getName();
        String playerName = player.getName();

        src.sendMessage(Text.of(String.format(template, playerName, sourceWorldName, source.getBlockX(),
                source.getBlockY(), source.getBlockZ(), target.getBlockX(), target.getBlockY(), target.getBlockZ(),
                targetWorldName, yaw, pitch)));
    }

    return CommandResult.success();
}

作为示例,我们获取到所有玩家,并模拟传送的行为,也就是在游戏控制台输出相关信息。

我们只用了CommandContext#getAllCommandContext#getOne两个方法以获取玩家、坐标、和两个浮点数。前者返回一个java.util.Collection,而后者返回一个java.util.Optional,然后我们就拿来处理了。

就这么简单?没错!就这么简单。完全不需要繁琐的参数处理过程,因为几乎一切Sponge都已经帮你搞定了。

这里有一点需要说明,CommandContext#getOne方法在发现获取到的参数不止一个时,将返回一个什么对象都不包含的Optional

最后我们把这些都放在一起,并加上了description(为了方便,这里没有设置权限):

import org.spongepowered.api.command.CommandCallable;  
import org.spongepowered.api.command.args.GenericArguments;  
import org.spongepowered.api.command.spec.CommandSpec;  
import org.spongepowered.api.text.Text;  
import org.spongepowered.api.text.format.TextColors;

CommandCallable command = CommandSpec.builder().executor(this::handleCommand)  
        .arguments(
                GenericArguments.playerOrSource(Text.of(TextColors.GREEN, "source")),
                GenericArguments.location(Text.of("location")),
                GenericArguments.optional(
                        GenericArguments.seq(
                                GenericArguments.doubleNum(Text.of("yaw")),
                                GenericArguments.doubleNum(Text.of("pitch")))))
        .description(Text.of("Try teleport ", TextColors.GREEN, "player", TextColors.RESET, "s. ")).build();

很好,大功告成!这里是整篇Post的相关源代码。

实际测试

我们打开Sponge服务端,先在服务端控制台进行测试: 然后我们打开游戏,输入/ttp ~ ~ ~,看到了预期的输出: 我们再输入/ttp DIM1 ~ ~ ~,试图传送入下界,输出也如预期所想: 然后是帮助: 这里我们注意到了和服务端控制台的区别,<source>是可选的: 最后我们试着使用选择器以及yawpitch Excellent!一点问题都没有:

总结

本篇Post所述仅仅是SpongeAPI中命令系统的冰山一角,如果想要了解更多关于SpongeAPI中命令系统的知识,感兴趣的读者可以参阅相关的开发文档