PlayerSpecificCustomUI
PlayerSpecificCustomUI
// Only player1 and player2 get the new value and a UI re-render;
// everyone else stays unaffected
someBinding.set(newValue, [player1, player2]);
Therefore, the correct implementation for the button with hover state needs to take
the acting player into account, and only manipulate the player specific value:
function MyButton(props: MyButtonProps): UINode {
...
return Pressable({
children: Text({ text: props.label, style: { ... } }),
onClick: props.onClick,
onEnter: player => backgroundColor.set(HOVERED_COLOR, [player]),
onExit: player => backgroundColor.set(DEFAULT_COLOR, [player]),
style: { backgroundColor, ... },
}),
}
Global Value vs Player Values
Conceptually, it would be helpful to explicitly make a distinction between the
“global value” and the “player values.” A player would see the global value by
default; however, if there is a player value set for a player, that player will see
the player value. Another way to think about global value is that, when a new
player joins the world, that player will always receive the global values as the
initial values.
The behavior of the set() method of the Binding can be more accurately described
as:
When the set() method is called with an array of players, we are effectively
setting a new player value for those players in the array. In this case, the global
value is left unchanged, so other players that are not in the array will be
unaffected.
When the set() method is called without an array of players, we are updating the
global value, and clearing all player values. As a result, all players will receive
the new value, regardless of whether they have any player values in the past.
As you can see, player values can be seen as “deviations” from the global value.
Therefore, we also provide a reset() method, which will remove the player values,
effectively setting the Binding back to the global value. Like the set() method,
the reset() method also takes an optional array of players, which indicates who we
should reset for. If provided, only those players in the array will have their
player values reset and receive the global value; if not provided, all player
values will be cleared. With the introduction of the reset() method, we can have an
even simpler implementation for the button hover state. We may treat the default
color as the global value, and the hovered color as the player value, then instead
of setting it back to the default color, we simply need to reset the Binding:
function MyButton(props: MyButtonProps): UINode {
const backgroundColor = new Binding<string>('#19AD0E');
return Pressable({
children: Text({ ... }),
onClick: props.onClick,
onEnter: player => backgroundColor.set('#87D481', [player]),
onExit: player => backgroundColor.reset([player]),
style: { backgroundColor, ... },
}),
}
What about Map Functions?
Other than the straightforward way of directly setting a new value, we sometimes
use a map function to get new values, for example in functional updates and derived
values for a Binding. Is the map function acting on the global value or the player
values?
The answer is both! It is worth noting that both functional update and derived
values respect player values. The map function will be used to mutate/derive both
the global value and each player value that the Binding might have. To illustrate
this in a concrete example:
// global player1 player2 player3
const binding = new Binding(0);
// binding 0 0 0 0
binding.set(1);
// binding 1 1 1 1
binding.set(2, [player1, player2]);
// binding 1 2 2 1
binding.set(v => v + 1);
// binding 2 3 3 2
const derived = binding.derive(v => v + 1);
// binding 2 3 3 2
// derived 3 4 4 3
binding.set(4, [player2, player3]);
// binding 2 3 4 4
// derived 3 4 5 5
binding.set(v => v + 1, [player3]);
// binding 2 3 4 5
// derived 3 4 5
Setting Player Values on Start
Sometimes we want to set player values for each player before the player interacts
with the UI. We can check this.world.getPlayers() to get the existing players and
connect the OnPlayerEnterWorld event to get the new players. For example, a simple
“Welcome, [player’s name]!” text would be:
class WelcomeMessage extends UIComponent {
initializeUI() {
const message = new Binding<string>('Welcome!');
return View({
children: [
Text({ text: textPrompt }),
UINode.if(
showButton,
Pressable({
children: Text({ text: buttonLabel }),
onClick: /** TODO: button effect */,
}),
),
],
});
}
}
She correctly realizes that the only time any of these Bindings might get updated
is when any player clicks the button, so it’s sufficient to update all of the
Bindings inside the onClick callback. Also, she realizes that the button label
should only be updated to the player clicking the button, but the text prompt and
the button visibility need to be updated for all players.
class GetReadyDialog extends UIComponent {
initializeUI() {
const textPrompt = new Binding<string>(
'Waiting for 8 players',
);
const buttonLabel = new Binding<string>('Get Ready');
const showButton = new Binding<boolean>(true);
return View({
children: [
Text({ text: textPrompt }),
UINode.if(
showButton,
Pressable({
children: Text({ text: buttonLabel }),
onClick: player => {
// only change the acting player's button label
if ( /** TODO: if the player has clicked */ ) {
buttonLabel.set('Get Ready', [player]);
} else {
buttonLabel.set('Cancel', [player]);
}
return View({
children: [
Text({text: textPrompt}),
UINode.if(
showButton,
Pressable({
children: Text({text: buttonLabel}),
onClick: player => {
if (readyPlayers.has(player)) {
readyPlayers.delete(player);
buttonLabel.set('Get Ready', [player]);
} else {
readyPlayers.add(player);
buttonLabel.set('Cancel', [player]);
}
const remainingSlots = 8 - readyPlayers.size;
if (remainingSlots > 0) {
textPrompt.set(`Waiting for ${remainingSlots} players`);
} else {
textPrompt.set('Go!');
showButton.set(false);
}
},
}),
),
],
});
}
}
Bob, on the other hand, is following the suggestions from this documentation.
Unlike Alice, he doesn’t immediately care about what the UI needs to render. He
decides to first think about a minimal but complete representation of the UI. He
realizes that, to decide what needs to be rendered in the UI, he needs to know the
number of remaining slots, which will be used to derive the text prompt and the
button visibility; he also needs to know whether the player has clicked the button
or not, which will be used to derive the button label.
class GetReadyDialog extends UIComponent {
initializeUI() {
const remainingSlots = new Binding<number>(8);
const hasClicked = new Binding<boolean>(false);
return View({
children: [
Text({
text: remainingSlots.derive(r =>
r > 0 ? `Waiting for ${r} players` : 'Go!',
),
}),
UINode.if(
remainingSlots.derive(r => r > 0),
Pressable({
children: Text({
text: hasClicked.derive(h =>
h ? 'Cancel' : 'Get Ready',
),
}),
onClick: /** TODO: button effect */,
}),
),
],
});
}
}
Like Alice, Bob also realizes that he needs to update the two Bindings in the
onClick callback, and that hasClicked should be updated only to the player clicking
the button, and remainingSlots should be updated to everyone. When both of those
Bindings are updated, the new value should depend on the old one, so he uses
functional update.
class GetReadyDialog extends UIComponent {
initializeUI() {
const remainingSlots = new Binding<number>(8);
const hasClicked = new Binding<boolean>(false);
return View({
children: [
Text({
text: remainingSlots.derive(r =>
r > 0 ? `Waiting for ${r} players` : 'Go!',
),
}),
UINode.if(
remainingSlots.derive(r => r > 0),
Pressable({
children: Text({
text: hasClicked.derive(h =>
h ? 'Cancel' : 'Get Ready',
),
}),
onClick: player => {
// only change click status for the acting player
hasClicked.set(h => !h, [player]);
return View({
children: [
Text({
text: remainingSlots.derive(r =>
r > 0 ? `Waiting for ${r} players` : 'Go!',
),
}),
UINode.if(
remainingSlots.derive(r => r > 0),
Pressable({
children: Text({
text: hasClicked.derive(h => (h ? 'Cancel' : 'Get Ready')),
}),
onClick: player => {
hasClicked.set(
h => {
remainingSlots.set(r => r + (h ? 1 : -1));
return !h;
},
[player],
);
},
}),
),
],
});
}
}
Now that we have seen two implementations of the same UI from Alice and Bob, which
one is better?
Alice thinks hers is better, because she explicitly maintains a set of players who
have clicked the button, so it’s easier to debug if anything goes wrong. It is also
easier to talk to other scripts in her game if other scripts need the same
information.
Bob thinks his implementation is better. There are fewer Bindings, and the code in
general is more concise. All the states are controlled by the Bindings, so he
doesn’t need to manually make sure the external storage and the Bindings are
synced.
So which one is better? It is completely up to you! The “correct” choice depends on
many factors: your familiarity with different techniques, the coding styles set by
your team, how your scripts talk to the rest of your gaming state management
system, etc. This section is not choosing one coding style for you. Rather, it is
demonstrating the capabilities of the Custom UI feature for you to better
understand the pros and cons of each approach.
Player-specific Bindings and UIs are powerful tools, but would require some time to
get used to. Happy coding!