Views from data
Most views bind to a view-model written in C#. A DataViewModel is a view-model with no code of its own: its values, rows and commands are set by name, as data, and it reports what the player does as events. It suits a window whose contents are decided somewhere other than the game's compiled code: a server, a mod, a script, or content downloaded after release.
It comes in two forms:
- A view file the game ships, driven by data. The view is trusted like any other.
- Markup from elsewhere, which the game did not ship and doesn't trust. It is checked and held to limits before anything is built from it (see Markup from elsewhere).
What BSGameLib does, and what the game does
BSGameLib.UI builds the view, holds its state, applies changes to it, and tells the game what the player did. That is all. Where the markup and the changes come from, and where the player's actions go, is the game's to decide: a network connection, a file, a mod's script. Nothing in BSGameLib fetches markup or carries changes, and BSGameLib.Server knows nothing about these views. Over a network sketches what a game builds to drive one from a server.
A view from a file
using BSGameLib.UI;
using BSGameLib.UI.DataViews;
public static class Backpack
{
public static DataViewModel Open(IViewManager views)
{
var backpack = new DataViewModel(views, Path.Combine(AppContext.BaseDirectory, "Views", "Backpack.view.xml"));
views.SetView(backpack);
backpack.Apply(DataViewChange.Set("Gold", 120));
backpack.Apply(DataViewChange.Command("Use"));
backpack.Apply(DataViewChange.Collection("Items", "Id", new List<IReadOnlyDictionary<string, object>>
{
new Dictionary<string, object> { ["Id"] = 1, ["Name"] = "Potion", ["Count"] = 3 },
new Dictionary<string, object> { ["Id"] = 2, ["Name"] = "Rope", ["Count"] = 1 },
}));
backpack.CommandInvoked += (_, e) => Console.WriteLine($"{e.Command} on item {e.RowKey}");
return backpack;
}
}
The view binds to those names as it would to a view-model's properties:
<View>
<StackLayout Orientation="Vertical" VerticalSpacing="8">
<Label Id="gold" Size="{200,24}" Text="{Binding Gold}" />
<Repeater Id="items" ItemsSource="{Binding Items}">
<ItemTemplate>
<StackLayout Orientation="Horizontal" HorizontalSpacing="8">
<Label Size="{120,24}" Text="{Binding Name}" />
<Label Size="{40,24}" Text="{Binding Count}" />
<Button Size="{60,24}" Text="Use" Command="Use" />
</StackLayout>
</ItemTemplate>
</Repeater>
</StackLayout>
</View>
Changing the state
Each change is made with one of DataViewChange's static methods, and Apply(DataViewChange) applies it:
| Method | What it does |
|---|---|
Set(name, value) |
Sets one value, such as Gold. |
Collection(name, keyColumn, rows) |
Replaces a collection of rows. Each row is found by the value of keyColumn. |
Row(collection, key, values) |
Changes the row with that key, or adds it at the end. |
Remove(collection, key) |
Takes out the row with that key. |
Command(name, enabled) |
Offers a command, or takes it back when enabled is false. |
A changed value is raised as a change, so anything bound to it updates. A changed collection replaces
the old one, so a repeater rebuilds its rows. A row's values are by name: a row's Name is
{Binding Name} in the row's template. A collection holds at most MostRows rows, which defaults to
1,000; rows past that are not taken.
What the player does
The view reports what the player does through two events:
| Event | Arguments | When it fires |
|---|---|---|
CommandInvoked |
Command, Collection, RowKey |
An offered command was pressed. Collection and RowKey are set when it was pressed on a row. |
ValueChanged |
Name, Value |
The player changed a value that is bound two ways, such as a text box. |
A command does nothing unless it was offered with DataViewChange.Command, and enabled: false
takes it back. A button bound to an offered command passes its row's key with it, so the button on a
row knows which row it is.
Markup from elsewhere
Markup the game didn't ship is given as text, with limits on its size:
using BSGameLib.UI;
using BSGameLib.UI.Builder;
using BSGameLib.UI.DataViews;
public static class ModWindows
{
public static DataViewModel Create(IViewManager views, string markup, IViewPictures pictures)
{
var limits = new UntrustedViewLimits(MostCharacters: 64 * 1024, MostDepth: 16, MostElements: 500);
return new DataViewModel(views, markup, "backpack", limits)
{
MostRows = 200,
Scope = new ViewScope
{
LocalizationPrefix = "backpack.",
Pictures = pictures,
Templates = new[] { Path.Combine(AppContext.BaseDirectory, "Theme.template.xml") },
},
};
}
}
The name, "backpack" here, is only for the log.
What it may not do
Such markup is checked before anything is built from it, and refused if it:
- Declares
<Resources>or<References>. Those would replace the game's own colours, styles and textures by their ids. It uses the game's theme as it is. - Uses a
TextureFile. It shows pictures only through its scope (see below). - Names a font by a path. A font is one of the game's fonts, named by a plain name with no folder,
no
.., and nothing absolute. - Is larger than its UntrustedViewLimits. The defaults are 262,144 characters of markup, 32 levels of nesting, and 2,000 elements, controls and their parts included.
A view that is refused, or that doesn't match the schema, fails to build. The reasons come out of
SetView as an AggregateException, so catch it and tell the player the window couldn't open; the
rest of the game is not affected.
What the game gives it
Besides its values and collections, only what its ViewScope gives reaches it:
- Pictures, through
Pictures: an IViewPictures that turns the name in anImage'sSourceinto a texture. The markup names pictures, never files, and an unknown name draws nothing. - Text, through
LocalizationPrefix: a prefix the game chooses for this view's{Localize}keys, so its text can live in its own part of the language files. Each key is looked up with the prefix first, then as written, so it can use the game's own keys too. With the prefixbackpack.,{Localize use}findsbackpack.useif the language file has it, anduseif not. - Theme, through
Templates: game templates the view is built with, by absolute path, as if it referenced them. That is how it gets the game's styles, soStyle="theme.button"works in it.
Over a network
To have a server open and drive a view on a client, the game carries the markup, the changes and the player's actions in packets of its own. A minimal set, with BSGameLib.Networking:
using BSGameLib.Networking;
// Server to client: open a view, set a value, offer a command.
public class OpenViewPacket : IDataPacket
{
public string View { get; set; }
public string Markup { get; set; }
}
public class SetViewValuePacket : IDataPacket
{
public string View { get; set; }
public string Name { get; set; }
public string Value { get; set; }
}
public class OfferViewCommandPacket : IDataPacket
{
public string View { get; set; }
public string Command { get; set; }
public bool Enabled { get; set; }
}
// Client to server: the player pressed a command.
public class ViewCommandPacket : IDataPacket
{
public string View { get; set; }
public string Command { get; set; }
public string RowKey { get; set; }
}
On the client, each packet's handler turns it into a DataViewModel or a DataViewChange, and
CommandInvoked sends a ViewCommandPacket back. Collections and rows need packets of their own in
the same way. On the server, the game decides what a command means, and refuses one for a view it
didn't open or a command it didn't offer.
Using it safely
- Treat everything the view reports as a request. Check a
CommandInvokedorValueChangedwhere the view's contents come from before acting on it; with a server, on the server. - Keep the limits as low as the window needs. A larger window is more work to build and draw.
- Catch the
AggregateExceptionfromSetView. - Give the view only the pictures and templates it should use.