PlateUp uses a hybrid ECS framework to run the game's simulation. To display things to players, the game uses views. Entities can request a view, which will then cause them to be displayed to all clients. Each frame, the view's system will collect data from ECS and pass it to the view, which can update itself to show the current state of the entity.
The game manages most of the work with views. If an entity has a CRequiresView
component, it will automatically get a view for each client. Destroying the entity will remove the corresponding views. When an entity has requested a view, the view manager will automatically create the view and attach CLinkedView
to the entity with the identifier of the view.
Views are implemented as standard Unity MonoBehavior components. To update the view to the game's state, you must write a system that sends updates each frame to the view. Views should not access ECS data directly; the view might not be running on the same instance of the game as the host.
Some views can implement additional behaviour, such as sending responses which enable clients to interact directly with the view. This is mostly used for UI, where the UI is implemented in the view, and responds with the player's requested actions when the player interacts with it.
View can contain subviews, which define additional, component-specific behaviour on a view. For instance, all appliances have a generic "appliance" view, but some appliances have extra behaviour to display, such as plate stacks that display how many plates are available. These are implemented as subviews, which work the same as views, but exist within another view.
To create a view, you need to create:
These are the most generic versions. In practice there are some more specific base classes to use. For most view systems, you can inherit from IncrementalViewSystemBase<T>
, which implements some caching to prevent sending repeat updates. If you want your view to send data back to the ECS backend you can inherit from ResponsiveViewSystemBase<TView, TResp>
.
Most new views are subviews of appliances. To implement an ordinary (non-responsive) subview, here's the general setup:
public class MyNewSubview : UpdatableObjectView<MyNewSubview.ViewData> {
public class UpdateView : IncrementalViewSystemBase<ViewData>, IModSystem {
private EntityQuery Views;
protected override void Initialise() {
base.Initialise();
Views = GetEntityQuery(new QueryHelper()
.All(typeof(CLinkedView), typeof(CMyComponent))
);
}
protected override void OnUpdate() {
using var views = Views.ToComponentDataArray<CLinkedView>(Allocator.Temp);
using var components = Views.ToComponentDataArray<CMyComponent>(Allocator.Temp);
for (var i = 0; i < views.Length; i++) {
var view = views[i];
var data = components[i];
SendUpdate(view, new ViewData {
MySentData1 = data.MyValue1,
MySentData2 = data.MyValue2
}, MessageType.SpecificViewUpdate);
}
}
}
// you must mark your ViewData as MessagePackObject and mark each field with a key
// if you don't, the game will run locally but fail in multiplayer
[MessagePackObject]
public struct ViewData : ISpecificViewData, IViewData.ICheckForChanges<ViewData> {
[Key(0)] public int MySentData1;
[Key(1)] public int MySentData2;
// this tells the game how to find this subview within a prefab
// GetSubView<T> is a cached method that looks for the requested T in the view and its children
public IUpdatableObject GetRelevantSubview(IObjectView view) => view.GetSubView<MyNewView>();
// this is used to determine if the data needs to be sent again
public bool IsChangedFrom(ViewData check) => MySentData1 != check.MySentData1 ||
MySentData2 != check.MySentData2;
}
// this receives the updated data from the ECS backend whenever a new update is sent
// in general, this should update the state of the view to match the values in view_data
// ideally ignoring all current state; it's possible that not all updates will be received so
// you should avoid relying on previous state where possible
protected override void UpdateData(ViewData view_data) {
// perform the update here
// this is a Unity MonoBehavior so we can do normal Unity things here
}
}
This subview looks for ECS entities that have a view (CLinkedView
, which is automatically attached for you by the view manager if you request it with CRequiresView
) and that have the relevant component (CMyComponent
). In this case, the data is passed directly from the component to SendUpdate, which caches it and broadcasts it appropriately. You can also transform the data there, and ideally should send as little data as necessary for your view to update.
The view system here is implemented as a nested class, although this isn't necessary. It mostly just keeps the two together and lets you reference the ViewData type without using the full path.
You should then attach the MonoBehavior component to the prefab of your appliance.
You must mark up your ViewData with MessagePack attributes (see the example above). If you don't, the game won't serialise the data correctly and your mod will not work in multiplayer
The view is automatically removed when the corresponding entity is destroyed or hidden. You can override this behavior by overriding void Remove()
in your view. This is useful for things which want to play an exit animation before they're removed. You should call probably base.Remove()
to perform the actual deletion as this also handles removing objects that may have been attached.
Unlike subviews, which are attached to a prefab that contains a view, creating a new view requires a bit more work. You first need to define a new ViewType
which, as mentioned above, can be used in CRequiresView
to create a new view instance for each client like so:
ViewType MyViewType = (ViewType)12345;
Entity entity = EntityManager.CreateEntity(typeof(CRequiresView), typeof(CPosition));
Set(entity, new CPosition(new Vector3(1f, 0f, 1f)));
Set(entity, new CRequiresView() { ViewType = MyViewType });
It is important that the same ViewType is not used for different kinds of Views.
You can also define the ViewMode in CRequiresView
which determines how the position is interpreted.
Next, you have to provide a prefab for your custom view type. One way to do so is by performing HarmonyPatch on LocalViewRouter.GetPrefab(ViewType view_type)
to return a prefab. This is currently necessary as your typical ECS systems do not run on clients and thus, is unable to access the AssetDirectory where view prefabs are supposed to go. Future development of modding tools, and the game, are expected to make adding a new view prefab easier.
[HarmonyPatch]
public static class LocalViewRouter_Patch {
// You can either load the prefab from an AssetBundle or create a new prefab programmatically
// The prefab must have one, and only one, IObjectView component (the view) attached to the root of the prefab
// Subviews, if required, can be added to children GameObject parented to the root of the prefab
GameObject MyViewPrefab;
[HarmonyPatch(typeof(LocalViewRouter), "GetPrefab")]
[HarmonyPrefix]
static bool GetPrefab_Prefix(ref GameObject __result, ViewType view_type) {
if (view_type == MyViewType) {
__result = MyViewPrefab;
return false; // To prevent the original `GetPrefab` code from running
}
return true;
}
}
Finally, there are some differences between views and subviews; Specifically, ViewData implements IViewData
instead of ISpecificViewData
. Thus, there is no need to implement IUpdatableObject GetRelevantSubview(IObjectView view)
.
[MessagePackObject]
public struct ViewData : IViewData, IViewData.ICheckForChanges<ViewData> {
[Key(0)] public int MySentData1;
[Key(1)] public int MySentData2;
// this is used to determine if the data needs to be sent again
public bool IsChangedFrom(ViewData check) => MySentData1 != check.MySentData1 ||
MySentData2 != check.MySentData2;
}
Views that send data to the ECS backend are known as responsive views. Here is a template of a responsive view:
public class MyResponsiveView : ResponsiveObjectView<MyResponsiveView.ViewData, MyResponsiveView.ResponseData> {
public class UpdateView : ResponsiveViewSystemBase<ViewData, ResponseData>, IModSystem {
EntityQuery Views;
protected override void Initialise() {
base.Initialise();
Views = GetEntityQuery(new QueryHelper()
.All(typeof(CLinkedView), typeof(CMyComponent))
);
}
protected override void OnUpdate()
{
using var views = Views.ToComponentDataArray<CLinkedView>(Allocator.Temp);
using var components = Views.ToComponentDataArray<CMyComponent>(Allocator.Temp);
for (var i = 0; i < views.Length; i++) {
var view = views[i];
var data = components[i];
SendUpdate(view, new ViewData {
MySentData1 = data.MyValue1,
MySentData2 = data.MyValue2
}, MessageType.SpecificViewUpdate);
if (ApplyUpdates(view.Identifier, PerformUpdateWithResponse, only_final_update: true))
{
// Do something if at least one ResponseData was processed this frame
}
}
}
private void PerformUpdateWithResponse(ResponseData data)
{
// Do something for each ResponseData received
}
}
// Definition of Message Packet that will be broadcasted to clients
// you must mark your ViewData as MessagePackObject and mark each field with a key
// if you don't, the game will run locally but fail in multiplayer
[MessagePackObject]
public struct ViewData : ISpecificViewData, IViewData.ICheckForChanges<ViewData> {
[Key(0)] public int MySentData1;
[Key(1)] public int MySentData2;
// this tells the game how to find this subview within a prefab
// GetSubView<T> is a cached method that looks for the requested T in the view and its children
public IUpdatableObject GetRelevantSubview(IObjectView view) => view.GetSubView<MyNewView>();
// this is used to determine if the data needs to be sent again
public bool IsChangedFrom(ViewData check) => MySentData1 != check.MySentData1 ||
MySentData2 != check.MySentData2;
}
// this receives the updated data from the ECS backend whenever a new update is sent
// in general, this should update the state of the view to match the values in view_data
// ideally ignoring all current state; it's possible that not all updates will be received so
// you should avoid relying on previous state where possible
protected override void UpdateData(ViewData view_data) {
// perform the update here
// this is a Unity MonoBehavior so we can do normal Unity things here
}
// Definition of Message Packet that will be sent to the ECS backend
// you must mark your ResponseData as MessagePackObject and mark each field with a key
// if you don't, the game will run locally but fail in multiplayer
[MessagePackObject]
public struct ResponseData : IResponseData, IViewData.ICheckForChanges<ViewData> {
[Key(0)] public int MyRespData;
}
// This runs every frame and sends response data to the ECS backend if `true` is returned
public override bool HasStateUpdate(out IResponseData state) {
// prepare the ResponseData here
// this is a Unity MonoBehavior so we can do normal Unity things here
// If ResponseData to be sent, return true. Otherwise, return false
}
}