Skip to content

feat: client-authoritative character select #401

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Assets/BossRoom/Scenes/CharSelect.unity
Git LFS file not shown
159 changes: 120 additions & 39 deletions Assets/BossRoom/Scripts/Client/Game/State/ClientCharSelectState.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using TMPro;
using Unity.Netcode;
Expand All @@ -19,66 +18,69 @@ public class ClientCharSelectState : GameStateBehaviour
/// </summary>
public static ClientCharSelectState Instance { get; private set; }

public override GameState ActiveState { get { return GameState.CharSelect; } }
public override GameState ActiveState => GameState.CharSelect;

public CharSelectData CharSelectData { get; private set; }

[SerializeField]
[Tooltip("This is triggered when the player chooses a character")]
private string m_AnimationTriggerOnCharSelect = "BeginRevive";
string m_AnimationTriggerOnCharSelect = "BeginRevive";

[SerializeField]
[Tooltip("This is triggered when the player presses the \"Ready\" button")]
private string m_AnimationTriggerOnCharChosen = "BeginRevive";
string m_AnimationTriggerOnCharChosen = "BeginRevive";

[Header("Lobby Seats")]
[SerializeField]
[Tooltip("Collection of 8 portrait-boxes, one for each potential lobby member")]
private List<UICharSelectPlayerSeat> m_PlayerSeats;
List<UICharSelectPlayerSeat> m_UIPlayerSeats;

[System.Serializable]
[Serializable]
public class ColorAndIndicator
{
public Sprite Indicator;
public Color Color;
}

[Tooltip("Representational information for each player")]
public ColorAndIndicator[] m_IdentifiersForEachPlayerNumber;

[SerializeField]
[Tooltip("Text element containing player count which updates as players connect")]
private TextMeshProUGUI m_NumPlayersText;
TextMeshProUGUI m_NumPlayersText;

[SerializeField]
[Tooltip("Text element for the Ready button")]
private TextMeshProUGUI m_ReadyButtonText;
TextMeshProUGUI m_ReadyButtonText;

[Header("UI Elements for different lobby modes")]
[SerializeField]
[Tooltip("UI elements to turn on when the player hasn't chosen their seat yet. Turned off otherwise!")]
private List<GameObject> m_UIElementsForNoSeatChosen;
List<GameObject> m_UIElementsForNoSeatChosen;

[SerializeField]
[Tooltip("UI elements to turn on when the player has locked in their seat choice (and is now waiting for other players to do the same). Turned off otherwise!")]
private List<GameObject> m_UIElementsForSeatChosen;
List<GameObject> m_UIElementsForSeatChosen;

[SerializeField]
[Tooltip("UI elements to turn on when the lobby is closed (and game is about to start). Turned off otherwise!")]
private List<GameObject> m_UIElementsForLobbyEnding;
List<GameObject> m_UIElementsForLobbyEnding;

[SerializeField]
[Tooltip("UI elements to turn on when there's been a fatal error (and the client cannot proceed). Turned off otherwise!")]
private List<GameObject> m_UIElementsForFatalError;
List<GameObject> m_UIElementsForFatalError;

[Header("Misc")]
[SerializeField]
[Tooltip("The controller for the class-info box")]
private UICharSelectClassInfoBox m_ClassInfoBox;
UICharSelectClassInfoBox m_ClassInfoBox;

[SerializeField]
Transform m_CharacterGraphicsParent;

private int m_LastSeatSelected = -1;
private bool m_HasLocalPlayerLockedIn = false;
int m_LastSeatSelected = -1;

bool m_HasLocalPlayerLockedIn;

GameObject m_CurrentCharacterGraphics;

Expand All @@ -92,17 +94,17 @@ public class ColorAndIndicator
/// an abstraction that makes it easier to configure which UI elements should
/// be enabled/disabled in each stage of the lobby.
/// </summary>
private enum LobbyMode
enum LobbyMode
{
ChooseSeat, // "Choose your seat!" stage
SeatChosen, // "Waiting for other players!" stage
LobbyEnding, // "Get ready! Game is starting!" stage
FatalError, // "Fatal Error" stage
}

private Dictionary<LobbyMode, List<GameObject>> m_LobbyUIElementsByMode;
Dictionary<LobbyMode, List<GameObject>> m_LobbyUIElementsByMode;

private void Awake()
void Awake()
{
Instance = this;
CharSelectData = GetComponent<CharSelectData>();
Expand All @@ -118,9 +120,9 @@ private void Awake()
protected override void Start()
{
base.Start();
for (int i = 0; i < m_PlayerSeats.Count; ++i)
for (int i = 0; i < m_UIPlayerSeats.Count; ++i)
{
m_PlayerSeats[i].Initialize(i);
m_UIPlayerSeats[i].Initialize(i);
}

ConfigureUIForLobbyMode(LobbyMode.ChooseSeat);
Expand Down Expand Up @@ -158,23 +160,45 @@ public override void OnNetworkSpawn()
/// Called when our PlayerNumber (e.g. P1, P2, etc.) has been assigned by the server
/// </summary>
/// <param name="playerNum"></param>
private void OnAssignedPlayerNumber(int playerNum)
void OnAssignedPlayerNumber(int playerNum)
{
m_ClassInfoBox.OnSetPlayerNumber(playerNum);
}

private void UpdatePlayerCount()
void UpdatePlayerCount()
{
int count = CharSelectData.LobbyPlayers.Count;
var pstr = (count > 1) ? "players" : "player";
m_NumPlayersText.text = "<b>" + count + "</b> " + pstr +" connected";
var count = CharSelectData.LobbyPlayers.Count;
m_NumPlayersText.text = "<b>" + count + "</b> " + "player(s) connected";
}

/// <summary>
/// Called by the server when any of the seats in the lobby have changed. (Including ours!)
/// </summary>
private void OnLobbyPlayerStateChanged(NetworkListEvent<CharSelectData.LobbyPlayerState> changeEvent)
/// <remarks>
/// This method serves as the conflict resolution of all player seats. A seat change request is initially sent
/// by a client via a ServerRPC (see OnPlayerClickedSeat method), the server modifies a NetworkList, and that
/// list change event is replicated to all clients.
/// Since the local player's seat is modified inside this class' OnPlayerChangedSeat method directly when a UI
/// element is selected, a NetworkListEvent that contains any local seat change will be simply ignored.
/// </remarks>
void OnLobbyPlayerStateChanged(NetworkListEvent<CharSelectData.LobbyPlayerState> changeEvent)
{
// ignore state changes for the local player unless the change event is a locked in event, or when seat
// has been invalidated (both server-authoritative)
if (changeEvent.Value.ClientId == NetworkManager.Singleton.LocalClientId)
{
var isLockedInEvent =
(changeEvent.Value.SeatState == CharSelectData.SeatState.LockedIn &&
changeEvent.PreviousValue.SeatState != CharSelectData.SeatState.LockedIn) ||
(changeEvent.Value.SeatState == CharSelectData.SeatState.Active &&
changeEvent.PreviousValue.SeatState == CharSelectData.SeatState.LockedIn);

if (!isLockedInEvent && changeEvent.Value.IsValid())
{
return;
}
}

UpdateSeats();
UpdatePlayerCount();

Expand Down Expand Up @@ -216,7 +240,7 @@ private void OnLobbyPlayerStateChanged(NetworkListEvent<CharSelectData.LobbyPlay
/// </summary>
/// <param name="state">Our current seat state</param>
/// <param name="seatIdx">Which seat we're sitting in, or -1 if SeatState is Inactive</param>
private void UpdateCharacterSelection(CharSelectData.SeatState state, int seatIdx = -1)
void UpdateCharacterSelection(CharSelectData.SeatState state, int seatIdx = -1)
{
bool isNewSeat = m_LastSeatSelected != seatIdx;

Expand Down Expand Up @@ -279,16 +303,16 @@ private void UpdateCharacterSelection(CharSelectData.SeatState state, int seatId
/// <summary>
/// Internal utility that sets the graphics for the eight lobby-seats (based on their current networked state)
/// </summary>
private void UpdateSeats()
void UpdateSeats()
{
// Players can hop between seats -- and can even SHARE seats -- while they're choosing a class.
// Once they have chosen their class (by "locking in" their seat), other players in that seat are kicked out.
// But until a seat is locked in, we need to display each seat as being used by the latest player to choose it.
// So we go through all players and figure out who should visually be shown as sitting in that seat.
CharSelectData.LobbyPlayerState[] curSeats = new CharSelectData.LobbyPlayerState[m_PlayerSeats.Count];
CharSelectData.LobbyPlayerState[] curSeats = new CharSelectData.LobbyPlayerState[m_UIPlayerSeats.Count];
foreach (CharSelectData.LobbyPlayerState playerState in CharSelectData.LobbyPlayers)
{
if (playerState.SeatIdx == -1 || playerState.SeatState == CharSelectData.SeatState.Inactive)
if (!playerState.IsValid() || playerState.SeatState == CharSelectData.SeatState.Inactive)
continue; // this player isn't seated at all!
if ( curSeats[playerState.SeatIdx].SeatState == CharSelectData.SeatState.Inactive
|| (curSeats[playerState.SeatIdx].SeatState == CharSelectData.SeatState.Active && curSeats[playerState.SeatIdx].LastChangeTime < playerState.LastChangeTime))
Expand All @@ -299,16 +323,16 @@ private void UpdateSeats()
}

// now actually update the seats in the UI
for (int i = 0; i < m_PlayerSeats.Count; ++i)
for (int i = 0; i < m_UIPlayerSeats.Count; ++i)
{
m_PlayerSeats[i].SetState(curSeats[i].SeatState, curSeats[i].PlayerNum, curSeats[i].PlayerName);
m_UIPlayerSeats[i].SetState(curSeats[i].SeatState, curSeats[i].PlayerNum, curSeats[i].PlayerName);
}
}

/// <summary>
/// Called by the server when the lobby closes (because all players are seated and locked in)
/// </summary>
private void OnLobbyClosedChanged(bool wasLobbyClosed, bool isLobbyClosed)
void OnLobbyClosedChanged(bool wasLobbyClosed, bool isLobbyClosed)
{
if (isLobbyClosed)
{
Expand All @@ -321,7 +345,7 @@ private void OnLobbyClosedChanged(bool wasLobbyClosed, bool isLobbyClosed)
/// It can also disable/enable the lobby seats and the "Ready" button if they are inappropriate for the
/// given mode.
/// </summary>
private void ConfigureUIForLobbyMode(LobbyMode mode)
void ConfigureUIForLobbyMode(LobbyMode mode)
{
// first the easy bit: turn off all the inappropriate ui elements, and turn the appropriate ones on!
foreach (var list in m_LobbyUIElementsByMode.Values)
Expand Down Expand Up @@ -368,7 +392,7 @@ private void ConfigureUIForLobbyMode(LobbyMode mode)
}

// go through all our seats and enable or disable buttons
foreach (var seat in m_PlayerSeats)
foreach (var seat in m_UIPlayerSeats)
{
// disable interaction if seat is already locked or all seats disabled
seat.SetDisableInteraction(seat.IsLocked() || isSeatsDisabledInThisMode);
Expand All @@ -379,9 +403,51 @@ private void ConfigureUIForLobbyMode(LobbyMode mode)
/// <summary>
/// Called directly by UI elements!
/// </summary>
/// <remarks>
/// For a reactive character select screen, the local player's selection is presented instantaneously
/// before a NetworkListEvent can be received from the server.
/// To accomplish this, the previous seat selected will be cleared or re-populated by a shared player, and
/// the new seat will be populated with the local selection.
/// </remarks>
/// <param name="seatIdx"></param>
public void OnPlayerClickedSeat(int seatIdx)
{
if (m_LastSeatSelected != -1)
{
var otherPlayerSharesSeat = false;

// if any other user shares seat, set them as owner; else deactivate seat
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"owner"? I thought the lockedIn state was server driven?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a player chooses a new seat, I need to clear the local player's previous seat selection from the UI. But sometimes that seat is shared by another player.

Whether that previous seat now becomes empty, or "held" by another player is determined by the last update that was received by the server on the seats.

foreach (var lobbyPlayer in CharSelectData.LobbyPlayers)
{
if (lobbyPlayer.ClientId == NetworkManager.Singleton.LocalClientId)
{
// ignore self;
continue;
}

if (lobbyPlayer.SeatIdx == m_LastSeatSelected)
{
// populate this seat with a shared player's lobby player state
otherPlayerSharesSeat = true;
m_UIPlayerSeats[m_LastSeatSelected].SetState(lobbyPlayer.SeatState, lobbyPlayer.PlayerNum, lobbyPlayer.PlayerName);
}
}

if (!otherPlayerSharesSeat)
{
// no other player shared this seat; it is safe to just disable
m_UIPlayerSeats[m_LastSeatSelected].SetState(CharSelectData.SeatState.Inactive, -1, string.Empty);
}
}

// get local player, and populate the seat that is anticipated to be taken
TryGetLobbyPlayer(NetworkManager.Singleton.LocalClientId, out var localLobbyPlayerState);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be worth to note this is the client drive part where we take the seat without waiting for the server driven value


// apply seat change directly to local player without waiting for server-driven NetworkList event
m_UIPlayerSeats[seatIdx].SetState(CharSelectData.SeatState.Active, localLobbyPlayerState.PlayerNum, localLobbyPlayerState.PlayerName);
UpdateCharacterSelection(CharSelectData.SeatState.Active, seatIdx);

// send server rpc containing selection
CharSelectData.ChangeSeatServerRpc(NetworkManager.Singleton.LocalClientId, seatIdx, false);
}

Expand All @@ -406,9 +472,24 @@ public void OnPlayerExit()
SceneManager.LoadScene("MainMenu");
}

bool TryGetLobbyPlayer(ulong clientId, out CharSelectData.LobbyPlayerState lobbyPlayerState)
{
foreach (var lobbyPlayer in CharSelectData.LobbyPlayers)
{
if (lobbyPlayer.ClientId == clientId)
{
lobbyPlayerState = lobbyPlayer;
return true;
}
}

lobbyPlayerState = default;
return false;
}

GameObject GetCharacterGraphics(Avatar avatar)
{
if (!m_SpawnedCharacterGraphics.TryGetValue(avatar.Guid, out GameObject characterGraphics))
if (!m_SpawnedCharacterGraphics.TryGetValue(avatar.Guid, out var characterGraphics))
{
characterGraphics = Instantiate(avatar.GraphicsCharacterSelect, m_CharacterGraphicsParent);
m_SpawnedCharacterGraphics.Add(avatar.Guid, characterGraphics);
Expand All @@ -418,13 +499,13 @@ GameObject GetCharacterGraphics(Avatar avatar)
}

#if UNITY_EDITOR
private void OnValidate()
void OnValidate()
{
if (gameObject.scene.rootCount > 1) // Hacky way for checking if this is a scene object or a prefab instance and not a prefab definition.
{
while (m_PlayerSeats.Count < CharSelectData.k_MaxLobbyPlayers)
while (m_UIPlayerSeats.Count < CharSelectData.k_MaxLobbyPlayers)
{
m_PlayerSeats.Add(null);
m_UIPlayerSeats.Add(null);
}
}
}
Expand Down
3 changes: 0 additions & 3 deletions Assets/BossRoom/Scripts/Client/UI/UICharSelectPlayerSeat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ public class UICharSelectPlayerSeat : MonoBehaviour
[SerializeField]
private string m_AnimatorTriggerWhenUnlocked = "Unlocked";

[SerializeField]
private CharacterTypeEnum m_CharacterClass;

// just a way to designate which seat we are -- the leftmost seat on the lobby UI is index 0, the next one is index 1, etc.
private int m_SeatIndex;

Expand Down
5 changes: 5 additions & 0 deletions Assets/BossRoom/Scripts/Shared/Game/State/CharSelectData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ public bool Equals(LobbyPlayerState other)
LastChangeTime.Equals(other.LastChangeTime) &&
SeatState == other.SeatState;
}

public bool IsValid()
{
return SeatIdx != -1;
}
}

private NetworkList<LobbyPlayerState> m_LobbyPlayers;
Expand Down