质粒

质粒

基于Fabric的服务端小游戏开发库。

基础库

Plasmid

Plasmid 是一个使用 Fabric 创建服务端迷你游戏的库。 Plasmid 完成了与迷你游戏实现相关的所有枯燥工作,以便让你能够专注于游戏本身。

Plasmid 是 Nucleoid 项目 的核心,该项目致力于为服务端 Minecraft 迷你游戏构建一个开源生态系统。 你可以在 Nucleoid GitHub 组织 上查看许多使用 Plasmid 实现的游戏示例。 你甚至可能有兴趣在我们的测试 Minecraft 服务器 nucleoid.xyz 上玩一玩其中的一些游戏! 如果你遇到任何问题或有任何疑问,或者想要参与其中,也可以在我们的 Discord 上找到我们。

使用

本文是我们 快速入门 wiki 页面的镜像,提供了对 Plasmid 概念的基本介绍。 你可以查看我们的 wiki 站点 以获取更详细的信息。

如果你想快速启动并运行一个基本的游戏设置,请克隆 plasmid-starter 仓库,运行 init.py,然后删除 .git、README.md 和 init.py。或者,如果你正在寻找已实现游戏的示例,请浏览 Nucleoid 组织 下的仓库。

添加到 Gradle

假设你已经设置好了 Fabric 工作区,设置 Plasmid 的第一步就是将其添加到你的 gradle 构建脚本中。你需要添加 maven 仓库以及 plasmid 依赖。PLASMID_VERSION 应替换为 Maven 上的最新版本。

本教程目前针对 Plasmid 0.5.x 进行了更新。

repositories {
  maven { url = 'https://maven.nucleoid.xyz/' }
}

dependencies {
  // ...
  modImplementation 'xyz.nucleoid:plasmid:PLASMID_VERSION'
}

创建游戏类型

"游戏类型"(GameType)是使用 Plasmid 创建游戏的入口:它们为你的游戏提供一个唯一标识符,以及在游戏开始时调用你的代码所需的所有信息。

Plasmid 旨在鼓励数据驱动的游戏,并围绕"游戏配置"这一概念进行工作。游戏配置本质上是游戏类型的一种特定变体!这可能涉及在不同的地图上游戏,或者完全不同的游戏机制。游戏配置简单地定义为数据包中的一个 JSON 文件,它引用你的 GameType 并传递任何可能对配置游戏有用的额外数据。虽然一开始这可能要多做一些工作,但它非常强大,可以让游戏更容易调整或在不重复代码的情况下产生多种变体。稍后会详细介绍配置!

要注册一个 GameType,你需要在你的 ModInitializer 类中调用 GameType.register()。注册一个 GameType 的调用可能看起来像这样:

GameType.register(
        new Identifier("plasmid_example", "example"),
        ExampleGameConfig.CODEC,
        ExampleGame::open
);

让我们分解一下这里发生了什么:

  • new Identifier("plasmid_example", "example")
    • 声明这个 游戏类型 的唯一标识符,游戏配置 JSON 将引用它
  • ExampleGameConfig.CODEC
    • 一个 Codec,用于从 JSON 文件加载游戏配置(稍后会详细介绍!)
  • ExampleGame::open
    • 一个方法引用,指向当玩家请求时用于启动你的游戏的函数

这自然还不能编译:ExampleGame 和 ExampleGameConfig 都不存在!让我们来解决这个问题。

在代码中创建我们的配置

首先,我们将创建 ExampleGameConfig 类,它将包含一个 String 字段,用作玩家加入时发送给玩家的消息。Java 新的 Records 非常适合用于配置,但不是必需的!

public record ExampleGameConfig(String greeting) {
}

这很简单!但是我们之前引用的 CODEC 字段不见了。那是怎么回事?

Codec 是 Mojang 的 DataFixerUpper 库实现的一个非常有用的工具,它本质上允许将 Java 对象方便地序列化和反序列化到 JSON 文件。Drullkus 对 Codec 的更详细解释可以在这里找到,但出于简单的目的,你只需要知道将它们组合在一起的模式。

本质上,Codec 描述了对象如何被序列化和反序列化。简单来说,它们可以由字段列表以及这些字段应如何被序列化来创建。它是这样的:

public record ExampleGameConfig(String greeting) {
    public static final Codec<ExampleGameConfig> CODEC = RecordCodecBuilder.create(instance -> {
        return instance.group(
                Codec.STRING.fieldOf("greeting").forGetter(ExampleGameConfig::greeting)
        ).apply(instance, ExampleGameConfig::new);
    });
}

这将对应于一个看起来像这样的 JSON 文件:

{
  "greeting": "Hello World!"
}

这里的大部分内容你可以忽略:你真正需要关心的只是 instance.group(...) 调用中的内容,以及 Codec 上的泛型。更具体地查看每个相关部分:

  • Codec<ExampleGameConfig>
    • 被反序列化到的类的类型作为泛型参数传递给 Codec。
  • Codec.STRING.fieldOf(...).forGetter(...)
    • 这添加了一个具有给定名称和类型的字段,将从 JSON 中读取。
    • 你会注意到 Codec.STRING 本身就是一个 Codec<String>!你声明的每个字段都需要一个 Codec 来描述该字段应如何处理。在这种情况下,我们指示 greeting 字段应使用 Codec.STRING 加载。以同样的方式,我们可以引用我们创建的任何其他 codec 并将其添加为字段!这在允许 codec 组合以创建复杂结构方面非常有用!
      • Codec 提示:大多数可序列化的 Minecraft 类型都会持有一个静态的 CODEC 字段供使用(例如 BlockPos.CODEC 或 Identifier.CODEC)。如果没有,我们捆绑了一个 MoreCodecs 类型,它提供了一些原版代码库中未包含的常见 codec(例如 MoreCodecs.TEXT)。
    • .fieldOf() 的参数指定了该值将从其读取的字段(在 JSON 中)的名称。
    • .forGetter() 指定了应如何从我们的配置对象中读回字段的值。这很有用,因为 codec 允许序列化和反序列化,并且需要 getter 将对象转换回数据。由于我们使用的是 record,这里我们可以使用方法引用。
  • ExampleGameConfig::new
    • 这告诉 codec 在所有字段被反序列化后如何创建对象。这需要对给定对象构造函数的方法引用,且所有字段按照它们被指定的顺序传递!。
    • 例如,如果我们传递了 Codec.STRING.fieldOf("foo") 然后 Codec.INT.fieldOf("bar"),构造函数将接受一个 (String, int)。
    • 但这里我们接受一个 String 字段,我们引用的构造函数也接受一个 String 参数。

所有这些 Codec 工作的最终结果是,当我们创建游戏配置时,所有这些数据将自动从我们的 JSON 文件解析并传递到我们的游戏代码!

创建配置

现在我们知道了配置应包含哪些数据,我们可以创建一个实际的游戏配置 JSON 供 Plasmid 加载。

所有游戏配置都需要位于你的模组资源(或数据包!)中,路径为 data/<namespace>/games/<id>.json。对于模组而言,namespace 应该就是你的模组 id,而 id 可以是任何唯一的名称,稍后用于从 Minecraft 内部引用你的游戏配置。

Plasmid 只需要配置中的 1 个 JSON 字段,其余部分根据你设置的配置 codec 加载。然而,还有一些额外的可选字段可能对定义有用。唯一必需的字段是 type,它指的是你之前在 namespace:path 格式中创建的 GameType(例如在我们的例子中为 plasmid_example:example)。

对于我们的目的,位于 data/plasmid_example/games/hello_world_example.json 的游戏配置将看起来像:

{
  "type": "plasmid_example:example",
  "greeting": "Hello, World!"
}

我们还可以在 JSON 中添加一些额外的内置字段,例如 name、short_name、description 和 icon。 这可能看起来像:

{
  "type": "plasmid_example:example",
  "name": "Hello World Example!",
  "description": ["Look at my cool game!", "It greets you when you join."],
  "icon": "minecraft:apple"
  // ...
}

name 和 description 也可以引用翻译键,因为它们属于 JSON 文本组件。例如,这也可以写作:"name": {"translation": "game.plasmid_example.hello_world_example"}。

关于翻译的说明

翻译在 Plasmid 中有点非标准,因为它完全是服务端的!通常翻译存储在游戏客户端中,服务器只发送_翻译键_,然后在客户端转换为相关的可读文本。然而,在这里,我们需要通过更改发送给玩家的数据包来处理翻译,使得在客户端收到之前就正确翻译。这是大量工作!幸运的是,这由 Server Translations 处理,我们不需要担心!

这一切实际上意味着你的语言文件需要放在 data 文件夹而不是 assets 文件夹中(例如 data/<namespace>/lang/en_us.json)。

如果我们不手动定义名称,有一些默认语言键我们应该注意:gameType.<namespace>.<id> 和 game.<namespace>.<id>。这些键分别应用于游戏_类型_和游戏_配置_。在解析游戏配置的可读名称时,将测试配置翻译和类型翻译,类型作为回退。这意味着严格来说只需要游戏类型翻译。

例如,我们可以将 data/plasmid_example/lang/en_us.json 定义为:

{
  "gameType.plasmid_example.example": "Plasmid Example!",
  "game.plasmid_example.hello_world_example": "Hello World Example!"
}

编写启动游戏的代码

现在我们已经设置了一个配置并告诉 Plasmid 如何从中读取,我们终于可以编写实际启动游戏的代码了。

出于本示例的目的,让我们创建一个 ExampleGame 类。我们将使用这个类来保存游戏的状态以及已加载的 ExampleGameConfig。不过现在,我们只需要创建我们在 GameType 中引用的 open 函数。

它应该看起来像:

public class ExampleGame {
    public static GameOpenProcedure open(GameOpenContext<ExampleGameConfig> context) {
        // get our config that got loaded by Plasmid
        ExampleGameConfig config = context.config();

        // create a very simple map with a stone block at (0; 64; 0)
        MapTemplate template = MapTemplate.createEmpty();
        template.setBlockState(new BlockPos(0, 64, 0), Blocks.STONE.getDefaultState());

        // create a chunk generator that will generate from this template that we just created
        TemplateChunkGenerator generator = new TemplateChunkGenerator(context.server(), template);

        // set up how the world that this minigame will take place in should be constructed
        RuntimeWorldConfig worldConfig = new RuntimeWorldConfig()
                .setGenerator(generator)
                .setTimeOfDay(6000);

        return context.openWithWorld(worldConfig, (activity, world) -> {
            // to be implemented
        });
    }
}

这里有很多要分解的内容,但如果我们把它拆开来看,也不算太复杂。我们的 open 将在玩家开始这个游戏时被调用。该函数接受一个 GameOpenContext,它持有来自我们 JSON 配置的数据(context.config()),并且必须返回一个 GameOpenProcedure,它指示 Plasmid 应如何继续设置游戏。值得注意的是这个函数在线程池上异步运行,因此在游戏开始前在这里运行任何慢速代码都是安全的。

GameOpenProcedure 通过 GameOpenContext.openWithWorld 函数创建,它接受一个 RuntimeWorldConfig 以及一个接受 GameActivity 和 ServerWorld 的 lambda。运行时世界是 Plasmid 中的一个概念,代表游戏所在的完全隔离且临时的世界。当游戏结束时,它会自动删除。当玩家加入游戏时,他们的物品栏将被清空,当他们离开时,将被恢复。游戏活动是在游戏中运行的特定逻辑集:这就是我们将配置以更改游戏行为的内容。我们可以在任何时候切换游戏中的活动。

RuntimeWorldConfig 描述了这个世界应如何创建。这里最重要要配置的是区块生成器:它告诉游戏世界应如何生成。例如,可以在这里传递主世界区块生成器,但出于我们的目的,我们正在创建一个空世界,其中只有一个石头方块。这是通过方便的 TemplateChunkGenerator 处理的:它接受一个 MapTemplate,它只是一个包含一些方块的非常基础的世界!然后生成器从中加载到世界本身。

最后,我们需要处理 lambda 中 GameActivity 参数要做的事情。此 lambda 中的代码将在主服务器线程上运行,用于运行实际的游戏设置代码。这主要涉及注册事件监听器或设置全局规则。

事件提示:我们利用 Stimuli 来处理游戏中的许多事件,因此那里的任何事件都可以在 Plasmid 中使用。

例如:

return context.openWithWorld(worldConfig, (activity, world) -> {
    activity.deny(GameRuleType.FALL_DAMAGE);

    activity.listen(GamePlayerEvents.ADD, player -> {
        // a player has been added!
    });
});

此代码将为所有玩家禁用摔落伤害,并注册一个事件监听器,每当有玩家添加到这个游戏时都会被调用。

但是!在我们为我们出色的示例游戏提供功能之前,我们需要响应玩家 offer 事件监听器。这在任何玩家加入游戏之前被调用,能够接受或拒绝该加入请求。最关键的是,监听器定义了玩家应如何以及在何处生成到我们的游戏世界中。

一个 offer 监听器的示例可能看起来像:

activity.listen(GamePlayerEvents.OFFER, offer -> {
    ServerPlayerEntity player = offer.player();
    return offer.accept(world, new Vec3d(0.0, 64.0, 0.0))
            .and(() -> {
                player.changeGameMode(GameMode.ADVENTURE);
            });
});

这很多!让我们分解一下:

  • 我们为 GamePlayerEvents.OFFER 注册一个监听器,它接受一个 offer 参数。
  • 我们从 offer 中获取尝试加入的玩家实例。
  • 我们调用 offer.accept(...) 以接受玩家进入游戏。
    • 我们向接受函数传递一个世界和一个位置,以便玩家被传送到那里。世界是由 Plasmid 在上面的代码中传递给我们的!
  • 然后我们在 .accept(...) 的结果上调用 .and(...),以附加一些额外的生成逻辑,在玩家加入时运行。在这种情况下,就是在玩家加入时将其游戏模式设置为冒险模式。

现在我们设置好了这个,我们可以回到我们的玩家添加监听器:截至目前,我们在它被调用时没有做任何事情。我们希望它在玩家加入时向玩家发送问候。让我们实现它:

GameSpace gameSpace = activity.getGameSpace();
activity.listen(GamePlayerEvents.ADD, player -> {
    LiteralText message = new LiteralText(config.greeting);
    gameSpace.getPlayers().sendMessage(message);
});

所以我们在监听器中添加了发送消息的逻辑,但什么是 GameSpace?GameSpace 是 Plasmid 引入的一个概念,顾名思义,代表游戏发生的_空间_。对于我们的所有目的而言,该空间就是游戏正在其中进行的这一个维度。GameSpace 对我们很有用,因为它跟踪其中的所有玩家,以及游戏所在的 ServerWorld。在这里,我们通过 GameActivity.getGameSpace() 访问 GameSpace。

与玩家协作还通过另一个 Plasmid API:PlayerSet。PlayerSet 仅代表一个玩家列表,它可以被迭代或查询,但还提供了对许多玩家执行批量操作的实用程序。例如,发送消息!在这里,我们使用 PlayerSet.sendMessage() 将我们的问候发送给游戏中的每个玩家。

嗒哒!🎉 我们有一个可以运行的游戏了!但在我们测试之前,让我们做一些小的重组。有了所有这些处理程序和 lambda,我们在 createOpenProcedure 中的代码很快就会变得相当冗长!如果我们能把所有事件监听器放在我们的 ExampleGame 对象上就好了。

事实证明,这完全可行,我们最终得到我们的 ExampleGame 设置:

public final class ExampleGame {
    private final ExampleGameConfig config;
    private final GameSpace gameSpace;
    private final ServerWorld world;

    public ExampleGame(ExampleGameConfig config, GameSpace gameSpace, ServerWorld world) {
        this.config = config;
        this.gameSpace = gameSpace;
        this.world = world;
    }

    public static GameOpenProcedure open(GameOpenContext<ExampleGameConfig> context) {
        // get our config that got loaded by Plasmid
        ExampleGameConfig config = context.config();

        // create a very simple map with a stone block at (0; 64; 0)
        MapTemplate template = MapTemplate.createEmpty();
        template.setBlockState(new BlockPos(0, 64, 0), Blocks.STONE.getDefaultState());

        // create a chunk generator that will generate from this template that we just created
        TemplateChunkGenerator generator = new TemplateChunkGenerator(context.server(), template);

        // set up how the world that this minigame will take place in should be constructed
        RuntimeWorldConfig worldConfig = new RuntimeWorldConfig()
                .setGenerator(generator)
                .setTimeOfDay(6000);

        return context.openWithWorld(worldConfig, (activity, world) -> {
            ExampleGame game = new ExampleGame(config, activity.getGameSpace(), world);

            activity.deny(GameRuleType.FALL_DAMAGE);
            activity.listen(GamePlayerEvents.OFFER, game::onPlayerOffer);
            activity.listen(GamePlayerEvents.ADD, game::onPlayerAdd);
        });
    }

    private PlayerOfferResult onPlayerOffer(PlayerOffer offer) {
        ServerPlayerEntity player = offer.player();
        return offer.accept(this.world, new Vec3d(0.0, 64.0, 0.0))
                .and(() -> {
                    player.changeGameMode(GameMode.ADVENTURE);
                });
    }

    private void onPlayerAdd(ServerPlayerEntity player) {
        LiteralText message = new LiteralText(this.config.greeting);
        this.gameSpace.getPlayers().sendMessage(message);
    }
}

测试游戏!

一旦一切编译通过,我们终于可以启动 Minecraft 了。如果我们的 GameType 完全正确设置并且游戏配置 JSON 就位,那么打开一个世界后,我们应该能够通过运行:/game open <id> 来启动我们的游戏。(记住,这引用的是 JSON 文件的名称,而不是 GameType!)

所以在我们的例子中:/game open plasmid_example:hello_world_example ...我们应该加入到我们的虚空世界中,有一个石头方块和一句可爱的问候!

现在,任何其他玩家也可以通过运行 /game join 或点击聊天中出现的链接来加入我们。

就是这样!🎉