PlateUp uses Unity's ECS (or DOTS) framework. ECS frameworks work by having a set of entities, which have data associated with them in components, and are acted upon by systems. All your game logic should be written in systems.
Each entity will have a number of components attached to it. Each component is a bit like a property of the entity. For example, if the entity represents a player, it will have the CPlayer
component. If an entity has a position in the world, it will have the CPosition
component.
It's important to think purely in terms of components when designing your systems. You should treat all entities only in terms of their components and avoid creating separate lists of entities. For example, in object-oriented programming you might have a List<Player> Players
and loop through that to act on players. In DOTS, you should just query for all entities that have CPlayer
instead.
When adding new functionality, you should figure out which components you want to act on; some of these will be because you want their data (like CPosition
) and some of these will be because you want to act on entities (like CAppliance
). You can then write your system to query for the entities with the components you need and then loop over them to act.
By PlateUp convention, component types are prefaced with
C
and singleton types are prefaced withS
. You don't have to do this but it makes them easier to keep track of.
Currently, PlateUp mods can't use Unity's Entities.ForEach pattern, which is neater but doesn't get processed properly when loading mods. You'll need to use manual queries for the moment as described here.
Entity queries are a key part of any system. You can query for entities that have all, none, or any. You should create the query once in initialisation and then access the entities and components when you run your updates:
public class MySystem : GenericSystemBase, IModSystem {
private EntityQuery EntitiesToActOn;
protected override void Initialise() {
base.Initialise();
EntitiesToActOn = GetEntityQuery(typeof(CAppliance));
}
protected override void OnUpdate() {
using var ents = EntitiesToActOn.ToEntityArray(Allocator.Temp);
foreach(var ent in ents) {
// do something with each entity here
}
}
}
In this example, we create a field to store the query in (EntitiesToActOn
). In initialise, we specify that we're querying for entities that have the CAppliance
component. Then, in OnUpdate
, we run the query to create an array of entities that match the query, which we can then loop through.
(The array created is allocated in memory managed by Unity, and we need to make sure it's disposed once we don't need it any more. The easiest way to do this is to use using
, which handles this for us. If you get warnings about undisposed memory, make sure you're disposing these correctly)
PlateUp has a helper called QueryHelper
designed to make writing queries slightly easier. You can pass a QueryHelper
to GetEntityQuery
, like this:
EntitiesToActOn = GetEntityQuery(new QueryHelper()
.All(typeof(CAppliance), typeof(CPosition))
.None(typeof(CIsOnFire))
.Any(typeof(CMyComponent), typeof(CMyOtherComponent)
);
Within OnUpdate
we frequently want to access the components of the entities we query. The most optimised way to do this is usually to go directly via the query. To do this you must ensure your query includes this component. This is slightly cumbersome:
using var ents = EntitiesToActOn.ToEntityArray(Allocator.Temp);
using var my_components = EntitiesToActOn.ToComponentDataArray<MyComponent>(Allocator.Temp);
for(var i = 0; i < ents.Length; i++) {
var ent = ents[i];
var my_component = my_components[i];
// do something with the entity and component here
}
Data stored globally (within an ECS World) can be written to singletons. An ECS singleton is defined as a component which only appears once. Unity provides some helpers to access singletons, and these have corresponding wrappers. In particular, PlateUp provides:
T GetOrDefault<T>()
to access the singleton or return the default value (instead of throwing an error),T GetOrCreate<T>()
to access the singleton or create it if it doesn't exist (this creates a new entity with only that component)Entity Set<T>(T t)
to set the value of a singleton (creating it if needed).For example, the game has a singleton called SDay
which tracks the current day. If we wanted to advance to the next day, we could write:
var day = GetOrDefault<SDay>();
day.Day += 1;
Set(day);
This safely access the singleton, modifies it, and sets the value back.
In general, you can use the EntityManager
which has a number of methods to read and write components. Unfortunately, this has a slightly overcomplicated interface and will fail if you do things like trying to access components that aren't set, or adding components that already exist. To make it easier, PlateUp has two sets of helpers, one in GenericSystemBase
and one in EntityContext
.
GenericSystemBase
is the base type of all systems in the game. It contains a number of helper functions that wrap the EntityManager
functionality.
You can see the full API through your IDE or by examining the IL. In particular:
bool Require<T>(Entity e, out T component)
This is the most common way to safely access components of an entity in PlateUp. It returns true if the component exists on the entity and sets component
to be the value of that component (if it existed). You can use it like this:
foreach(var ent in ents) {
if(!Require(ent, out CPosition pos)) continue;
// do something with pos here
}
This will skip over any entities that don't have the CPosition
component and access it for any that do.
There's also an equivalent bool Require<T>(Entity e, out DynamicBuffer<T> comp)
for buffers
void Set<T>(Entity e, T component)
This will set a component on an entity. It checks if the entity already has the component and adds it if it isn't already present, so you don't need to. You can use void Set<T>(Entity e)
if you want to set the component with default values.
bool Has<T>(Entity e)
Checks if the entity has the component of type T
. There's also the equivalent bool HasBuffer<T>(Entity e)
PlateUp also has a helper called EntityContext
. This is a wrapper around the multiple ways Unity has of accessing entity data (EntityManager
, EntityCommandBuffer
and EntityCommandBuffer.ParallelWriter
) and provides a fixed interface for all of them. In practice it's more useful for writing code in Burst or parallelised jobs, but it can be helpful when writing abstracted code that wants to access entities.
In the simplest case, you can create a context by passing just the EntityManager
:
var ctx = new EntityContext(EntityManager);
and this context can then be passed to enable the access of entities in the appropriate context. The API for EntityContext
is similar to the functionality of GenericSystemBase
with the caveat that the EntityContext
also appropriately handles cases where you are writing commands into a command buffer
Systems have two key functions, Initialise
and OnUpdate
. You can set your system queries up in Initialise
, which is run when the system in created (in PlateUp this is when the lobby is first entered, or when leaving a multiplayer session and returning to single player).
Your system should not contain any data or have any state. It should always act based on the entities in the world
OnUpdate
is run every frame, and you should generally write your systems to act every time their update is called.
In general it's better to split your code into multiple systems and use components to mark up entities so they can be acted on by the other systems. For instance, when an appliance is set on fire, all you need to do is mark it as CIsOnFire
. Other systems will look for appliances that have this tag and set up the extra entities and tags that are required.
When writing systems, you typically want to limit when they execute. PlateUp provides some abstract systems for you to inherit to ensure your systems run only when you want them to.
There are 2 broad categories of systems:
Unlike GenericSystemBase
which is executed at all times, there are various PlateUp specific abstract systems that inherit GenericSystemBase
that have their OnUpdate()
method run at limited times.
No. | Class Name | Periods when OnUpdate() will execute |
Present Singleton Marker(s) |
---|---|---|---|
1 | TutorialSystem * |
In the tutorial | STutorialSystemMarker |
2 | FranchiseSystem * |
While in Headquarters | SFranchiseMarker |
3 | RestaurantSystem ** |
Preparation Phase Practice Mode Day |
SKitchenMarker |
4 | StartOfNightSystem |
On first frame when entering preparation phase | SKitchenMarker SIsNightFirstUpdate SIsNightTime |
5 | NightSystem |
Preparation Phase | SKitchenMarker SIsNightTime |
4 | StartOfDaySystem |
On first frame when entering Practice Mode/Day | SKitchenMarker SIsDayFirstUpdate SIsDayTime |
6 | DaySystem |
Practice Mode Day |
SKitchenMarker SIsDayTime |
7 | FranchiseBuilderSystem * |
When deciding to scrap cards or create franchise | SFranchiseBuilderMarker |
8 | GameOverSystem |
When qutting the game OR When run is lost |
SGameOver |
9 | PostgameInitialisationSystem |
On first frame after losing the run | SPostgameFirstFrameMarker |
10 | PostgameSystemBase |
During display of news item and run info | SPostgameMarker |
*Similar to Day and Night, these systems have a *FirstFrameSystem
variant that only execute for a single frame, when entering their respective scenes. They also have *CleanUpSystem
varients which execute when exiting the scene. These are executed in the presence of CSceneFirstFrame
(For *FirstFrameSystem
) and SClearScene
components (For *CleanUpSystem
).
** "RestaurantFirstFrameSystem" is called RestaurantInitialisationSystem
.
If you want a system to run in multiple scenes/phases, with mostly similar behaviour, you should inherit the abstract class that covers all the required scenes/phases. Then you can check for the presence of the Singleton to decide which code block to execute.
public class FranchiseAndRestaurantSystem : GenericSystemBase {
protected override void OnUpdate() {
if (HasSingleton<SFranchiseMarker>()) {
// Do something for franchise
}
if (HasSingleton<SKitchenMarker>()) {
// Do something for restaurant
}
}
}
Additional Singleton markers can be used to further specify when systems should update. For example, DaySystem
does not discriminate between Practice Mode and Day. Hence, you can make the system require SPracticeMode
for update like so:
public class PracticeModeSystem : GenericSystemBase {
protected override void Initialise() {
base.OnInitialise();
RequireSingletonForUpdate<SPracticeMode>();
}
protected override void OnUpdate() {
// Only runs in Practice Mode
}
}
These only perform their actions when a player input is made. At its core InteractionSystem
still inherits GenericSystemBase
, but provides an additional layer of abstraction that better suits the task at hand. Thus, InteractionSystem
behave slightly differently than a general system.
Instead of requiring you to use EntityQuery and act on all entities that satisfy the query, you're writing the system as if its acting only on one entity at a time. This will get clearer as you read on. InteractionSystem
provides some fields you can override to change its behavior:
InteractionMode RequiredMode => InteractionMode.Items; // Enum representing the mode (See below for more info)
bool AllowAnyMode => false; // Set to true if system applies in both Appliance and Item modes
InteractionType RequiredType => InteractionType.Act; // Enum representing the key that triggeres Perform()
bool AllowActOrGrab => false; // Set to true if the interaction applies to both Act and Grab
// (eg. Opening blueprint letters and parcels)
bool RequirePress => true;
bool RequireHold => false;
bool AllowTransferOnly => false;
bool UseImmediateContext => false;
InteractionMode
is used to determine what is being interacted with. There are 2 interaction modes, namely Appliances (with ApplianceInteractionSystem
) and Items (with ItemInteractionSystem
). Take for example, this is how you can distinguish between when a player will pick up a Counter, as opposed to an Apple sitting on the Counter. Both ApplianceInteractionSystem and ItemInteractionSystem inherit InteractionSystem
InteractionType
is used to determine they type of interaction, and thus the key that is used to activate Perform()
. Some examples of game mechanics that use each InteractionType
are:
Interaction Type | Game Mechanic |
---|---|
Look |
Highlighting appliances when player is facing it Keeping the appliance info popup shown until player looks away |
Grab |
Picking and dropping appliances Taking items and tools (These are split into different systems, they are a bit more complicated) |
Act |
Taking customer orders Using Order Machine |
Notify |
Ping beam Show appliance info Show blueprint info |
There are two common methods in InteractionSystem
. IsPossible()
runs every frame and you should use this to determine if conditions are met based on data in components in both the Interactor
and Target
are met. Perform()
runs if IsPossible()
return true, and the appropraiate player input is received. Here, you can modify the components on the Interactor and Target to achieve the intended outcome.
The order in which systems execute is important. You might want your system to run only after another system is finished, or ensure that one system runs in between two other systems. By default, the system ordering is unpredictable, so you should ensure your ordering is correct even if it seems to work - it might not work next time you run it because the order might change. There are two ways to enforce ordering:
[UpdateBefore(typeof(MyOtherSystem)]
and [UpdateAfter(typeof(MyOtherSystem)]
. These don't guarantee that your system will be immediately before or after, only that they are before or after in the loop. The two systems must be in the same group for this to work.[UpdateInGroup(typeof(MySystemGroup))]
. Systems in a group are always run as a block, one after the other, and can be sorted against each other. This is useful if you want to run a number of systems and don't want other systems running in between. For instance, the code that handles transferring items is split over many systems and doesn't want the data to change during this process, so all the systems are run within one group