-
Notifications
You must be signed in to change notification settings - Fork 0
Outline number 2
Data attributes are the actual stored data and are values of any type (primitive or Object). The interesting part about these values is that they are at their core Observable. This means that you can listen to them change or use them in different kinds of bindings, which enables you to fluently build up complex strings and expressions which will dynamically change, when the underlying data changes. It also allows running code when the value changes, for example in order to do something on every kill. The game and player state will be mostly made up out of these observable attributes, enabling you to compose simpler or more complex player or game statistics, like the total amount of kills a team has, in a straightforward way.
The listeners are not serialized, but the wrapped values are. This allows you to store these attributes via the actual storage system.
Observables are a helpful little wrapper around properties that allows you to observe when changes are made. This allows for Reactive programming, and decoupling of the model from the view.
- Observables of Observables bind the child Observable to the parent Observable so that chained Observables propogate their changes upwards.
- Observable implementations of Collections wrap around Lists, Maps, etc, causing updates whenever values are removed or added as well as when nested Observables in the collection update.
- Observable collections also have custom Observable properties such as
size
- Observable collections provide
.filter
and.map
methods, which create a new Observable collection that wraps around the original, filtering and mapping the updates accordingly. Filtering and mapping operations wrap rather than mutate the original Observable collection, resulting in an Observable collection View, which mirrors the original and then adds some processing layers on top.
The storage system will be a simple YAML/JSON flatfile system. To do this, objects are serialized down to primitive types (including) String via an automated process (using reflection) or via custom registered (de)serializers. This primitive data will then be passed to the storage engine which writes it to disk. A simple single-file and a folder (one file per identifier/player) storage engine will be provided, additional ones (like SQL) might be explored later on.
Player data just consists of Data attributes and is stored on a player object or a wrapper around it.
The player data can be persisted via the Storage system, if it is not limited to the current game.
Same as Player data.
The raw world data will be stored as a schematic.
Same as Arena storage
In order to create an arena you need to define different properties:
- What parts are "death" zones, which kill you when you enter them (outside the arena or the floor)
- Where do players actually spawn?
- What is the Identifier of the arena?
- What is the displayed name of the arena?
- What is the actual arena region?
- How many players can it house? How many have to be there at minimum to make the arena usable?
- What is the lobby identifier and, equally important, where is the lobby positioned relative to the arena's origin, when both are spawned in?
As you can see, there are quite a few things that make up an arena — and the list isn't even complete:
Each minigame might need additional data to function correctly.
We have already discussed how this data will be stored (Data attributes), but how do you create the commands you need to create an arena?
The answer we chose is to introduce another type of attributes. These attributes can be queried like normal data attributes (and are likely stored as such on the finished arena, albeit with a class name pointing to them), but they also provide an Input supplier.
This supplier takes care of allowing the user to set set the attribute.
It is invoked via a generic /arena set <attribute name>
command, which fetches the value from the supplier and applies it to the arena.
Arenas that are not yet completely build, i.e. have attributes missing, will not be usable for anything besides completing them.
This structure allows any minigame to provide a unique and custom blueprint, made up by the attributes, for their arenas. The creation command will respect that blueprint and dynamically adjust the question or commands it requires and prompts the user for, to fully initialize each provided arena.
An input supplier is a very vague concept. It is an object that fetches some value (probably typed via generics) and returns it either via a CompletableFuture or a callback to the caller. This allows for a generic "attribute set" command, which does roughly the following:
attribute.getInputSupplier().fetchInput(player, value -> arena.setAttribute(attribute, value))
(or an equivalent solution using CompletableFutures).
How it does this is up to the object, though an easy solution to listen to events or chat input once should be explored, so that composing the result in a fluid and easy way becomes possible.
Maybe CompletableFuture<EventClass> EventUtils.listenOnce(EventClass)
or similiar functions could fulfill that requirement.
The lobby functions basically in the same way and will have:
- An Identifier
- A region encompassing it
- A player spawn point within it
An identifier is a unique human-readable string that unambiguously identifies some structure/object.
A match is essentially an instance of the minigame running in some arena. It is in charge of letting players join, leave and get them to an arena and will also exist for the whole duration of the game.
They have a fixed size, i.e. can only house a certain range of players and using this information the match can pick out an appropriate arena.
The match also provides commands to:
- Start a new match
- Let players join and leave
- Stop the match
As each minigame differs in when a player can join, the match will hold a joinable
predicate, that will decide whether a player can join or is rejected.
This predicate is provided by the Mode.
The actual time spent in the lobby is a normal Phase. Games can choose to add it or leave it out, but if they leave it out, the join predicate might need to be adjusted. The phase will pick the lobby for the arena it is in and transport players to it. TODO: Let the match do this?
The game ends when all players leave, the server is stopped or a phase has normally terminated the game (likely because some somebody won the game).
A match picks and handles the current Mode, which is essentially an abstraction for all mechanics and functions of the minigame.
The match also stores the original position of the player, before he was teleported to the match, in a player attribute. This can then be used to teleport him back when the game ends.
A spawn point is a point in an arena relative to its origin where a player is teleported to at the start of a match. Not only do spawn points have a relative location, they also have an optional Team attribute which defines which team the point is for. The system then teleports a player to a spawn point based on their team.
Teleporting players to spawnpoints is all handled by a premade mechanic which determines where to route each player via a Spawn Strategy.
This strategy has the form of (List<GamePlayer>, List<Spawn>) -> Map<GamePlayer, Spawn>
.
This allows for easy custom spawn strategies. Premade strategies will include FFA and TEAMS.
A team is probably self explanatory. It has the following attributes:
-
id (String)
- The ID of the team (e.g. red, blue, green, humans, zombies, etc.) -
membersTotal (Int)
- The # of players on this team -
membersAlive (Int)
- The # of currently alive players on this team
A mode determines the gameplay for a given match. To do this it holds and controls:
- All registered phases, including their End criteria and Triggers
- All global (phase agnostic) Mechanics
The mode is picked by the match, which means that you can create different flavours of your game, for example a "normal" and a "hardcore" mode, a "PvP" or "PvE" mode, etc.
The mode also has an identifier that is used to refer to it on user-facing commands.
Mode mode = new Mode(identifier);
mode.addPhase(<phase>);
mode.setJoinablePredicate((player, match) -> false)
Mechanics form the core of a minigame, they are the objects that actually make something strange or crazy happen.
The idea of them stems from the observation, that many smaller pieces of logic stay consistent throughout different games:
Double jumping or flying, dieing in one hit or having exceptionally many lives, respawning before you die and other, well, mechanics are very common.
In order to not be forced to repeat all of that and maybe write long and complex listeners and event handlers, each of those fundamental parts can be encapsulated as a mechanic.
Mechanics take information and triggers they need as constructor parameters and can therefore limit what kind of triggers they accept.
A mechanic that needs to cancel a projectile launch event will only take Trigger<? extends ProjectileLaunchEvent>
, for example.
As an additional help, each mechanic takes a predicate that decides whether it should affect a certain player.
This allows you to, for example, turn off cosmetic items for some, turn them on for others.
A more interesting use for this are classes.
If you give each player an attribute denoting his class and check for that in the mechanics, you can create different RPG classes with ease.
All mechanics, from shooting fireballs to a stronger dash or sneak attack can be active at the same time — and yet only the correct players can actually use them.
Due to this no additional handling for classes and other similar concepts should be necessary.
After all of this a question remains: How do these things actually get triggered?
How do they decide when to act and modify things?
The answer is a Trigger.
Triggers are essentially just event listeners or repeatedly invoked runnables ("Tickers") which execute the mechanic when something happens in the world or on a repeated schedule, but they could be anything.
Another thing they can subscribe to are attribute changes.
Often you do not just need to know something fired, but you will actually need to get some data out of it.
An example for this could be a mechanic that needs to cancel an event to make its custom vbehaviour work.
To allow this, triggers are generic and produce a value: Trigger<T>
.
The result is then passed to the mechanic or whatever else they triggered.
More than one Trigger can be added to a mechanic to allow it to be triggered by multiple things.
This is handled by the mechanic's constructor, which just can just take multiple triggers.
You could do it in one, but focusing each trigger on one thing helps keep the triggers small and the code more readable.
In addition to that you can compose triggers, if the value they produce is compatible.
This means that you could compose a Trigger<PlayerEvent>
and another Trigger<PlayerEvent>
and maybe even Trigger<PlayerMoveEvent>
to a single one, just by using and
or or
logical operations.
Many minigames are made up out of different phases. If you take "Hunger games" as an example, you have roughly three different states:
- Running around the map
- Fighting
- Deathmatch
Each of these phases may require a completely changed gameplay and therefore a different set of Mechanics. You could use the predicate mechanism to solve this, but it would get quite unwieldy. To combat this issue the concept of Phases was introduced. Each phase contains its own Triggers and Mechanics, as well as something that can make them transition between each other: One or more End criteria.
Phases will have two basic lifecycle calls:
This method is called when the phase takes control of the mode. It can register its mechanics and end criteria here or do any other kind of preparation.
This method is called when the phases loses control over the mode and is replaced by another. You can clear up after yourself here.
If you have different Phases you need something to actually initiate the transition. This is where End Critieria come in. They contain two properties, a Trigger and a phase to switch to. As soon as the trigger fires, the end criteria moves on to the target phase. As that target can be anything, complex transitions and loops are possible and by using different end criterias with different triggers you can create a conditional phase transition. The conditional phase conditions could just initiate a death match when only a few players are left, but if a certain point threshold is reached death match will be run earlier. Or you change the entire next type of the game (as a phase controls most of that) depending on some condition, e.g. move to a bow minigame when most kills were done with a bow.
They could be added like so:
phase.addEndCriteria(new EndCriteria(Trigger, NewPhase));
Scoreboards are used to display information relevant to the current Match or global statistics. If any Phase needs additional information to be displayed in a scoreboard it can ask the match to do so.
Scoreboards should take advantage of the observable nature of Attributes to automatically update themselves when the attached attributes change.
Quite a few parts of the system require user commands, so a command system is quite useful here. The general idea and capabilities are defined by a few core design ideas:
The command system is kept in the style of tree with an unlimited number of children. Each top-level child in that tree represents a command and children of commands represent their subcommands. This allows easily moving each subcommand to their own file, if they are complex enough.
As an additional bonus the tree style systems allows the following:
- Effective dispatch to subcommands
- Automatically generated usage and help pages for each command. They are registered as children with the keyword "help" of the node they belong to.
- Automatically generated global help pages encompassing all commands and letting you search and filter your way around them.
Parsing command arguments can be quite cumbersome, so this is meant to address it. Instead of just getting the string arguments you get a context object. Using this you can simply pop objects off like this:
Player player = context.shiftPlayer();
If something goes wrong, either let it crash or throw a custom exception. This allows each of the context methods to only return something if they succeed. In case of an error, the code calling your method will catch the exception, map it to an error message and display it to the user in a controlled fashion.
A Match will be created at runtime, if a player wants to join a specific Match mode and no existing match for that mode is running.
Furthermore the match will pick its own arena, based on the amount of players it should house.
A Match mode is a combination of a Mode and the maximum amount of players.
It is used to identify a type of game (like "survival games in a round of five") and will be used to find an existing match and, if none exist, what type of match to create.
In order to find a free match it goes through the list of running matches for this mode and checks their joinable
predicate.
Match modes will be set up by the server owner via set of commands and then be referred to by players via their unique Identifier. An example dummy command could look like:
/newMatch <match identifier> <mode identifier> <max players>
/joinMatch <match identifier>
End Critieria:
If this is only a Trigger, where do you store the phase to switch to? Just phase.addEndCriteria(trigger, nextPhase)
?