-
Notifications
You must be signed in to change notification settings - Fork 6
Tutorial
This is the official tutorial for developing minigames with the Flint engine. Please note that while extensive, this tutorial is not exhaustive, and should be used alongside the official documentation of the API.
Flint is a high-level, platform-agnostic engine designed to ease the task of developing minigames for Minecraft. It assumes responsibility for several generic minigame systems, such as arena, round, and player management, greatly reducing code redundancy by removing the need for inclusion of this generic code in every minigame plugin written.
The basic doctrine of Flint is that it should provide as much flexibility as possible to projects utilizing it without compromising functionality. Therefore, non-configurable components or features should be avoided at all costs. Branching off from this is an avoidance of over-abstraction: the API is designed to be cleaner and more pleasant to use than the moving parts inside, but it is not meant to be a black box, and therefore does not abstract to the point that underlying mechanisms are difficult to understand. Furthermore, it distinguishes itself as an engine as opposed to a framework by serving more as a collection of utilities than a plugin within itself.
Before you can use Flint, you'll need to add it as a dependency. If you're using Maven or Gradle, add http://repo.caseif.net/content/groups/public/ to your build file as a repository, and net.caseif.flint:flint:1.3.2 as an artifact.
Before jumping into the rest of the tutorial, here are a few objects defined by the API which you should be familiar with first.
A LifecycleStage is an immutable object relating to the timing system. Each Round is created with an ordered set of LifecycleStages, and will initially be set to the first value within it. Each stage defines an ID (used for reference) and an integer representing the duration it lasts for before progressing to the next stage (although stages may be infinite by defining a duration of -1). Upon the duration being reached or being so instructed by its owning plugin, a Round will progress to the next defined stage. If the current stage is the last defined, the Round will be ended by the engine.
The Location3D object represents a 3-dimensional point, optionally with an associated world name. This will generally be an analog to the platform-native location object.
The Boundary object represents a cuboid and consists of two Location3D objects situated at the extreme corners of the region on all three axes. Boundaries are an essential part of an Arena, and are used both to contain players and to track actions which will be rolled back upon a Round within it ending.
Before you get started with creating your game, you'll want to first check if the version of the engine you're running against is compatible with your plugin. Presumably your plugin has a minimum version it requires, which may be validated with the FlintCore#getApiRevision method. This method will return an int defining the current feature level of the engine. Generally this is incremented with each minor or major revision. As of version 1.3.2, the API revision is 5. If you build against the latest version, your check should resemble the following:
if (FlintCore.getApiRevision() < 5) {
throw new RuntimeException("Outdated Flint version!");
}You'll also want to check that the current major revision is compatible. Major revisions may introduce non-backwards-compatible (breaking) changes, so you'll generally only want to depend on a single value for this check. If you build against Flint 1.3.2 (major revision 1), your check should resemble the following:
if (FlintCore.getMajorVersion() != 1) {
throw new RuntimeException("Incompatible Flint!");
}The table below contains all released Flint versions along with their respective API revisions and major versions.
| Version | API Revision | Major Version |
|---|---|---|
| 1.0 | 1 | 1 |
| 1.1 | 2 | 1 |
| 1.2 | 3 | 1 |
| 1.3 | 4 | 1 |
| 1.3.1 | 5 | 1 |
| 1.3.2† | 5 | 1 |
† Flint v1.3.2 includes an API change but is binary-compatible with v1.3.1, so an API revision bump was not necessary.
The first thing you'll need to do is create your core Minigame object. Each minigame plugin is associated with a single Minigame, which grants control over all public aspects of the engine. This may be done through the FlintCore class, which serves to provide methods relevant to the engine as a whole rather than a single minigame.
Minigame minigame = FlintCore.registerPlugin(myPluginName);This will provide you with a central Minigame object for controlling your minigame, which will be used throughout the tutorial. (Note: myPluginName must represent the name of your plugin as registered with the server.)
As described in the Objects section, minigames may be configured through the use of ConfigNodes. To assign a value to a node, call Minigame#setConfigValue(ConfigNode.SOME_NODE, someValue) (where SOME_NODE is the name of the node and someValue is an object or primitive you wish to assign). A full enumeration of available config nodes with their respective descriptions may be found in the documentation.
As of Flint 1.3.2, the only required node is DEFAULT_LIFECYCLE_STAGES. This will define the default LifecycleStages to assign to a round upon creation and will later allow you to create a Round in an Arena without providing any additional parameters. To set this value, model your code off the following:
Minigame minigame = ...; // initialized elsewhere
// create the stages
// note: it is recommended you store your LifecycleStages as global variables so they may be referenced later
final LifecycleStage stage1 = new LifecycleStage("first", 60); // called first with a length of 60 seconds
final LifecycleStage stage2 = new LifecycleStage("second", 120); // called second with a length of 120 seconds
// build the ImmutableSet
ImmutableSet<LifecycleStage> stages = ImmutableSet.<LifecycleStage>builder().add(stage1).add(stage2).build();
// assign the config value (assuming minigame has been defined previously)
minigame.setConfigValue(ConfigNode.DEFAULT_LIFECYCLE_STAGES, stages);In certain cases, the builder model may be used to construct objects. The builder model is essentially a pattern in which a "builder" object is used to customize the exact parameters for object instantiation, after which a build() method may be called to construct the actual object (think of StringBuilder).
To obtain a Builder for a given type such as Arena:
Minigame minigame = ...; // initialized elsewhere
Arena.Builder arenaBuilder = (Arena.Builder) minigame.createBuilder(Arena.class);Once the builder has been configured, the build() method may be called to construct a new Arena object:
Arena arena = arenaBuilder.build();Currently, the following classes may be built using this model:
Next, let's focus on creating arenas. Arenas are necessary for minigames to function, as every Round is contained by one. To create an arena, consider again the builder model described above in conjunction with the following code:
Minigame minigame = ...; // initialized elsewhere
Arena.Builder arenaBuilder = (Arena.Builder) minigame.createBuilder(Arena.class);
String id = "my-first-arena";
String displayName = "My First Arena";
Location3D[] spawnPoints = ...; // initialized elsewhere
Boundary boundary = ...; // also initialized elsewhere
Arena arena = arenaBuilder.id(id).displayName(displayName).spawnPoints(spawnPoints).boundary(boundary).build();(The variables containing the variables are created separately for the purposes of this example, but of course they could be inlined just as easily.)
This will create a new arena for use with the plugin with the given parameters. Let's run through them:
id — The internal ID of the new arena. Used within the library and with the lobby sign wizard. Note that this will be converted to lowercase by the engine.
displayName — The "friendly" name of the arena as displayed to players.
spawnPoints — The spawn points of the arena as Location3Ds. This would presumably be defined through player input or location. The array may not be empty, at least one element must define a world, and no two elements may define different worlds. They serve as the initial spawn points in the new arena (until you add more, that is).
boundary — The boundary of the arena. The boundary is used both in containing players (who will be teleported back inside by default upon breaching it), and by the rollback engine to determine which actions to log for rollback.
Optionally, the displayName parameter may be omitted — in this case, the ID will be used as the display name in its original case (i.e. if the id parameter is "Something" (mixed-case), the internal ID will be stored as "something" (lowercase) while the display name will be stored as "Something" (original case)).
Once you have an Arena object, you can use it to create a round. To do this, simply call Arena#createRound(), or alternatively, Arena#getOrCreateRound() if you cannot or prefer not to confirm that it doesn't already exist. (Optionally, you may provide an ImmutableSet of LifecycleStages to use for this particular round, but without this parameter, the new round will default to the config value set previously.) This call will return a new Round object containing state information, which you may use to manipulate the activity of the arena.
The round's timer will be active by default; however, you may stop it if you wish by calling Round#setTimerTicking(false). It will also default to the first defined LifecycleStage. You may set it to a particular stage by calling Round#setLifecycleStage(LifecycleStage) (or Round#setLifecycleStage(LifecycleStage, true) if you wish to reset the timer as well), or advance it to the next defined stage by calling Round#nextLifecycleStage(). When you want the round to end and be deinitialized, you may call Round#end() (optionally, calling Round#end(false) will cause the engine to refrain from rolling back the Round.
Now that you've got your Round object, it's time to add some players to it. Let's say you have a player UUID, obtained through a command or world event. You can add this player to the round by calling Round#addChallenger(UUID). (Please note that this method will only accept UUIDs, as this is currently the standard mode of identification for players in Minecraft.)
The method will return a JoinResult object containing information regarding the outcome of the invocation. If JoinResult#getStatus() returns JoinResult.SUCCESS, the new Challenger may be obtained via the JoinResult#getChallenger() method. Otherwise, this method will throw an IllegalStateException. If the status is Status.INTERNAL_ERROR, the Throwable responsible for the failure status may be obtained via Arena#getThrowable() (this too will throw an IllegalStateException if the status is not appropriate). Any other status denotes a specific issue which prevents the player from being added (e.g. the round being full, the player being offline).
Additionally, the player will have their inventory stored to disk and cleared ("pushed") and be teleported into the arena.
Each Challenger object is associated with the UUID of a single player in the world. Provided the object is not orphaned (discussed later in the tutorial), this player will be guaranteed to be online.
Now, let's say you have a player UUID, but you're not sure if the player is in a round or not. If you have a Minigame object, you can use it to obtain a Challenger object from a UUID. Calling Minigame#getChallenger(UUID) will return an Optional<Challenger> object. If calling isPresent() on this object returns true, the player with the UUID is in a round, and you can obtain the respective Challenger object by calling get() on the Optional.
Flint includes a team API in its specification. Team objects are associated with rounds and may contain a number of challengers within their respective rounds. This API is designed primarily for the convenience of those using Flint; however, implementations do include mechanisms for blocking combat among team members (i.e. "friendly fire") (enabled by default) and per-team chat channels (disabled by default), which may be toggled via respective ConfigNodes.
The Round interface contains several methods for creating and getting teams. The Challenger interface contains the getTeam() and setTeam(Team) methods, used to get its current Team. The Team interface contains methods for adding and removing challengers from it. Please see the official API documentation for more information.
Flint contains a metadata API, used to attach arbitrary data to Flint objects. If an object extends the MetadataHolder interface, it may be used to obtain Metadata object, which may then be used to assign data. Consider the following code:
Round round = ...; // assuming this is initialized previously
Metadata metadata = round.getMetadata(); // get the Metadata object attached to the Round
if (metadata.containsKey("someKey")) { // check whether a certain key is defined
// do something
}
String stringData = metadata.<String>get("stringData"); // get a String attached with the key "stringData"
metadata.set("newKey", "someValue"); // attach new data with the key "newKey"
Metadata someStructure = metadata.createStructure("aStructure"); // create a child Metadata object
someStructure.set("someSubValue", "aValue"); // attach data to the child MetadataCertain Flint objects (as of version 1.3.2, limited to Arenas) may be assigned persistent metadata which it will retain across server reloads or restarts. This is stored within a PersistentMetadata object, which is parented by the base Metadata interface. Consider the following code:
Arena arena = ...; // assuming this is initialized previously
PersistentMetadata metadata = arena.getPersistentMetadata(); // get the persistent metadata
MyObject.Serializer serializer = MyObject.getSerializer();
metadata.<MyObject>get("someKey", serializer); // get a value and deserialize it with serializer
metadata.set("someOtherKey", myObject, serializer); // set a value and serialize it with serializerBecause PersistentMetadata only support primitive and string values, Serializers must be used to serialize arbitrary objects to Strings. MyObject might look like this:
public class MyObject {
private static final Serializer SERIALIZER = new MySerializer();
private String someString;
private int someInt;
public MyObject(String str, int i) {
this.someString = str;
this.someInt = i;
}
// some getters and setters
public MySerializer getSerializer() {
return SERIALIZER;
}
static class MySerializer implements Serializer<MyObject> {
private MySerializer() {}
@Override
public String serialize(MyObject obj) {
return obj.someString + ";" + obj.someInt;
}
@Override
public MyObject deserialize(String serial) {
// serial should be validated first (not included in this example)
String str = serial.split(";")[0];
int i = Integer.parseInt(serial.split(";")[1]);
return new MyObject(str, i);
}
}
}Flint includes an API for lobby signs. Lobby signs are physical signs in the world which display select information about an arena. They may be created automatically by players via the lobby wizard, initiated by creating a sign with a certain sequence for the first line. This sequence is equal to the title of the minigame they wish to create a lobby sign for in square brackets. For instance, if the minigame is called "Spleef", the wizard would be initiated by the line "[Spleef]".
However, they may also be created manually by minigame plugins via methods in Arena. These methods are well-documented and may be viewed in the official API documentation for the Arena interface. Additionally, LobbySign, an interface representing a single lobby sign in the world, and its subinterfaces may be viewed in the documentation as well.
For further customization, a populator API is available. This API allows minigames to configure exactly what is displayed on a given sign by implementing code to compute such.
To create a populator, either create a class which implements LobbySignPopulator, or use the builder model
Flint includes an automatic rollback engine, which tracks all physical changes to blocks and select entities (item frames and armor stands) and their respective states.
An Arena will by default be rolled back automatically upon its Round ending (this behavior may be changed via a ConfigNode). Alternatively, an Arena may also be manually rolled back to its state prior to its current Round being creating via the Arena#rollback() method.
Additionally, a block may be manually specified for rollback via the Arena#markForRollback(Location3D) method. This will flag the block at the provided location to be rolled back the next time a rollback is invoked.
Flint includes an event system for the purpose of granting minigame plugins control over actions which take place within them. Consider the following listener method:
@Subscribe
public void onChallengerJoinRoundEvent(ChallengerJoinRoundEvent event) {
Challenger challenger = event.getChallenger();
// do something with the challenger
}This listener class would be registered when the minigame is initialized. If it is contained by a class called MinigameListener, it would be registered via this code:
minigame.getEventBus().register(new MinigameListener());(Note: for technical and historical reasons, Flint uses its own independent event bus as opposed to integrating with the respective platform's.)
Events are considered immutable (except for ChallengerLeaveRoundEvent, for which the return point may be set) and are called after their respective action has taken place (with an exception again for ChallengerLeaveRoundEvent, which waits to teleport the player until after the event has been posted).
All event classes defined by the API may be found in the official API documentation.
Flint is very hierarchical in nature, in that most objects have a parent of some sort. Because of this, it introduces the concept of logical orphans, objects which have been removed from their parents (e.g. a challenger being removed from a round) or whose parents have become obsolete (e.g. a round containing challengers ending). When an object becomes logically orphaned, a special flag is set which will cause all of its methods to throw an OrphanedComponentException upon invocation. The purpose of this is to safeguard against minigames storing and using references to obsolete objects, and therefore will (ideally) never be invoked in any other case.
This concludes the Flint tutorial. Please note that this tutorial is not exhaustive, and does not describe every aspect of the API. For more information on how to use Flint, please visit the official API documentation, as it contains a detailed description of every defined public class and method.
If you have suggestions for how the tutorial could be improved, please contact me at me@caseif.net. If you have a suggestion for how the API could be better, please create an issue or a pull request in the GitHub repository.
Thanks for reading! I've put a lot of time into this project, so I hope you enjoy using it as much as I did creating it.