Defines GameSetting and GameSettings. GameSetting wraps a sandbox-safe PropertyDescription with UI metadata (label, description, group, range, step, enum names) and helpers to get/set values and enumerate discrete numeric steps. GameSettings discovers [Setting] properties via the engine TypeLibrary, caches per type, builds GameSetting instances, and filters settings for live game instances.
namespace CardGames;
/// <summary>What kind of control a <see cref="GameSetting"/> needs.</summary>
public enum SettingKind
{
Bool, // a toggle
Number, // a stepped value or +/- stepper
Enum // a one-of-N choice
}
/// <summary>
/// A single configurable setting on a game, discovered from a <c>[Setting]</c> property by
/// <see cref="GameSettings"/>. Wraps the reflected <see cref="PropertyDescription"/> (the engine's
/// sandbox-safe reflection) plus the display metadata pulled from <c>[Title]</c>/<c>[Description]</c>/
/// <c>[Group]</c>/<c>[Range]</c>/<c>[Step]</c>, so the UI never touches reflection directly.
/// </summary>
public sealed class GameSetting
{
public required PropertyDescription Property { get; init; }
public required SettingKind Kind { get; init; }
/// <summary>
/// Display label (defaults to the prettified property name; overridden by <c>[Title]</c>).
/// </summary>
public required string Label { get; init; }
public string Description { get; init; } = "";
/// <summary>
/// Section heading from <c>[Group]</c>, or empty for ungrouped.
/// </summary>
public string Group { get; init; } = "";
// Numeric bounds (from [Range]) and increment (from [Step]). Max <= Min means "unbounded".
public float Min { get; init; }
public float Max { get; init; }
public float Step { get; init; } = 1f;
/// <summary>
/// For <see cref="SettingKind.Enum"/>: the choice names, in declaration order.
/// </summary>
public IReadOnlyList<string> EnumNames { get; init; } = Array.Empty<string>();
/// <summary>
/// The property's name - stable id used for sync hashing and lookups.
/// </summary>
public string Name => Property.Name;
public object Get( CardGame game ) => Property.GetValue( game );
// SetValue runs the engine's Translation, which coerces our UI value to the property type: a float
// for numbers, an enum name string for enums, a bool for toggles.
public void Set( CardGame game, object value ) => Property.SetValue( game, value );
/// <summary>
/// Current value as a float (for numeric controls).
/// </summary>
public float GetNumber( CardGame game ) => Get( game ) switch
{
int i => i,
long l => l,
short s => s,
byte b => b,
float f => f,
double d => (float)d,
_ => 0f,
};
/// <summary>
/// Current value as the enum name / bool / number string (for read-only display).
/// </summary>
public string GetText( CardGame game ) => Get( game )?.ToString() ?? "";
/// <summary>
/// True when this numeric setting has a small, finite set of steps worth rendering as buttons.
/// </summary>
public bool HasDiscreteSteps => Kind == SettingKind.Number && Max > Min && Step > 0f && (Max - Min) / Step <= 12f;
/// <summary>
/// The discrete values Min..Max by Step, for a segmented control. Empty when not discrete.
/// </summary>
public IReadOnlyList<float> Steps()
{
if ( !HasDiscreteSteps ) return Array.Empty<float>();
var list = new List<float>();
for ( float v = Min; v <= Max + 0.0001f; v += Step )
list.Add( v );
return list;
}
}
/// <summary>
/// Discovers and describes <c>[Setting]</c>-marked properties on a <see cref="CardGame"/> type using the
/// engine's <c>TypeLibrary</c> (the sandbox-safe reflection path). Results are cached per type once the
/// type is registered. Used by the lobby settings panel.
/// </summary>
public static class GameSettings
{
// Reset on hotload so an edit that adds/removes a [Setting] is picked up live, not masked by a stale entry.
[Sandbox.SkipHotload]
private static readonly Dictionary<Type, IReadOnlyList<GameSetting>> _cache = new();
/// <summary>
/// Every configurable setting on a game type, in declaration order. Walks the type's base classes too, so
/// a setting declared on a base game (e.g. the universal "Card throwing" toggle on <see cref="CardGame"/>)
/// is surfaced for every subclass - not only settings declared on the leaf type.
/// </summary>
public static IReadOnlyList<GameSetting> For( Type gameType )
{
if ( gameType is null ) return Array.Empty<GameSetting>();
if ( _cache.TryGetValue( gameType, out var cached ) ) return cached;
var type = TypeLibrary.GetType( gameType );
if ( type is null )
return Array.Empty<GameSetting>(); // not registered yet - don't cache, so a later call retries
var list = new List<GameSetting>();
var seen = new HashSet<string>();
for ( var t = type; t is not null; t = t.BaseType )
{
foreach ( var member in t.DeclaredMembers )
{
if ( member is not PropertyDescription prop ) continue;
if ( !prop.HasAttribute<SettingAttribute>() || !prop.CanRead || !prop.CanWrite ) continue;
if ( !seen.Add( prop.Name ) ) continue; // a derived override wins; never list the same setting twice
if ( Build( prop ) is { } setting )
list.Add( setting );
}
}
IReadOnlyList<GameSetting> result = list;
_cache[gameType] = result;
return result;
}
/// <summary>
/// The settings to show for a live game instance: the type's <c>[Setting]</c>s minus any the game hides
/// right now (<see cref="CardGame.ShowSetting"/>), e.g. the card-throwing toggle when it's unsupported.
/// </summary>
public static IReadOnlyList<GameSetting> For( CardGame game )
{
if ( game is null ) return Array.Empty<GameSetting>();
return For( game.GetType() ).Where( s => game.ShowSetting( s.Name ) ).ToList();
}
/// <summary>
/// Does this game type have any configurable settings? (Drives whether the lobby shows the panel.)
/// </summary>
public static bool Any( Type gameType ) => For( gameType ).Count > 0;
/// <summary>
/// Does this live game have any settings to show right now (after per-instance hiding)?
/// </summary>
public static bool Any( CardGame game ) => For( game ).Count > 0;
private static GameSetting Build( PropertyDescription prop )
{
var pt = prop.PropertyType;
SettingKind kind;
IReadOnlyList<string> enumNames = Array.Empty<string>();
if ( pt == typeof( bool ) )
{
kind = SettingKind.Bool;
}
else if ( pt.IsEnum )
{
kind = SettingKind.Enum;
enumNames = pt.GetEnumNames();
}
else if ( IsNumeric( pt ) )
{
kind = SettingKind.Number;
}
else
{
return null; // unsupported type - skip rather than render a broken control
}
var display = prop.GetDisplayInfo();
var range = prop.GetCustomAttribute<RangeAttribute>();
var step = prop.GetCustomAttribute<StepAttribute>();
return new GameSetting
{
Property = prop,
Kind = kind,
Label = string.IsNullOrWhiteSpace( display.Name ) ? prop.Name : display.Name,
Description = display.Description ?? "",
Group = display.Group ?? "",
Min = range?.Min ?? 0f,
Max = range?.Max ?? 0f,
Step = step?.Step ?? 1f,
EnumNames = enumNames,
};
}
private static bool IsNumeric( Type t )
=> t == typeof( int ) || t == typeof( long ) || t == typeof( short ) || t == typeof( byte )
|| t == typeof( float ) || t == typeof( double );
}