Multiplayer 7. The lobby scene
While you'd normally expect the lobby scene to be the start of the climatic end, it's just the beginning for us, which is fairly obvious if you've been paying attention as we've not even started looking into the game mechanics yet.
This is probably going to be the longest blog post of these tutorials, as it's quite a complex topic we're going to cover and it's going to be best to cover it in one go.
By the end of this, you should have a basic but fully working lobby for your game that clients can join, change their name and team, flag themselves as ready, and then start a game. The server's going to have some additional settings that only they can play around with, so they can bias the game towards something that suits them.
Act one, scene Lobby
We'll start, then, by creating a simple Lobby scene. To begin with, this will be an empty scene. We'll populate it later with some UI wizardry. After you've created your Lobby scene, remember to add it to the Build Settings.
If we go back to the Offline scene and look at the Network Manager, we'll see the online scene option is set to Level A. Let's replace that with the Lobby scene. This means that whenever we start a new game, the Lobby will be what's loaded first.
The reasoning behind this is that we're not going to let users progress past the lobby unless there are the correct number of players on the correct teams.
Time to rewrite step 2 (again)
In the second multiplayer blog we focused on creating a mockup to show how to pass variables around when changing scene. Now's come the time to update the LocalPlayerDataStore class (where we store the local player's settings when changing scenes) and the PlayerDataForClients class (how was send and read all player's settings, and keep them in sync). We'll also update the LocalPlayerDataManager class to give our players some default values when they join our lobby.
In the step 2 mockup we looked at creating a random colour and keeping that same colour as we changed scene. Now we're going store and pass around another three values. The team of the player, their name, and if they're ready. We'll also strip the colour change and make it purely dependant on the player's team choice.
// Player/SyncedData/LocalPlayerDataStore.cs
using UnityEngine;
namespace Player.SyncedData {
public class LocalPlayerDataStore {
private static LocalPlayerDataStore instance;
public string playerName = "";
public int team = 0;
public bool isReady = false;
public bool isServer = false;
private LocalPlayerDataStore () { }
public static LocalPlayerDataStore GetInstance ()
{
if (instance == null) {
instance = new LocalPlayerDataStore();
}
return instance;
}
}
}
We'll start easy here. As with before, this code simply provided a stateful object for storing the local player's settings. These are used by the next piece of code so that whenever a scene is started, these are read and sent to the server for distribution to the other clients.
// Player/SyncedData/PlayerDataForClients.cs
using UnityEngine;
using UnityEngine.Networking;
namespace Player.SyncedData {
public class PlayerDataForClients : NetworkBehaviour {
public const int TEAM_VIP = 1;
public const int TEAM_INHUMER = 2;
public delegate void NameUpdated(GameObject player, string playerName);
public event NameUpdated OnNameUpdated;
public delegate void TeamUpdated(GameObject player, int team);
public event TeamUpdated OnTeamUpdated;
public delegate void IsReadyFlagUpdated(GameObject player, bool isReady);
public event IsReadyFlagUpdated OnIsReadyFlagUpdated;
public delegate void IsServerFlagUpdated (GameObject player, bool isServer);
public event IsServerFlagUpdated OnIsServerFlagUpdated;
[SyncVar(hook = "UpdateName")]
private string playerName;
[SyncVar(hook = "UpdateTeam")]
private int team;
[SyncVar(hook = "UpdateIsReadyFlag")]
private bool isReadyFlag;
[SyncVar(hook = "UpdateIsServerFlag")]
private bool isServerFlag;
public override void OnStartClient()
{
// don't update for local player as handled by LocalPlayerOptionsManager
// don't update for server as the server will know on Command call from local player
if (!isLocalPlayer && !isServer) {
UpdateName(playerName);
UpdateTeam(team);
UpdateIsReadyFlag(isReadyFlag);
UpdateIsServerFlag(isServerFlag);
}
}
public string GetName()
{
return playerName;
}
[Client]
public void SetName(string newName)
{
CmdSetName(newName);
}
[Command]
public void CmdSetName(string newName)
{
playerName = newName;
}
[Client]
public void UpdateName(string newName)
{
playerName = newName;
if (this.OnNameUpdated != null) {
this.OnNameUpdated(gameObject, newName);
}
}
public int GetTeam()
{
return team;
}
[Client]
public void SetTeam(int newTeam)
{
CmdSetTeam(newTeam);
}
[Command]
public void CmdSetTeam(int newTeam)
{
team = newTeam;
}
[Client]
public void UpdateTeam(int newTeam)
{
team = newTeam;
if (this.OnTeamUpdated != null) {
this.OnTeamUpdated(gameObject, newTeam);
}
}
public bool GetIsReadyFlag()
{
return isReadyFlag;
}
[Client]
public void SetIsReadyFlag(bool newIsReady)
{
CmdSetIsReadyFlag(newIsReady);
}
[Command]
public void CmdSetIsReadyFlag(bool newIsReady)
{
isReadyFlag = newIsReady;
}
[Client]
public void UpdateIsReadyFlag(bool newIsReady)
{
isReadyFlag = newIsReady;
if (this.OnIsReadyFlagUpdated != null) {
this.OnIsReadyFlagUpdated(gameObject, newIsReady);
}
}
public bool GetIsServerFlag()
{
return isServerFlag;
}
[Client]
public void SetIsServerFlag(bool newIsServer)
{
CmdSetIsServerFlag(newIsServer);
}
[Command]
public void CmdSetIsServerFlag(bool newIsServer)
{
isServerFlag = newIsServer;
}
[Client]
public void UpdateIsServerFlag(bool newIsServer)
{
isServerFlag = newIsServer;
if (this.OnIsServerFlagUpdated != null) {
this.OnIsServerFlagUpdated(gameObject, newIsServer);
}
}
}
}
This method ends up being quite verbose. We could probably put the effort into simplifying it using an interface that store the correct value type, but there aren't many variables we need to pass between all clients in this manner so it's probably better code to keep it with it's many, many methods above.
This next method has changed a bit now though:
// Player/SyncedData/LocalPlayerDataManager.cs
using GameState;
using UnityEngine;
using UnityEngine.Networking;
namespace Player.SyncedData {
public class LocalPlayerDataManager : NetworkBehaviour {
public PlayerDataForClients clientData;
private string[] names = new string[] { "Adam", "Betty", "Charles", "Deborah", "Eddy", "Francis", "Gerald", "Holly" };
private int[] teams = new int[] { PlayerDataForClients.TEAM_VIP, PlayerDataForClients.TEAM_INHUMER };
public override void OnStartLocalPlayer()
{
LocalPlayerDataStore store = LocalPlayerDataStore.GetInstance();
State.GetInstance().Subscribe(
new StateOption().GameState(State.GAME_OFFLINE),
() => {
if (clientData != null) {
clientData.SetName("");
clientData.SetTeam(0);
clientData.SetIsServerFlag(false);
clientData.SetIsReadyFlag(false);
}
else {
store.playerName = "";
store.team = 0;
store.isReady = false;
store.isServer = false;
}
}
);
CreateDefaultValues();
clientData.SetName(store.playerName);
clientData.SetTeam(store.team);
clientData.SetIsReadyFlag(store.isReady);
clientData.SetIsServerFlag(store.isServer);
clientData.OnNameUpdated += OnNameUpdated;
clientData.OnTeamUpdated += OnTeamUpdated;
clientData.OnIsReadyFlagUpdated += OnIsReadyFlagUpdated;
clientData.OnIsServerFlagUpdated += OnIsServerFlagUpdated;
}
private void CreateDefaultValues ()
{
LocalPlayerDataStore store = LocalPlayerDataStore.GetInstance();
if (store.playerName != "" || store.team != 0 | store.isServer != false || store.isReady != false) {
return;
}
store.playerName = names[Random.Range(0, 8)];
store.team = teams[Random.Range(0, 2)];
if (State.GetInstance().Network() == State.NETWORK_SERVER) {
store.isServer = true;
}
}
public void OnNameUpdated(GameObject player, string newName)
{
LocalPlayerDataStore.GetInstance().playerName = newName;
}
public void OnTeamUpdated(GameObject player, int newTeam)
{
LocalPlayerDataStore.GetInstance().team = newTeam;
}
public void OnIsReadyFlagUpdated(GameObject player, bool isReady)
{
LocalPlayerDataStore.GetInstance().isReady = isReady;
}
public void OnIsServerFlagUpdated (GameObject player, bool isServer)
{
LocalPlayerDataStore.GetInstance().isServer = isServer;
}
}
}
While the local data manager is similar to before, the OnStartLocalPlayer()
method has changed significantly. We want to make sure our players can get into the game as fast as possible. Therefore we're going to randomly assign them a name and a team.
We've not really had to worry about players dropping in and out of the game before. With the lobby it's likely to happen more often so we need to keep a tight grip on their variables. As such, we reset them whenever they return to the Offline scene.
Tracking's more of a team game
In a previous blog, we covered tracking individual players. Now we have additional data related to the players in teams, we're also going to want to track that too. We don't want to rewrite the previous player tracking code though as that's quite functional, so in this case we're going to create a new tracker.
// Player/Tracking/PlayerTracker.cs
using Player.SyncedData;
using UnityEngine;
namespace Player.Tracking {
public class TeamTracker {
private static TeamTracker instance;
public delegate void TeamChanged(int numVIPs, int numInhumers);
public event TeamChanged OnTeamChanged;
private int vips = 0;
private int inhumers = 0;
private TeamTracker()
{
foreach (GameObject player in PlayerTracker.GetInstance().GetPlayers()) {
AddTeamTrackingToPlayers(player);
}
PlayerTracker.GetInstance().OnPlayerAdded += AddTeamTrackingToPlayers;
PlayerTracker.GetInstance().OnPlayerRemoved += (GameObject player) => {
CountTeams();
};
}
public static TeamTracker GetInstance()
{
if (instance == null) {
instance = new TeamTracker();
}
return instance;
}
public void ForceRecount()
{
CountTeams();
}
private void AddTeamTrackingToPlayers(GameObject player)
{
player.GetComponent<PlayerDataForClients>().OnTeamUpdated += (GameObject localPlayer, int teamId) => {
CountTeams();
};
}
private void CountTeams()
{
vips = inhumers = 0;
foreach (GameObject player in PlayerTracker.GetInstance().GetPlayers()) {
if (player.GetComponent<PlayerDataForClients>().GetTeam() == PlayerDataForClients.TEAM_VIP) {
vips++;
}
else {
inhumers++;
}
}
if (OnTeamChanged != null) {
OnTeamChanged(vips, inhumers);
}
}
public int[] GetTeams()
{
return new int[] { vips, inhumers };
}
}
}
This code is again a singleton, but notice that whenever a new instance of this tracker is created, it automatically reads the current state from the PlayerTracker object, and then adds hooks to it so it can stay up-to-date. The hooks in this case mean that whenever a player is added to or removed from the tracker, this TeamTracker will automatically trigger a recount of the teams, and then fire it's own event to whoever's listening.
Now we have some code that will, not only autonomously keep track of all players and which teams they're in, but we don't have to worry about passing it around via a game object reference.
The ForceRecount()
method will be used from one of the UI classes. Sometimes the UI will know better when it wants a recount.
Beauty is in the UIs of the beholder
We now have the events required to pass data between our local client and all other clients, so it's time to look at building the UI that will control our lobby.
Above's an image of what the lobby will look like. We're going to break this down into sections to make the UI code a bit more manageable. The area on the left with the players and the scrollbar will be our Entries section. The entries will take two separate UIs. One for a local player and one for remote players.
Everything else to the right of it we'll call the Settings section. This Settings section will need to have a client and a server option. Let's start with the settings then.
// UI/Lobby/SettingsUI.cs
using GameState;
using Modal;
using Player.SyncedData;
using Player.Tracking;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
namespace UI.Lobby {
public class SettingsUI : NetworkBehaviour {
[SyncVar]
private string level = "Waiting for first choice";
[SyncVar]
private string timeLimit = "Waiting for first choice";
public GameObject clientUI;
public GameObject serverUI;
public Dropdown serverLevelSelect;
public Dropdown serverTimeSelect;
public Text serverIPAddress;
public Text clientLevelSelect;
public Text clientTimeSelect;
public GameObject readyWaitingButton;
public GameObject readyNowButton;
public GameObject startGameButton;
public Text startGameButtonText;
private bool allowServerStart = false;
public void Start()
{
if (State.GetInstance().Network() == State.NETWORK_CLIENT) {
clientUI.SetActive(true);
}
else {
serverUI.SetActive(true);
ChangeLevel(serverLevelSelect);
ChangeTimeLimit(serverTimeSelect);
SetIPAddress();
}
}
public void OnGUI()
{
UpdateClientSettings();
UpdateServerStartButton();
}
[Server]
public void SetIPAddress()
{
serverIPAddress.text = Network.player.ipAddress;
}
[Server]
public void ChangeLevel(Dropdown target)
{
level = target.options[target.value].text;
}
[Server]
public void ChangeTimeLimit(Dropdown target)
{
timeLimit = target.options[target.value].text;
}
[Client]
public void SetReadyState(bool isReady)
{
PlayerTracker.GetInstance().GetLocalPlayer().GetComponent<PlayerDataForClients>().SetIsReadyFlag(isReady);
if (readyWaitingButton == null || readyNowButton == null) {
return;
}
readyWaitingButton.SetActive(!isReady);
readyNowButton.SetActive(isReady);
}
public void LeaveLobby()
{
if (State.GetInstance().Network() == State.NETWORK_CLIENT) {
NetworkManager.singleton.StopClient();
}
if (State.GetInstance().Network() == State.NETWORK_SERVER) {
NetworkManager.singleton.StopHost();
}
State.GetInstance().Game(State.GAME_DISCONNECTING);
}
public void StartGame()
{
if (!allowServerStart) {
return;
}
ModalManager.GetInstance().Show(
"Ready to start the game?",
"Yes!",
"Not yet...",
() => {
NetworkManager.singleton.ServerChangeScene(serverLevelSelect.value == 0 ? "Level A" : "Level B");
},
() => {
ModalManager.GetInstance().Hide();
}
);
}
[ClientCallback]
private void UpdateClientSettings()
{
clientLevelSelect.text = level;
clientTimeSelect.text = timeLimit;
}
[ServerCallback]
private void UpdateServerStartButton()
{
int[] teams = TeamTracker.GetInstance().GetTeams();
if (teams[0] == 0 || teams[1] == 0) {
startGameButtonText.text = "Waiting for teams";
allowServerStart = false;
return;
}
bool allReady = true;
foreach (GameObject player in PlayerTracker.GetInstance().GetPlayers()) {
if (!player) {
continue;
}
PlayerDataForClients settings = player.GetComponent<PlayerDataForClients>();
if (!settings.GetIsReadyFlag() && !settings.GetIsServerFlag()) {
allReady = false;
}
}
startGameButtonText.text = allReady ? "Start game" : "Waiting on ready";
allowServerStart = allReady;
}
}
}
We're going to store the game settings in this class for the moment and use [SyncVar] to share them between our server and it's clients.
There shouldn't be too much complexity in this class as it's mainly just passing data from the UI to our other classes. You can see a fair amount of use of the State manager code we created as the UI needs to know who's looking at it.
With this lobby, every client must set their Ready flag before the game can be played. The server has no ready flag as they can only go once everyone else has signalled the game can start. This is mainly handled in the SetReadyState()
and the UpdateServerStartButton()
methods.
On to the entries code then. This code relies heavily on our PlayerTracker and TeamTracker events to make sure that the lobby is showing the correct players.
// UI/Lobby/EntriesUI.cs
using Player.Tracking;
using System.Collections.Generic;
using UI.Lobby.Player;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
namespace UI.Lobby {
public class EntriesUI : NetworkBehaviour {
public GameObject viewportContent;
public Text versusText;
public GameObject localPlayerEntryPrefab;
public GameObject otherPlayerEntryPrefab;
public int entryPrefabHeight = 80;
private Dictionary<int, GameObject> lobbyEntries = new Dictionary<int, GameObject>();
// on start instead of awake to make sure it happens afterwards
public void Start()
{
foreach (GameObject player in PlayerTracker.GetInstance().GetPlayers()) {
NewLobbyPlayerAdded(player);
}
PlayerTracker.GetInstance().OnPlayerAdded += NewLobbyPlayerAdded;
PlayerTracker.GetInstance().OnPlayerRemoved += OldLobbyPlayerRemoved;
TeamTracker.GetInstance().OnTeamChanged += UpdateVersusText;
TeamTracker.GetInstance().ForceRecount();
}
private void NewLobbyPlayerAdded(GameObject player)
{
if (viewportContent == null) {
return;
}
bool isLocalPlayer = player == PlayerTracker.GetInstance().GetLocalPlayer();
GameObject lobbyEntry = Instantiate(isLocalPlayer ? localPlayerEntryPrefab : otherPlayerEntryPrefab);
lobbyEntry.transform.SetParent(viewportContent.transform, false);
lobbyEntry.GetComponent<EntryInterface>().SetPlayerObject(player);
lobbyEntries.Add(player.GetInstanceID(), lobbyEntry);
AlignLobbyEntriesInViewport();
}
private void OldLobbyPlayerRemoved(GameObject player)
{
if (!lobbyEntries.ContainsKey(player.GetInstanceID())) {
return;
}
Destroy(lobbyEntries[player.GetInstanceID()]);
lobbyEntries.Remove(player.GetInstanceID());
AlignLobbyEntriesInViewport();
}
private void AlignLobbyEntriesInViewport()
{
if (viewportContent == null) {
return;
}
int counter = 0;
foreach (GameObject player in lobbyEntries.Values) {
Vector3 localPos = player.GetComponent<RectTransform>().localPosition;
player.GetComponent<RectTransform>().localPosition = new Vector3(localPos.x, -(entryPrefabHeight / 2) + (-(entryPrefabHeight + 2) * counter), localPos.z);
counter ++;
}
RectTransform transform = viewportContent.GetComponent<RectTransform>();
transform.sizeDelta = new Vector2(transform.sizeDelta.x, counter * (entryPrefabHeight + 2));
}
private void UpdateVersusText(int vips, int inhumers)
{
if (versusText != null) {
versusText.text = vips + " vs " + inhumers;
}
}
}
}
To appear in the lobby, a new object is instantiated by this code, and kept up-to-date by code within itself (see below). We then need to manually align the player's UI entries every time they change. Quite odd how Unity doesn't do this itself, but it doesn't.
Last for this method is the text at the top of the lobby. This tells us how many are on each team because it looks nice, and it shows how to use the TeamTracker.
These next three code snippets are the code for the entries themselves. The remote entry code takes changes from the PlayerDataForClients object via event and replicates them on the UI, and the local entry code sends changes to the PlayerDataForClients object, ready to be synced to the other clients.
// UI/Lobby/Player/EntryInterface.cs
using UnityEngine;
namespace UI.Lobby.Player {
public interface EntryInterface {
void SetPlayerObject(GameObject playerObject);
}
}
// UI/Lobby/Player/RemoteEntryUI.cs
using Player.SyncedData;
using UnityEngine;
using UnityEngine.UI;
namespace UI.Lobby.Player
{
public class RemoteEntryUI : MonoBehaviour, EntryInterface
{
public GameObject isReadyBackground;
public GameObject isServerBackground;
public Text nameText;
public Text teamText;
private PlayerDataForClients settings;
public void SetPlayerObject(GameObject player)
{
settings = player.GetComponent<PlayerDataForClients>();
// force a change when setup so we have initial settings
UpdateNameFromSettings(player, settings.GetName());
UpdateTeamFromSettings(player, settings.GetTeam());
UpdateReadyFlagFromSettings(player, settings.GetIsReadyFlag());
UpdateServerFlagFromSettings(player, settings.GetIsServerFlag());
// set up events so when client player settings change, hud updates
settings.OnNameUpdated += UpdateNameFromSettings;
settings.OnTeamUpdated += UpdateTeamFromSettings;
settings.OnIsReadyFlagUpdated += UpdateReadyFlagFromSettings;
settings.OnIsServerFlagUpdated += UpdateServerFlagFromSettings;
}
// used when PlayerDataForClients changes name
public void UpdateNameFromSettings(GameObject player, string name)
{
nameText.text = name;
}
// used when PlayerDataForClients changes team
public void UpdateTeamFromSettings(GameObject player, int teamId)
{
if (teamId == PlayerDataForClients.TEAM_VIP) {
teamText.text = "VIP";
}
if (teamId == PlayerDataForClients.TEAM_INHUMER) {
teamText.text = "Inhumer";
}
}
public void UpdateReadyFlagFromSettings (GameObject player, bool isReady)
{
isReadyBackground.SetActive(isReady);
}
public void UpdateServerFlagFromSettings(GameObject player, bool isServer)
{
isServerBackground.SetActive(isServer);
}
}
}
// UI/Lobby/Player/LocalEntryUI.cs
using Player.SyncedData;
using UnityEngine;
using UnityEngine.UI;
namespace UI.Lobby.Player
{
public class LocalEntryUI : MonoBehaviour, EntryInterface
{
public InputField nameInputField;
public GameObject vipButton;
public GameObject inhumerButton;
public GameObject readyBackground;
public Text nameText;
public Text teamText;
private PlayerDataForClients settings;
public void SetPlayerObject(GameObject player)
{
settings = player.GetComponent<PlayerDataForClients>();
UpdateNameFromSettings(player, settings.GetName());
UpdateTeamWithSettings(player, settings.GetTeam());
UpdateReadyFlagFromSettings(player, settings.GetIsReadyFlag());
settings.OnNameUpdated += UpdateNameFromSettings;
settings.OnTeamUpdated += UpdateTeamWithSettings;
settings.OnIsReadyFlagUpdated += UpdateReadyFlagFromSettings;
}
// sent from UI to change name
public void SendNameToSettings(InputField nameText)
{
settings.SetName(nameText.text);
}
// sent from UI to change team
public void SendTeamToSettings(int teamId)
{
settings.SetTeam(teamId);
}
public void UpdateNameFromSettings(GameObject player, string name)
{
nameInputField.text = name;
nameText.text = nameInputField.text;
}
private void UpdateTeamWithSettings(GameObject player, int teamId)
{
if (teamId == PlayerDataForClients.TEAM_VIP) {
if (vipButton) vipButton.SetActive(true);
if (inhumerButton) inhumerButton.SetActive(false);
teamText.text = "VIP";
}
if (teamId == PlayerDataForClients.TEAM_INHUMER) {
if (vipButton) vipButton.SetActive(false);
if (inhumerButton) inhumerButton.SetActive(true);
teamText.text = "Inhumer";
}
}
public void UpdateReadyFlagFromSettings(GameObject player, bool isReady)
{
readyBackground.SetActive(isReady);
nameText.gameObject.SetActive(isReady);
teamText.gameObject.SetActive(isReady);
nameInputField.gameObject.SetActive(!isReady);
if (isReady) {
vipButton.SetActive(false);
inhumerButton.SetActive(false);
}
else {
UpdateTeamWithSettings(player, settings.GetTeam());
}
}
}
}
That's all of the code we require, so now all that's left is listing the UI work itself. There's a lot of this. Sorry.
UI for me, UI for you
There are three separate UI prefabs for this Lobby scene. The first is rather epically long and is the main UI for both client and server. Save this as LobbyUI.
Next up we'll cover the UI for a local player. Save this as a prefab called LobbyLocalPlayerUI. After creating it, you'll need to save it back into the LobbyUI, in the EntriesUI script.
We're almost there now. We've only got the remote player UI to go. Save this as a prefab called LobbyRemotePlayerUI and also attach to the LobbyUI.
Now we're completed a marathon UI session, here's a video showing how to lobby should look and work.
If you want the code for this post, you can find it at github.com/dittto/unity-multiplayer-7.