HTML Data Binding
Data binding synchronizes the state of your UI with that of the game, effectively eliminating a lot of JavaScript boilerplate that would otherwise be needed.
Overview
The data binding system is a major part of Cohtml. Its main point is to eliminate the need to move as much as possible of the data to the UI so that the UI creators have full control over how it’s displayed. Simultaneously, C++ engineers only need to write a couple of lines to expose all the data and move on to other tasks.
Cohtml allows for easy communication between your application and the UI via several mechanisms - one of them being data binding. See Communicating with the game for another one.
Using the model from the HTML
Once the object is exported from C++, it can be attached to the DOM using the set of data-bind-*
properties. To complete the example from above, imagine that we want to move a nameplate in the UI using the player’s left position:
<div class="nameplate" data-bind-style-left="{{g_TestPlayer.leftPos}}"></div>
Note the special double curly brace syntax ({{expression}}
) that you need to use.
With the model exposed and the HTML using it, the <div>
element is now linked to the player.
With these steps, the <div>
will automatically update anytime the model changes. Note that you didn’t have to write any JavaScript to synchronize the UI and the game. Although this example is contrived, you can imagine how powerful this feature can become when dealing with a complex screen powered by tens of variables. Scroll below for more details on how to make use of the subsystem.
nullptr
is not supported at the moment, i.e. expressions that are using it won’t be updated.Syntax overview
HTML/JavaScript syntax
The syntax of the data binding attributes is a JavaScript expression. The simplest expressions only refer to the model’s properties and are encapsulated in double curly braces like we just saw above:
<div data-bind-style-left="{{myModel.leftPos}}"></div>
where myModel
is a named model which has a leftPos
property.
You can also construct complex expressions such as:
<div data-bind-style-left="{{myModel.LeftPos}}.toFixed()"></div>
<div data-bind-style-left="Math.pow({{myModel.LeftPos}}, 2)"></div>
Only the code referring to the model’s properties needs to be inside the curly braces.
Supported data-bind-* attributes
data-bind-value
The data-bind-value
attribute takes a value and assigns the node’s textContent
property to it.
Example:
<span data-bind-value="{{player.health}}"></span>
If you want to round the value then you can use the toFixed
method.
Example:
<span data-bind-value="{{player.health}}.toFixed(2)"></span>
data-bind-html
The data-bind-html
attribute takes a value and assigns the node’s innerHTML
property to it. This allows the creation of text elements that can have formatting and embedded images.
data-bind-html
should be used to create small pieces of DOM without more data-binding attributes inside.Example:
<span data-bind-html="{{item.tooltip}}"></span>
Styling attributes
The following attributes allow you to modify the element’s style:
Data bind attribute | Affected style property | Accepted values |
---|---|---|
data-bind-style-left | left | string or number (px) |
data-bind-style-top | top | string or number (px) |
data-bind-style-opacity | opacity | floating point number between 0 and 1 |
data-bind-style-width | width | string or number (px) |
data-bind-style-height | height | string or number (px) |
data-bind-style-color | color | CSS color as string or unsigned RGBA |
data-bind-style-background-color | background-color | CSS color as string or unsigned RGBA |
data-bind-style-background-image-url | background-image | URL to the image |
data-bind-style-transform2d | transform | string, containing 6 comma-separated numbers |
data-bind-style-transform-rotate | transform: rotate(..) | string or number (deg) |
All the properties above that take number (px) will assume that the number is a measurement in pixels (e.g. binding 42 to data-bind-style-left
will be equivalent to left: 42px
).
(Unsigned RGBA) have the same semantics as hexadecimal syntax where 1st byte is alpha, 2nd is blue, 3rd is green and 4th is red.
If data-bind-style-transform-rotate
takes a number (deg) then it will assume that the number is a measurement in degrees (e.g. binding 90.5 to data-bind-style-transform-rotate
will be equivalent to transform: rotate(90.5deg)
).
There are two other styling attributes - data-bind-class
and data-bind-class-toggle
. data-bind-class
takes a string in the format data-bind-class="class-name[;class-name]"
. The class name can be any CSS class. The class specified by class-name
will be added to the element. Here’s a brief example:
<head>
<style>
.class-type-left-10 {
left : 10px;
}
.class-type-left-20 {
left : 20px;
}
.class-type-top-30 {
top : 30px;
}
.class-type-top-40 {
top : 40px;
}
</style>
</head>
<body>
<div data-bind-class="'class-type-'+{{this.type_1}};'class-type-'+{{this.type_2}}"></div>
</body>
data-bind-class-toggle
takes a string in the format data-bind-class-toggle="class-name:bool_condition[;class-name:bool_condition]"
. The class-name
is the name of some CSS class and bool_condition
is a boolean or an expression that evaluates to a boolean. If the boolean is true
or the condition evaluates to true
, the class specified by class-name
is added to the element, otherwise, it is removed.
Let’s see an example with class toggling:
<head>
<style>
.red {
background-color: red;
}
</style>
</head>
<body>
<div class="ToggleWithExpression" data-bind-class-toggle="red:{{this.Health}} < 50">Something red</div>
<div class="ToggleWithBoolean" data-bind-class-toggle="red:{{this.hasLowHealth}}">Something red</div>
The red
class will be present on ToggleWithExpression
as long as {{this.Health}} < 50
is true
, changing the element’s background to red. Respectively the red
class will be present on ToggleWithBoolean
as long as {{this.hasLowHealth}}
is true
. Otherwise, it won’t be applied and the element will have whatever background it usually has.
Structural data binding
The attributes above only allow you to modify the visual style and the text content of DOM elements. The real power of the data binding system stems from the fact that you can also modify the entire DOM tree with it. This is done via two other attributes:
data-bind-if
: Displays a DOM element based on a condition. The expressions in the attribute value should evaluate as a boolean value.data-bind-for
: Repeats a DOM node for each element in a collection. The basic syntax isdata-bind-for="iter:{{myModel.arrayProperty}}"
, wheremyModel.arrayProperty
must be an array, anditer
is a variable used for iteration, which is available in data binding attributes for the child DOM nodes. The syntax for accessing properties of an iterator in adata-bind-for
is{{iter.property}}
.
You can also use the full form data-bind-for="index, iter:{{myModel.arrayProperty}}"
, where index
is loop counter. If you don’t need to use the index or iterator you can use _
e.g. data-bind-for="index, _:{{myModel.arrayProperty}}"
If the data-bind-for
collection is a std::vector
then the elements could be also raw
/std::shared_ptr
/std::unique_ptr
pointers (or your custom pointer type).
data-bind-for
with a collection of primitive types is not supported.- Changes via JS to the DOM element with
data-bind-if
attribute will be lost if the expression’s value is switching between true and false. - Changes via JS to the DOM element generated from
data-bind-for
will cause undefined behavior if the collection’s size is changed.
Structural data binding allows you to generate entire screens by just providing a template for each element in a collection (e.g. all items in the player’s inventory) and the system will take care of repeating the template as many times as necessary.
The next several examples only show the relevant HTML because the C++ model is straightforward:
< !-- displays a warning message if the player's health is low -->
<div data-bind-if="{{g_Player.health}} < 40" id="playerHPLowWarn">
Player needs a medic!
</div>
< !-- The following will result into a list, depending on the weapons the player has, like so:
Rare Gun
Common Sword
Rare Dagger
-->
<div data-bind-for="weapon:{{player.weapons}}">
<span data-bind-if="{{weapon.isRare}}">
Rare
</span>
<span data-bind-if="!{{weapon.isRare}}">
Common
</span>
<span data-bind-value="{{weapon.name}}">
</div>
< !-- Enumerates all pets of the player -->
<div class="petsList">
<div data-bind-for="iter:{{g_Player.pets}}" class="petItem">
<div class="petName" data-bind-value="{{iter.name}}"></div>
<div class="petSpeed" data-bind-value="{{iter.info.speed}}"></div>
<div class="petType" data-bind-value="{{iter.info.isMount}} ? 'Mount' : 'Companion'"></div>
<div>
</div>
< !-- Will result in -->
<div class="petsList">
<div class="petItem">
<div class="petName">Sabertooth tiger</div>
<div class="petSpeed">80</div>
<div class="petType">Mount</div>
</div>
<div class="petItem">
<div class="petName">Eagle</div>
<div class="petSpeed">90</div>
<div class="petType">Companion</div>
</div>
<div class="petItem">
<div class="petName">Pony</div>
<div class="petSpeed">70</div>
<div class="petType">Mount</div>
</div>
</div>
< !-- Access loop counter -->
<div class="petsList">
<div data-bind-for="index, iter:{{g_Player.pets}}" class="petItem">
<div data-bind-class="'petName' + {{index}}" data-bind-value="{{iter.name}}"></div>
<div>
</div>
< !-- Will result in -->
<div class="petsList">
<div class="petItem">
<div class="petName0">Sabertooth tiger</div>
</div>
<div class="petItem">
<div class="petName1">Eagle</div>
</div>
<div class="petItem">
<div class="petName2">Pony</div>
</div>
</div>
Virtual list
In case you need pagination, you can use the method engine.createVirtualList
which will create a view-like object with the following properties:
- startIndex - index from which the
data-bind-for
will start generating DOM elements. The default value is 0. - pageSize - maximum number of elements that will be generated from the
data-bind-for
.
Virtual lists are used by applying them to the data-bind-for
attributes with an array of your model. The virtual list will serve as a view, so it will not change the array. One virtual list can be used in different expressions with different arrays and if you change a property of the virtual list it will automatically apply the change to all arrays that it is associated with. The only thing that you must do is to call synchronizeModels
.
For example, let’s say we have a model g_Game
with property heroes
which is an array and has ~100 elements. At any time we want to show only 5 heroes and want buttons that will show the next and previous page. It will be way more efficient if we generate only 5 UI elements, instead of generating 100 and hiding 95 of them. This can be achieved easily with only a few lines of JavaScript.
<div class="heroes-board">
<div class="hero" data-bind-for="iter:uiState.vList({{g_Game.heroes}})">
...
<div>
<div class="button" onclick="nextPage()"> </div>
<div class="button" onclick="prevPage()"> </div>
</div>
<script>
var uiState = new Object;
function update() {
engine.synchronizeModels();
window.requestAnimationFrame(update);
}
engine.on("Ready", function() {
uiState.vList = engine.createVirtualList();
uiState.vList.startIndex = 0;
uiState.vList.pageSize = 5;
windows.requestAnimationFrame(update);
}
function nextPage() {
if (uiState.vList.startIndex + uiState.vList.pageSize < g_Game.heroes.length)
uiState.vList.startIndex += uiState.vList.pageSize;
}
function prevPage() {
if (uiState.vList.startIndex - uiState.vList.pageSize >= 0)
uiState.vList.startIndex -= uiState.vList.pageSize;
}
</script>
Data-binding events
The data-binding events are DOM element attributes for attaching event listeners to the DOM.
Example:
<div class="shop-menu" data-bind-for="item:{{g_Shop.items}}">
<div class="item-box" data-bind-[eventName]="g_Shop.buyItem(event, this, {{item}})">
</div>
</div>
event
- The JavaScript Event object from the fired event.[eventName]
- All events are listed in Supported Events. Example:click
,mouseup
,dblckick
, etc.this
- Is set to the DOM element on which the handler is registered.
Supported Events
abort: is fired when the loading of a resource has been aborted.
blur: is fired when an element has lost focus
click: is fired when a pointing device button (usually a mouse's primary button) is pressed and released on a single element.
dblclick: is fired when a pointing device button (usually a mouse's primary button) is clicked twice on a single element.
error: is fired when an error occurred; the exact circumstances vary, events by this name are used from a variety of APIs.
focus: is fired when an element has received focus.
focusin: is fired when an element is about to receive focus.
focusout: is fired when an element is about to lose focus.
keydown: is fired when a key is pressed down.
keypress: is fired when a key that produces a character value is pressed down.
keyup: is fired when a key is released.
load: is fired when progression has begun successful.
mousedown: is fired when a pointing device button is pressed on an element.
mouseover: is fired when a pointing device is moved onto the element that has the listener attached or onto one of its children.
mouseout: is fired when a pointing device (usually a mouse) is moved off the element that has the listener attached or off one of its children.
mouseenter: is fired when a pointing device (usually a mouse) is moved over the element that has the listener attached.
mouseleave: is fired when the pointer of a pointing device (usually a mouse) is moved out of an element that has the listener attached to it.
mousemove: is fired when a pointing device (usually a mouse) is moved while over an element.
mouseup: is fired when a pointing device button is released over an element.
input: is fired synchronously when the value of an <input>, <select>, or <textarea> element is changed.
change: is fired synchronously when the value of an <input>, <select>, or <textarea> element is changed and committed.
scroll: is fired when the document view or an element has been scrolled.
touchstart: is fired when one or more touch points are placed on the touch surface.
touchend: is fired when one or more touch points are removed from the touch surface.
resize: is fired when the document view has been resized.
durationchange: is fired when the duration attribute has been updated.
emptied: is fired when the media has become empty.
ended: is fired when playback or streaming has stopped because the end of the media was reached or because no further data is available.
seeked: is fired when a seek operation completed.
seeking: is fired when a seek operation began.
timeupdate: is fired when the time indicated by the currentTime attribute has been updated.
wheel: is fired when a pointing device (usually a mouse) wheel button is rotated.
Custom data binding attributes
In some cases, default data-binding attributes are not powerful enough to satisfy the needs of our users. In these cases you can create your own attributes.
To register a custom attribute use engine.registerBindingAttribute("my-attribute-name", MyAttributeHandler)
.
class MyAttributeHandler {
init(element, value) {
/// This will be executed only once per element when the attribute attached to it is bound with a model.
/// Set up any initial state, event handlers, etc. here.
}
deinit(element) {
/// This will be executed only once per element when the element is detached from the DOM.
/// Clean up state, event handlers, etc. here.
}
update(element, value) {
/// This will be executed every time that the model which the attribute is attached to is synchronized.
}
}
engine.on('Ready', function () {
engine.registerBindingAttribute("my-attribute-name", MyAttributeHandler);
}
element
- The DOM element to which the handler is attachedvalue
- The result from the evaluation of the attribute’s expression in the HTML
…and then you can use it on any number of DOM elements:
<div data-bind-my-attribute-name="{{dataBindingExpression}}"> </div>
Creating the attribute handlers
Once you call registerBindingAttribute
a new handler will be created for each element in DOM that has the data-bind-my-attribute-name
attribute.
init
and the first update
will be executed on the first synchronizeModels
after an attribute handler is registered.init
and deinit
are useful to manage the lifetime of the attribute. In the case where the element with a custom attribute is generated via data-bind-if
and data-bind-for
these callbacks will be called when the element is attached to or removed from the DOM.The following example illustrates how to create your own equivalent of data-bind-value
which capitalizes the provided value.
<div data-bind-coh-capitalize="{{g_Game.text}}"></div>
<script src="cohtml.js"> </script>
<script>
class Capitalize {
update(element, value) {
element.textContent = value.toUpperCase();
}
}
engine.on('Ready', function () {
engine.registerBindingAttribute("coh-capitalize", Capitalize);
engine.createJSModel("g_Game", {
text: "lorem ipsum",
});
engine.synchronizeModels();
});
</script>
The following example illustrates how to create custom event handlers.
<div data-bind-coh-events="{{g_Game.state}}"></div>
<script src="cohtml.js"> </script>
<script>
class MyCustomEventFamily {
constructor() {
this.state = null;
}
init(element, value) {
this.state = value;
this.event1 = element.addEventListener("eventName1", (event) => {
eventHandler1(event, this.state);
});
this.event2 = element.addEventListener("eventName2", (event) => {
eventHandler2(event, this.state);
});
}
update(element, value) {
this.state = value;
}
deinit(element) {
element.removeEventListener(this.event1);
element.removeEventListener(this.event2);
}
}
engine.on('Ready', function () {
engine.registerBindingAttribute("coh-events", MyCustomEventFamily);
engine.createJSModel("g_Game", {
state : 1
});
engine.synchronizeModels();
});
</script>
Observable models
An observable model is a smart object which will automatically push itself for an update when some of its properties have changed.
This is useful in cases where you need to keep an active state. Some very common scenarios are character selection screens, quest logs, inventories, shops etc. In these cases observable models:
- Eliminate the need to track indexes of selected elements
- Improve readability of data-bind attributes.
- In most cases improve the performance, because data-bind attribute expressions are simpler.
You can create an observable model with engine.createObservableModel('modelName')
Example:
Without observable
<div id="target" data-bind-value="{{gameState.players}}[{{gameState.selected}}].money + '$'"></div>
<script src="cohtml.js"> </script>
<script>
engine.on('Ready', function () {
let gameState = {
players: [ {money: 100}, {money: 200}, {name: 300} ],
selected: 0
}
engine.updateWholeModel(gameState);
engine.synchronizeModels();
gameState.selected = 1;
engine.updateWholeModel(gameState);
engine.synchronizeModels();
});
</script>
With observable
<div id="target" data-bind-value="{{activeState.selectedPlayer.money}} + '$'"></div>
<script src="cohtml.js"> </script>
<script>
engine.on('Ready', function () {
let gameState = {
players: [ {money: 100}, {money: 200}, {name: 300}],
}
engine.createObservableModel("activeState");
// Will call engine.updateWholeModel(activeState) for us
activeState.selectedPlayer = gameState.players[0];
// Will update the target element
engine.synchronizeModels();
activeState.selectedPlayer = gameState.players[1];
engine.synchronizeModels();
});
</script>
activeState.selectedPlayer.money = 100
will not cause auto-update.As you can see, this gives a convenient way to update the active state without extra work. The example above has one big downside. If the game updates gameState
this will not automatically push activeState
for an update, because the gameState
doesn’t know about existing of activeState
. This will lead to a desynchronization between the game and the UI. We can solve this problem with the use of engine.addSynchronizationDependency(gameState, activeState)
. This method will create a one-way dependency between gameState
and activeState
, which means that each time when gameState
is pushed for an update, activeState
will be pushed too.
Example:
<div id="target" data-bind-value="{{activeState.selectedPlayer.money}} + '$'"></div>
<script src="cohtml.js"> </script>
<script>
engine.on('Ready', function () {
let gameState = {
players: [ {money: 100}, {money: 200}, {name: 300} ],
}
engine.createObservableModel("activeState");
engine.addSynchronizationDependency(gameState, activeState);
// Will call engine.updateWholeModel(activeState) for us
activeState.selectedPlayer = gameState.players[1];
// Will update the target element
engine.synchronizeModels();
gameState.players[1].money = 50;
// Will push 'activeState' for update too
engine.updateWholeModel(gameState);
engine.synchronizeModels();
});
</script>
Now we can update our gameState
independently from activeState
and everything will work as we expected.
In cases where you don’t need a dependency between 2 models anymore, you can use engine.removeSynchronizationDependency(gameState, activeState)
to remove it.
You can find a more detailed sample of the observable models in Samples/uiresources/ObservableModels.
Precise Data Binding Updates
Sometimes a few properties of a model change much more often than the rest. This makes updating the model less efficient, since properties that have not changed will be marked for an update and will be reevaluated. This can be avoided by splitting the model in two models - one that changes often and one that remains mostly constant, but it is not always convenient to do so.
Precise data binding updates allow updating a single property of a model. Given the model:
struct Player
{
std::string Name;
unsigned int Score;
};
void CoherentBind(cohtml::Binder* binder, Player* player)
{
if (auto type = binder->RegisterType("Player", player))
{
type.Property("name", &Player::Name)
.Property("score", &Player::Score)
;
}
}
Creating a precise update handle and updating it can be done as:
void OnReadyForBindings(cohtml::View* view)
{
view->CreateModel("thePlayer", &player);
// create a handle for the score property
auto scoreHandle = view->GetBinder()->CreatePreciseHandle(&player, "score");
// mark the score as dirty whenever it has changed
player->OnScoreChanged([view, scoreHandle, player]() {
view->GetBinder()->UpdatePreciseHandle(player, scoreHandle);
});
}
Creating and updating a handle for precise data-binding updates requires the current address of the C++ instance. Therefore whenever a C++ object that will have or already has precise handles attached to it moves in memory, the binder has to be notified by calling the InstanceMoved method
. For types with move semantics and access to the view this can be done in the move constructor:
struct Player
{
Player(Player&& rhs)
{
view->GetBinder()->InstanceMoved(&rhs, this);
}
};
For structures inside collections that don’t have a move constructor, InstanceMoved
may be called whenever there was a chance the collection relocated its storage.
void addObjective(std::vector<QuestObjective>& objectives)
{
auto old_storage = objectives.data();
auto old_size = objectives.size();
points.push_back(QuestObjective{});
for (auto index = 0; i < old_size; ++i) {
view->GetBinder()->InstanceMoved(old_storage + i, &objectives[i]);
}
}
Precise Data Binding Updates for collections
Sometimes only a few of the elements of a collections inside a model change. Instead of updating the whole collection it might be more efficient to update just the elements that have changed or have been added or removed.
Given the model:
struct Reward
{
std::string Name;
};
struct Game
{
std::vector<Reward> Rewards;
}
void CoherentBind(cohtml::Binder* binder, Reward* reward)
{
if (auto type = binder->RegisterType("Reward", reward))
{
type.Property("name", &Reward::Name)
;
}
}
void CoherentBind(cohtml::Binder* binder, Game* game)
{
if (auto type = binder->RegisterType("Game", game))
{
type.Property("rewards", &Game::Rewards)
;
}
}
Creating a precise update handle for the Rewards
collection and updating it can be done as:
void OnReadyForBindings(cohtml::View* view)
{
view->CreateModel("theGame", &game);
// create a handle for the Rewards collection
auto rewardsHandle = view->GetBinder()->CreatePreciseHandle(&game, "rewards");
// mark the reward as dirty whenever it has changed
game->OnRewardChanged([view, rewardsHandle, game](size_t changedRewardIndex) {
view->GetBinder()->ElementUpdated(game, rewardsHandle, changedRewardIndex);
});
// mark new rewards as added
game->OnRewardAdded([view, rewardsHandle, game](size_t newRewardIndex) {
view->GetBinder()->ElementAdded(game, rewardsHandle, newRewardIndex);
});
// mark removed rewards
game->OnRewardRemoved([view, rewardsHandle, game](size_t removedRewardIndex) {
view->GetBinder()->ElementRemoved(game, rewardsHandle, removedRewardIndex);
});
}
Sometimes a collection can change so much so applying the individual changes is more expensive than just updating the collection as a whole. This depends on the size of the DOM trees generated for each element in the collection and the types of the changes. If a large amount of the changes are remove and add element, updating the collection as a whole might be more efficient since less DOM elements will be created or destroyed, at the cost for more DOM elements to have their values replaced.
Performance of the precise updates for collections can be measured using the EnableProfiling
API. The scope is called PreciseUpdateCollection
. If a collection has enough changes to be more efficient to be updated as a whole you can use:
view->GetBinder()->UpdatePreciseHandle(game, rewardsHandle);
to update the collection as a whole even if there are precise changes have already been registered for the collection.