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