NucleoidMC / plasmid

Server-side minigame development library based on Fabric.
GNU Lesser General Public License v3.0
88 stars 31 forks source link
hacktoberfest minecraft minigame-api

Plasmid

Plasmid is a library for creating server-side minigames with Fabric. Plasmid does all the boring work relating to minigame implementation, to rather allow focus effort on just the game itself.

Plasmid is the core of the Nucleoid Project, an effort to build an open source ecosystem for server-side Minecraft minigames. You can view many examples of games implemented with Plasmid on the Nucleoid GitHub Organisation. You may even be interested in playing some of them over on our testing Minecraft server at nucleoid.xyz! You can also find us on our Discord if you have any troubles or queries, or would like to get involved.

Using

This is a mirror of our Getting Started wiki page and provides a basic introduction to Plasmid concepts. You can view our wiki site for more detailed information.

If you would like to get up and running quickly with a basic game setup, clone the plasmid-starter repository, run init.py, and then delete .git, README.md, and init.py. Alternatively, if you are looking for examples of existing implemented games, take a look through the Nucleoid Organisation repositories.

Adding to Gradle

Assuming you already have a Fabric workspace set up, the first step to setting up Plasmid will be adding it to your gradle buildscript. You will need to add the maven repository as well as the plasmid dependency. PLASMID_VERSION should be replaced with the latest version from Maven.

This tutorial is currently updated for Plasmid 0.5.x.

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

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

Creating a game type

A "game type" (GameType) is the entry-point to creating a game with Plasmid: they provide a unique identifier for your game, as well as all the information needed for it to be able to call your code when the game starts.

Plasmid is designed to encourage data-driven games, and works with the concept of a "game config". A game config is essentially a specific variation of a game type! This may involve a different map to play on, or entirely different game mechanics. A game config is simply defined as a JSON file in a datapack that references your GameType and passes along any extra data that may be useful for configuring your game. While this may be a bit more work at first, it is very powerful in allowing games to be much easier to tweak or produce multiple variations of without duplicating code. More on configs later!

To register a GameType, you will need to call GameType.register() in your ModInitializer class. A call to register a GameType may look something like:

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

Let's break down what is going on here:

This naturally will not compile yet: neither ExampleGame nor ExampleGameConfig exist! Let's get to that.

Creating our config in code

First we will create our ExampleGameConfig class, which will hold a String field that will be used as a message to send to the player when they join. Java's new Records are perfect for configs, but not required!

public record ExampleGameConfig(String greeting) {
}

That's simple enough! But we're missing the CODEC field that we referenced earlier. What is that about?

A Codec is a very helpful tool implemented by Mojang's DataFixerUpper library that essentially allows for convenient serialization and deserialization of a Java object to a JSON file. A more detailed explanation of Codecs by Drullkus can be found here, but for simple purposes, all you need to know is the pattern for putting them together.

Essentially, a Codec describes how an object is serialized and deserialized. Simply, they can be created from a list of fields and how those fields should be serialized. It goes like this:

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);
    });
}

This will correspond to a JSON file that looks something like:

{
  "greeting": "Hello World!"
}

Most things here you can ignore: you only really need to worry about what's in the instance.group(...) call, and the generic on the Codec. To look at each relevant part more specifically:

The end result of all this Codec work is that when we create a game config, all this data will be automatically parsed from our JSON file and passed to our game code!

Creating a config

Now that we know what data our config should hold, we can create an actual game config JSON for Plasmid to load.

All game configs need to be located in your mod resources (or datapack!) at data/<namespace>/games/<id>.json. For the purpose of a mod, the namespace should just be your mod id, and the id can be any unique name that will later be used to reference your game config from inside Minecraft.

Plasmid requires only 1 JSON field from the config, while the rest is loaded as per the config codec that you set up. There are however also some additional optional fields which may be useful to define. The only required field is the type, which refers to the GameType you created earlier in namespace:path format (e.g. in our case, plasmid_example:example).

For our purposes, our game config at data/plasmid_example/games/hello_world_example.json will look like:

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

We can also add some additional builtin fields to our JSON such as a name, short_name, description, and icon. This may look like:

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

name and description can also reference translation keys due to being JSON Text Components. For example, this may instead be: "name": {"translation": "game.plasmid_example.hello_world_example"}.

A note on translations

Translations are a bit non-standard in Plasmid due to it being entirely server-side! Usually translations are stored with the game client, and the server simply sends over translation keys which are then turned into relevant readable text on the client-side. Here, however, we need to instead handle translations by changing the packets that get sent to players such that they are correctly translated before the client even receives it. This is a lot of work! Luckily, this is handled by Server Translations, and we do not need to worry about it!

All this actually means for you is that your language files need to go in the data folder instead of the assets folder (e.g. data/<namespace>/lang/en_us.json).

There are some default language keys we should worry about if we're not manually defining a name: gameType.<namespace>.<id> and game.<namespace>.<id>. These keys are applied for game types and game configs respectively. When resolving the readable name for a game config, both the config translation and type translation will be tested, with the type as a fallback. This means only the game type translation is strictly necessary.

For example, we may define our data/plasmid_example/lang/en_us.json as:

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

Writing the code to start our game

Now that we have set up a config and have told Plasmid how to read from it, we can finally write the code to actually start our game.

For the purpose of this example, let's create an ExampleGame class. We will use this class to hold the state of the game as well as our ExampleGameConfig that got loaded. For now though, we just need to create this open function that we referenced to the GameType.

This should look like:

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
        });
    }
}

There is a lot to unpack here, but it's not too complex if we break it down. Our open will be called whenever a player starts this game. The function takes a GameOpenContext, which holds the data from our JSON config (context.config()), and must return a GameOpenProcedure, which instructs Plasmid how it should continue to set up the game. It is worth nothing that this function is run asynchronously on the thread pool, so it is safe to run whatever slow code here before the game starts.

The GameOpenProcedure is created from the GameOpenContext.openWithWorld function, and takes in a RuntimeWorldConfig as well as a lambda that accepts a GameActivity and ServerWorld. A runtime world is a concept within Plasmid that represents the fully isolated and temporary world that the game takes place within. It is automatically deleted when the game finishes. When a player joins the game, their inventory will be cleared, and when they leave, it will be restored back to them. A game activity is a specific set of logic that is running within a game: this is what we will configure to change game behaviour. We can switch the activity within a game at any point.

The RuntimeWorldConfig describes how this world should be created. The most important thing to be configured within here is the chunk generator: this tells the game how the world should generate. It would be possible to, for example, pass the overworld chunk generator here, but for our purpose, we're creating an empty world with a single stone block. This is handled through the convenience TemplateChunkGenerator: this takes a MapTemplate, which is just a very basic world that contains some blocks! The generator then loads from that into the world itself.

Finally, we need to address what to do in the lambda with the GameActivity parameter. The code inside this lambda will run on the main server thread, and is used to run the actual game setup code. This mainly involves registering event listeners, or setting global rules.

Event tip: we make use of Stimuli for handling many events in games, so any event from there can be used within Plasmid.

For example:

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

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

This code will disable fall damage for all players, as well as registering an event listener that will be called whenever a player is added to this game.

However! Before we give functionality to our brilliant example game, we need to respond to the player offer event listener. This is called before any player joins the game, and is able to accept or reject that join request. Most critically, the listener defines how and where the player should be spawned into our game world.

An example offer listener may look like:

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

That's a lot! Let's break it down:

Now that we have that set up, we can return to our player add listener: as of right now, we're not doing anything when it is called. We want it to send a greeting to the player when they join. Let's implement that:

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

So we've added logic to send a message within the listener, but what is a GameSpace? A GameSpace is a concept introduced by Plasmid which, as the name implies, represents the space within which a game is occurring. For all our purposes, that space is just this one dimension that the game is playing within. The GameSpace is useful for us in that it keeps track of all the players within it, as well as the ServerWorld that the game is taking place within. Here, we access the GameSpace through GameActivity.getGameSpace().

Working with players additionally goes through a different Plasmid API: a PlayerSet. A PlayerSet represents just a list of players, and it can be iterated over or queried, but additionally provides utilities for performing bulk operations over many players. For example, sending a message! Here, we use PlayerSet.sendMessage() to send our greeting to every player within the game.

Tada! šŸŽ‰ We have a working game! But before we test it, let's do some minor reorganization. With all these handlers and lambdas, our code inside createOpenProcedure is going to get quite lengthy very quickly! It would be nice if we can put all event listeners on our ExampleGame object instead.

Turns out, that works just fine, and we are left with our final ExampleGame setup:

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) {
        return offer.accept(this.world, new Vec3d(0.0, 65.0, 0.0))
                .thenRunForEach(player -> {
                    player.changeGameMode(GameMode.ADVENTURE);
                });
    }

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

Testing the game!

Once everything compiles, we can finally launch up Minecraft. If our GameType is all correctly set up and game config JSON in place, once opening a world, we should be able to start our game by running: /game open <id>. (Remember, this is referencing the name of the JSON file and not the GameType!)

So in our case: /game open plasmid_example:hello_world_example ...and we should be joined into our void world with a stone block with a lovely greeting!

Now, any other player can join us too by running /game join or clicking the link that shows up in chat.

That's it! šŸŽ‰