UI Razor component for the community games browser. It shows a searchable, sortable grid of community game packages, queries Package.FindAsync, and asks GameDirector to mount/start a selected package.
@namespace CardGames
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@using System.Collections.Generic
@inherits Panel
@*
The full games browser - reached from the launcher's "Browse Games" button (host only).
A search box and sort tabs drive a Package.FindAsync query; results show as cards. Picking one asks the
director to mount its cloud package and start it. Failures (won't download, not a card game) stay here.
*@
<root class="cgui">
<button class="back" onclick=@(() => Director?.CloseBrowser())>‹ Back</button>
<div class="hall">
<div class="controls">
<div class="search">
<span class="ico">search</span>
<TextEntry placeholder="Search games…" OnTextEdited=@OnSearchEdited></TextEntry>
</div>
<div class="sorts">
@foreach ( var s in Sorts )
{
var sort = s;
<button class="sort @(_sort == sort.Key ? "active" : "")" onclick=@(() => SetSort( sort.Key ))>@sort.Value</button>
}
</div>
</div>
@if ( !string.IsNullOrEmpty( _error ) )
{
<div class="cg-error">@_error</div>
}
@if ( _busy )
{
<div class="cg-note">Searching…</div>
}
else if ( _results.Count == 0 )
{
<div class="cg-note">No games found. Try a different search.</div>
}
else
{
<div class="grid">
@foreach ( var package in _results )
{
var p = package;
<GameCard Package=@p OnClick=@(() => Select( p )) Loading=@(_loadingIdent == p.FullIdent) />
}
</div>
}
</div>
</root>
@code
{
private GameDirector _director;
private GameDirector Director => _director ??= Sandbox.Game.ActiveScene?.Get<GameDirector>();
// Sort orders offered as tabs - the key goes straight into the FindAsync query (sort:<key>).
private static readonly KeyValuePair<string, string>[] Sorts =
{
new( "trending", "Trending" ),
new( "popular", "Popular" ),
new( "newest", "Newest" ),
new( "quality", "Top Rated" ),
};
private string _sort = "popular";
private string _error = "";
private bool _busy;
private string _loadingIdent; // the package currently being mounted/started (per-card spinner)
private List<Package> _results = new();
private string _query = "";
private bool _searchPending;
private RealTimeSince _sinceEdited;
private bool _searched; // kick off the first search once, after the panel mounts
private void SetSort( string sort )
{
if ( _sort == sort ) return;
_sort = sort;
Refresh();
}
// Debounce typing: note the edit, then fire the search a beat after the last keystroke (see Tick).
private void OnSearchEdited( string text )
{
_query = text ?? "";
_searchPending = true;
_sinceEdited = 0f;
}
public override void Tick()
{
base.Tick();
if ( !_searched )
{
_searched = true;
Refresh();
}
if ( _searchPending && _sinceEdited > 0.3f )
{
_searchPending = false;
Refresh();
}
}
// Query the cloud for games matching the current sort + search text.
private async void Refresh()
{
_busy = true;
_error = "";
StateHasChanged();
try
{
var query = $"type:cggame sort:{_sort} {_query}".Trim();
var result = await Package.FindAsync( query, 60 );
_results = result.Packages?.ToList() ?? new();
}
catch ( Exception e )
{
_results = new();
_error = "Couldn't reach the games list. Check your connection.";
Log.Warning( e, "Games search failed" );
}
_busy = false;
StateHasChanged();
}
// Mount + start the chosen package. On success the HUD swaps this panel out; on failure we show why.
private async void Select( Package package )
{
if ( _loadingIdent is not null || Director is null ) return;
_loadingIdent = package.FullIdent;
_error = "";
StateHasChanged();
var error = await Director.StartCommunityGame( package );
_loadingIdent = null;
if ( !string.IsNullOrEmpty( error ) )
_error = error;
StateHasChanged();
}
protected override int BuildHash() => HashCode.Combine( _busy, _error, _loadingIdent, _sort, _results.Count );
}