Player/Items/ItemBox.cs

An item pickup component for a game. It shows a visual that spins and bobs, handles trigger collisions with cars, plays collect effects/sounds, does a client-side optimistic hide, sends an RPC to the host to validate and grant a rolled pickup, and manages respawn timing synced via RespawnAt.

Networking
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 )]
	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 )]
	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 && Time.Now >= _localHideUntil;

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

	// Local optimistic hide after the collector touches it; self-heals if the host rejects the collect.
	private float _localHideUntil;

	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;

		// Instant local feedback: play FX (broadcast) and hide the box right away.
		PlayCollectFx();
		_localHideUntil = Time.Now + RespawnDelay;

		// The host validates and applies the authoritative roll/grant + respawn.
		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 )
	{
		// Check the authoritative respawn timer, not Available — the collector's local optimistic hide
		// would otherwise block this when the collector is the host.
		if ( Time.Now < RespawnAt || !car.IsValid() || !car.Inventory.IsValid() || car.Inventory.HasItem )
			return;

		// Host owns the box: consume it (RespawnAt syncs to clients). FX already played by the collector.
		RespawnAt = Time.Now + RespawnDelay;

		// 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);
	}
}