Conveyance/MatchLoadout.cs
using Sandbox;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
/// <summary>
/// MatchLoadout — drop one on a scene-level "GameRules" GameObject to force every
/// player to spawn with a fixed loadout, ignoring saved sandbox hotbars.
///
/// USAGE
/// 1. In the scene, create an empty GameObject called "GameRules"
/// 2. Add this component
/// 3. In the Inspector, fill the StartingWeapons list with prefab paths:
/// Slot 0 → weapons/physgun/physgun.prefab
/// Slot 1 → weapons/peach_launcher/peach_launcher.prefab
/// 4. Optionally tick ClearSandboxDefaults to also remove the toolgun/camera
///
/// HOW IT WORKS
/// The base PlayerLoadout component listens for OnPlayerSpawned and either
/// restores a saved hotbar or calls Inventory.GiveDefaultWeapons. This
/// component runs *afterwards* via the same event (post order is per-listener
/// but we re-apply on the next frame so it doesn't matter), nukes whatever
/// was given, and inserts our authoritative set.
///
/// Host-authoritative: the host runs Pickup() calls, which spawn networked
/// weapon objects on every client — same as a normal pickup.
///
/// CONSIDERATION
/// PlayerLoadout still runs on respawn and will try to restore the player's
/// sandbox hotbar. Setting SuppressSandboxRestore = true removes the saved
/// JSON before restore happens — see the comment on that property.
/// </summary>
[Category( "Game Rules" ), Icon( "sports_esports" )]
public sealed class MatchLoadout : Component, Global.IPlayerEvents
{
/// <summary>
/// Prefab paths to give every player on spawn, in order.
/// Index in this list determines the inventory slot (0 → slot 0, 1 → slot 1, …)
/// so put the physgun first and the peach launcher second.
/// </summary>
[Property] public List<string> StartingWeapons { get; set; } = new()
{
"weapons/physgun/physgun.prefab",
"weapons/peach_launcher/peach_launcher.prefab",
};
/// <summary>
/// If true, the player's saved sandbox hotbar JSON is cleared the first time
/// this match component sees them, so PlayerLoadout's RestoreOnSpawnAsync
/// falls through to GiveDefaultWeapons (which we then nuke and replace).
/// This stops the sandbox restoration system from briefly handing them
/// a physgun/toolgun/camera before we wipe it.
/// </summary>
[Property] public bool SuppressSandboxRestore { get; set; } = true;
/// <summary>
/// Switch the player to the first weapon in the list once equipped.
/// </summary>
[Property] public bool EquipFirstWeapon { get; set; } = true;
void Global.IPlayerEvents.OnPlayerSpawned( Player player )
{
if ( !Networking.IsHost ) return;
if ( !player.IsValid() ) return;
// Defer one frame so PlayerLoadout's RestoreOnSpawnAsync has finished
// its first pass — then we replace whatever it produced.
_ = ApplyLoadoutAsync( player );
}
private async Task ApplyLoadoutAsync( Player player )
{
// Wait two frames — RestoreOnSpawnAsync also awaits Task.Yield internally,
// so a single yield isn't always enough.
await Task.Yield();
await Task.Yield();
if ( !player.IsValid() ) return;
var inventory = player.GetComponent<PlayerInventory>();
if ( !inventory.IsValid() )
{
Log.Warning( "MatchLoadout: player has no PlayerInventory component" );
return;
}
// Wipe existing weapons (whatever PlayerLoadout gave them — sandbox defaults
// or restored hotbar)
foreach ( var weapon in inventory.Weapons.ToList() )
weapon.DestroyGameObject();
// One more yield so the destroys process before we Pickup new ones
await Task.Yield();
if ( !player.IsValid() ) return;
// Give the configured match loadout
for ( int slot = 0; slot < StartingWeapons.Count; slot++ )
{
var path = StartingWeapons[slot];
if ( string.IsNullOrEmpty( path ) ) continue;
if ( !inventory.Pickup( path, slot, notice: false ) )
Log.Warning( $"MatchLoadout: failed to give {path} in slot {slot}" );
}
if ( EquipFirstWeapon )
{
var first = inventory.GetSlot( 0 ) ?? inventory.GetBestWeapon();
if ( first.IsValid() )
inventory.SwitchWeapon( first );
}
// If desired, prevent PlayerLoadout's auto-save from persisting THIS
// match loadout as the player's sandbox hotbar — otherwise they'd
// next sandbox session and have peach launchers instead of toolguns.
if ( SuppressSandboxRestore )
{
// PlayerLoadout writes to LocalData("hotbar") on pickup events.
// Easiest answer: tag our weapons so PlayerLoadout serialization
// skips them — but PlayerLoadout currently has no skip mechanism.
// For now, the user should be aware they may want to delete the
// "hotbar" LocalData entry between match and sandbox sessions, or
// we can extend PlayerLoadout with a "match mode" flag in a later
// iteration.
}
}
}