Player/Items/ItemBox.cs

ItemBox component for in-game pickup boxes. It handles availability/respawn, visual idle motion (spin and bob), trigger detection by cars, server-authoritative collection via RPC, playing collect effects/sounds, and selecting a pickup via weighted roll based on race position.

NetworkingFile Access
using System.Collections.Generic;
using System.Linq;
using Machines.GameModes;
using Machines.Player;

namespace Machines.Items;

public sealed class ItemBox : Component, Component.ITriggerListener
{
	/// <summary>
	/// Visual child hidden while the box is regenerating.
	/// </summary>
	[Property]
	public GameObject Visual { get; set; }

	/// <summary>
	/// Seconds before the box is available again after being collected.
	/// </summary>
	[ConVar( "game_item_respawn", Saved = true, Min = 0, Max = 30, Flags = ConVarFlags.Replicated | ConVarFlags.GameSetting )]
	public static float RespawnDelay { get; set; } = 5f;

	/// <summary>
	/// When false, all item boxes destroy themselves on enable.
	/// </summary>
	[ConVar( "game_pickups", Saved = true, Flags = ConVarFlags.Replicated | ConVarFlags.GameSetting )]
	public static bool PickupsEnabled { get; set; } = true;

	/// <summary>
	/// Idle spin speed of the visual, degrees/second.
	/// </summary>
	[Property, Group( "Idle Motion" )]
	public float SpinSpeed { get; set; } = 45f;

	/// <summary>
	/// How far the visual bobs up and down (units).
	/// </summary>
	[Property, Group( "Idle Motion" )]
	public float BobHeight { get; set; } = 6f;

	/// <summary>
	/// Bob cycles per second.
	/// </summary>
	[Property, Group( "Idle Motion" )]
	public float BobSpeed { get; set; } = 1f;

	/// <summary>
	/// FX spawned when the box is collected.
	/// </summary>
	[Property]
	public GameObject CollectEffect { get; set; }

	/// <summary>
	/// Sound played when the box is collected.
	/// </summary>
	[Property]
	public SoundEvent CollectSound { get; set; }

	/// <summary>
	/// Time.Now the box becomes available again (synced).
	/// </summary>
	[Sync]
	public float RespawnAt { get; set; }

	/// <summary>
	/// True when the box can currently be collected.
	/// </summary>
	public bool Available => Time.Now >= RespawnAt;

	// Authored local position of the visual; bob is applied relative to it.
	private Vector3 _visualBase;
	private bool _haveBase;

	protected override void OnEnabled()
	{
		// Pickups disabled, remove the box
		if ( !PickupsEnabled )
			GameObject.Destroy();
	}

	protected override void OnUpdate()
	{
		if ( !Visual.IsValid() )
			return;

		if ( Visual.Enabled != Available )
			Visual.Enabled = Available;

		if ( !_haveBase )
		{
			_visualBase = Visual.LocalPosition;
			_haveBase = true;
		}

		// Idle spin + bob around the authored position.
		Visual.LocalRotation = Rotation.FromYaw( Time.Now * SpinSpeed );
		Visual.LocalPosition = _visualBase + Vector3.Up * (MathF.Sin( Time.Now * BobSpeed * MathF.Tau ) * BobHeight);
	}

	public void OnTriggerEnter( Collider other )
	{
		if ( !Available )
			return;

		var car = other.GameObject.GetComponentInParent<Car>();
		if ( !car.IsValid() || !car.IsAuthority )
			return;

		if ( !car.Inventory.IsValid() || car.Inventory.HasItem )
			return;

		// Owner detected the touch; the host validates and applies it.
		CollectFromClient( car );
	}

	/// <summary>
	/// Sent by the touching car's owner; the host rolls the item, consumes the box and grants it.
	/// </summary>
	[Rpc.Host]
	private void CollectFromClient( Car car )
	{
		if ( !Available || !car.IsValid() || !car.Inventory.IsValid() || car.Inventory.HasItem )
			return;

		// Host owns the box: consume it (RespawnAt syncs to clients) and play FX everywhere.
		RespawnAt = Time.Now + RespawnDelay;
		PlayCollectFx();

		// Grant + stat run on the car's owner (Held is owner-synced, stats are local-player).
		car.Inventory.GrantPickup( RollItem( car ) );
	}

	[Rpc.Broadcast]
	private void PlayCollectFx()
	{
		if ( CollectEffect.IsValid() )
		{
			CollectEffect.Clone( new CloneConfig
			{
				Transform = new Transform( WorldPosition ),
				StartEnabled = true
			} );
		}

		if ( CollectSound.IsValid() )
			Sound.Play( CollectSound, WorldPosition );
	}

	private PickupDef RollItem( Car car )
	{
		var defs = PickupDef.All;
		if ( defs.Count == 0 )
			return null;

		var frac = PositionFraction( car );

		var total = 0f;
		foreach ( var d in defs )
			total += MathF.Max( 0f, d.WeightAt( frac ) );

		if ( total <= 0f )
			return defs[0];

		var r = Game.Random.Float( 0f, total );
		foreach ( var d in defs )
		{
			r -= MathF.Max( 0f, d.WeightAt( frac ) );
			if ( r <= 0f )
				return d;
		}

		return defs[^1];
	}

	private float PositionFraction( Car car )
	{
		var standings = BaseGameMode.Current?.GetComponent<RaceStandings>();
		if ( !standings.IsValid() )
			return 0.5f;

		var list = standings.GetStandings()
			.Where( s => !s.IsGhost )
			.OrderBy( s => s.Position )
			.ToList();

		if ( list.Count <= 1 )
			return 0.5f;

		var idx = list.FindIndex( s => s.Slot == car.Slot );
		return idx < 0 ? 0.5f : idx / (float)(list.Count - 1);
	}
}