UI/MapCard.razor

A Razor UI component for a map card shown in the game lobby. It displays a map thumbnail, minimap, title, description, player vote avatars, capacity chip, and navigation controls; it updates textures and computes a hash for UI change detection.

File AccessNetworking
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.UI.Navigation;
@using Machines.Components;
@using Machines.Resources;

@namespace Machines.UI
@inherits Panel

@{
	var flow = LobbyFlow.Current;
	var displayMap = Map ?? flow?.LocalBrowsedMap;
	var localReady = flow?.IsLocalReady ?? false;
	var locked = !IsSide && localReady;
	var poolCount = flow?.MapsForMode( flow?.SelectedMode ?? GameModeType.Race ).Count ?? 0;
	var minimapTex = GetMinimapTexture();

	// Host pick gets an outline so everyone sees what will launch.
	var isHostPick = displayMap != null && flow != null && displayMap.ResourcePath == flow.SelectedMapIdent;

	// Players browsing this map; their avatars sit on the card.
	var voters = displayMap != null ? flow?.VotersFor( displayMap.ResourcePath ) : null;
	var voteCount = voters?.Count ?? 0;
	var totalPlayers = Connection.All.Count;
}

<div class="map-tile @(IsSide ? "is-side" : "is-hero") @(locked ? "locked" : "") @(isHostPick ? "host-pick" : "")" onclick=@OnCardClick>
	<div class="photo">
		<image @ref="ThumbImage" class="photo-img" />
		@if ( displayMap?.Thumbnail == null )
		{
			<div class="photo-empty">NO IMAGE</div>
		}
		@if ( !IsSide && displayMap != null )
		{
			<div class="leaderboard-btn" onclick=@OpenLeaderboard>
				<i>leaderboard</i>
			</div>
		}
		@if ( voteCount > 0 )
		{
			<div class="vote-avatars">
				@foreach ( var c in voters )
				{
					<div class="vote-avatar avatar-wrap @(c.IsHost ? "is-host" : "")">
						<image class="avatar" src="avatar:@(c.SteamId)" />
						<div class="avatar-fade"></div>
					</div>
				}
			</div>
		}
	</div>

	@if ( !IsSide && minimapTex != null )
	{
		<div class="minimap-overlay @(_minimapExpanded ? "expanded" : "")" onclick=@(() => _minimapExpanded = !_minimapExpanded)>
			<image @ref="MinimapImage" class="minimap-img" />
		</div>
	}

	<div class="caption">
		<div class="title-row">
			<div class="color-bar"></div>
			<div class="title">@(displayMap?.Title ?? "No maps")</div>
		</div>
		@if ( !IsSide )
		{
			@if ( !string.IsNullOrEmpty( displayMap?.Description ) )
			{
				<div class="description">@displayMap.Description</div>
			}
			<div class="meta-row">
				@if ( displayMap != null )
				{
					<span class="chip chip-capacity">@(displayMap.MinPlayers)@("-")@(displayMap.MaxPlayers) players</span>
				}
			</div>
		}
	</div>

	@if ( !IsSide && poolCount > 1 )
	{
		<div class="cycle cycle-left">
			<div class="chevron">‹</div>
			<InputHint Action="MenuLeft" Dark=@(true) />
		</div>
		<div class="cycle cycle-right">
			<InputHint Action="MenuRight" Dark=@(true) />
			<div class="chevron">›</div>
		</div>
	}
</div>

@code
{
	public MapResource Map { get; set; }
	public bool IsSide { get; set; }

	// Side card click selects that map; hero card is handled by its chevrons.
	private void OnCardClick()
	{
		if ( IsSide )
			LobbyFlow.Current?.SelectMap( Map );
	}

	// Navigate to the leaderboard for this map.
	private void OpenLeaderboard()
	{
		var map = ResolvedMap;
		if ( map == null )
			return;

		this.Navigate( $"/leaderboard?map={System.Uri.EscapeDataString( map.ResourcePath )}" );
	}

	public Sandbox.UI.Image ThumbImage { get; set; }

	private MapResource ResolvedMap => Map ?? LobbyFlow.Current?.LocalBrowsedMap;

	public Sandbox.UI.Image MinimapImage { get; set; }

	// Track-layout PNG alongside the scene file (<scene>_minimap.png).
	private bool _minimapExpanded;
	private Texture _minimapTex;
	private string _minimapFor;

	private Texture GetMinimapTexture()
	{
		var scenePath = ResolvedMap?.Scene?.ResourcePath;
		if ( scenePath != _minimapFor )
		{
			_minimapFor = scenePath;
			_minimapExpanded = false;
			_minimapTex = scenePath != null
				? Texture.LoadFromFileSystem( scenePath.Replace( ".scene", "_minimap.png" ), FileSystem.Mounted, warnOnMissing: false )
				: null;
		}
		return _minimapTex;
	}

	public override void Tick()
	{
		base.Tick();

		if ( ThumbImage != null )
			ThumbImage.Texture = ResolvedMap?.Thumbnail;

		if ( MinimapImage != null )
			MinimapImage.Texture = GetMinimapTexture();
	}

	protected override int BuildHash()
	{
		var flow = LobbyFlow.Current;
		var localReady = flow?.IsLocalReady ?? false;

		// Include voters so avatars refresh as players browse.
		var voters = ResolvedMap != null ? flow?.VotersFor( ResolvedMap.ResourcePath ) : null;
		var voteHash = voters?.Count ?? 0;
		if ( voters != null )
		{
			foreach ( var c in voters )
				voteHash = System.HashCode.Combine( voteHash, c.SteamId );
		}

		return System.HashCode.Combine(
			System.HashCode.Combine( Map?.ResourcePath, IsSide, flow?.SelectedMode, flow?.SelectedMapIdent, flow?.State, localReady, Networking.IsHost ),
			System.HashCode.Combine( GetMinimapTexture() != null, _minimapExpanded, voteHash, Connection.All.Count ) );
	}
}