UI/PlayerSlots.razor

A Razor UI component for the player slots panel. It renders a fixed number of player slots, shows avatars, local/ready indicators, and opens the friends list when clicking an empty slot. It orders connections with host first then by SteamId and computes a hash for UI change detection.

Networking
@using Sandbox;
@using Sandbox.UI;
@using Machines.Components;

@namespace Machines.UI
@inherits Panel

@{
	var lobbyState = LobbyFlow.Current?.State ?? LobbyState.Browsing;
	var showReadyChip = lobbyState != LobbyState.Launching;
	var connections = SortedConnections();
}

@for ( int i = 0; i < VisibleSlotCount; i++ )
{
	var slotIndex = i;
	// Stable sort so every machine renders the same order.
	var connection = slotIndex < connections.Count ? connections[slotIndex] : null;
	var occupied = connection != null;
	var isReady = occupied && IsReady( connection );
	var isLocal = occupied && connection == Connection.Local;
	var steamId = connection?.SteamId;

	<div @key=@($"slot-{slotIndex}-{(occupied ? (long)steamId : -1)}") class="slot slot-@(slotIndex + 1) @(occupied ? "occupied" : "empty") @(isLocal ? "is-local" : "") @(showReadyChip && isReady ? "is-ready" : "")" onclick=@(() => OnSlotClicked( slotIndex ))>
		@if ( occupied )
		{
			<div class="avatar-wrap">
				<image class="avatar" src="avatar:@(steamId)" />
				<div class="avatar-fade"></div>
			</div>
			@if ( isLocal )
			{
				<div class="number">@(Input.UsingController ? "🎮" : "⌨️")</div>
			}
			@if ( showReadyChip && isReady )
			{
				<div class="ready-check">✓</div>
			}
			<div class="player-label">@(isLocal ? "YOU" : $"P{slotIndex + 1}")</div>
		}
		else
		{
			<div class="placeholder question">?</div>
			<div class="placeholder invite">+</div>
		}
	</div>
}

@code
{
	/// <summary>
	/// Number of base empty slots always shown.
	/// </summary>
	public int BaseSlotCount { get; set; } = 4;

	/// <summary>
	/// Maximum total slots (extra slots only appear when occupied).
	/// </summary>
	public int MaxSlotCount { get; set; } = 8;

	/// <summary>
	/// Stable connection order: host first, then by SteamId.
	/// </summary>
	private static List<Connection> SortedConnections()
	{
		return Connection.All
			.OrderByDescending( c => c.IsHost )
			.ThenBy( c => c.SteamId.ValueUnsigned )
			.ToList();
	}

	private static bool IsReady( Connection c )
	{
		var ready = LobbyFlow.Current?.Ready;
		return ready != null && ready.TryGetValue( c.Id, out var r ) && r;
	}

	/// <summary>
	/// Slots to render: base count plus any occupied extras.
	/// </summary>
	private int VisibleSlotCount => Math.Min( Math.Max( BaseSlotCount, Connection.All.Count ), MaxSlotCount );

	private void OnSlotClicked( int slotIndex )
	{
		// Empty slot: open friends list to invite.
		if ( slotIndex < Connection.All.Count )
			return;

		Game.Overlay.ShowFriendsList( new Sandbox.Modals.FriendsListModalOptions() );
	}

	protected override int BuildHash()
	{
		int h = System.HashCode.Combine( Connection.All.Count, LobbyFlow.Current?.State, VisibleSlotCount );
		foreach ( var c in SortedConnections() )
		{
			h = System.HashCode.Combine( h, c.SteamId, IsReady( c ), c == Connection.Local );
		}
		return h;
	}
}