Loot/Loot.cs

A networked world loot component for the game. Creates a GameObject with renderer, collider and rigidbody, shows a local world label, handles player Use to pick up items, applies impulse, moves into player inventory and spawns effects/log messages.

NetworkingFile Access
using System;
using Sandbox;

namespace BrickJam;

/// <summary>
/// A pickup-able loot item in the world. Scene-System port of the legacy <c>Loot : UsableEntity</c>.
///
/// The legacy entity built its own model/physics; here a loot is a GameObject with a
/// <see cref="ModelRenderer"/> + <see cref="ModelCollider"/> + <see cref="Rigidbody"/> created at
/// runtime by <see cref="Create"/>.
///
/// Deferred: the ground-loot world UI panel (<c>GroundLootPanel</c>) and event-log messages belong
/// to the UI system. The pickup itself works against the ported <see cref="ContainerComponent"/>.
/// </summary>
[Title( "Loot" )]
[Category( "Loot" )]
public sealed partial class Loot : LegacyUsableComponent
{
	[Property, Sync] public string PrefabName { get; set; }
	[Sync] public LootRarity Rarity { get; set; } = LootRarity.Common;
	[Sync] public int BaseMonetaryValue { get; set; }
	[Sync] public string BaseName { get; set; } = "Loot";
	[Sync] public Player LastPlayer { get; set; }

	public LootPrefab Prefab => LootPrefab.Get( PrefabName );
	public string FullName => $"{Rarity} {BaseName}";
	public int MonetaryValue => (int)(BaseMonetaryValue * LootRarityTable.RarityMap[Rarity]);

	public override string UseString { get => $"take the {FullName}"; set { } }
	public override float InteractionDuration { get => 0.2f; set { } }

	private Player picker;
	private bool deleting;

	public static Loot Create( LootPrefab resource, Vector3 position, Rotation rotation, bool setRarity = true )
	{
		if ( resource is null )
			return null;

		var go = new GameObject( true, "Loot" );
		go.WorldPosition = position;
		go.WorldRotation = rotation;
		go.Tags.Add( "loot", "usable" );

		var model = Model.Load( string.IsNullOrEmpty( resource.Model ) ? "models/error.vmdl" : resource.Model );

		var renderer = go.Components.Create<ModelRenderer>();
		renderer.Model = model;

		var collider = go.Components.Create<ModelCollider>();
		collider.Model = model;

		var body = go.Components.Create<Rigidbody>();
		body.MotionEnabled = false; // keyframed until picked up

		var loot = go.Components.Create<Loot>();
		loot.PrefabName = resource.ResourceName;
		loot.BaseMonetaryValue = resource.MonetaryValue;
		loot.BaseName = resource.Name;
		if ( setRarity )
			loot.Rarity = LootRarityTable.RandomRarityFromLevel( MansionGame.Instance?.CurrentLevelType ?? LevelType.Mansion );

		go.NetworkSpawn();
		return loot;
	}

	/// <summary>
	/// Build the world-anchored loot label LOCALLY on every client. World-space razor panels must be created
	/// locally (not baked into the networked object), because a <c>PanelComponent</c> recreated from the
	/// network snapshot on a proxy never rebuilds its razor tree - it would only render on the host. The
	/// label reads the loot's [Sync]'d Rarity/BaseName, so it shows correctly on each client.
	/// </summary>
	private GameObject labelGo;
	private const float LabelRange = 300f;

	protected override void OnStart()
	{
		base.OnStart();

		labelGo = new GameObject( true, "Label" ) { Parent = GameObject };
		labelGo.LocalPosition = Vector3.Up * 20f;
		labelGo.NetworkMode = NetworkMode.Never;

		var panel = labelGo.Components.Create<WorldPanel>();
		panel.LookAtCamera = true;
		panel.PanelSize = new Vector2( 1500, 300 );

		var type = Game.TypeLibrary.GetType( "GroundLootPanel" );
		if ( type is not null )
			labelGo.Components.Create( type );
	}

	protected override void OnUpdate()
	{
		// A WorldPanel re-renders every frame, so with lots of loot on the ground all those labels were a
		// constant Render/UI cost. Only render this one while the local player is near it.
		if ( !labelGo.IsValid() )
			return;

		var show = Player.Local.IsValid() && Player.Local.WorldPosition.Distance( WorldPosition ) <= LabelRange;
		if ( labelGo.Enabled != show )
			labelGo.Enabled = show;
	}

	public static Loot CreateFromEntry( ItemEntry entry, Vector3 position, Rotation rotation )
	{
		var loot = Create( entry.Prefab, position, rotation, false );
		if ( loot is not null )
			loot.Rarity = entry.Rarity;

		return loot;
	}

	public override void Use( Player user )
	{
		if ( !Networking.IsHost || picker is not null )
			return;

		picker = user;
		LastPlayer = user;

		if ( Components.TryGet<Rigidbody>( out var body ) )
		{
			body.MotionEnabled = true;
			var normal = (user.EyePosition - WorldPosition).Normal;
			var force = 100f + WorldPosition.Distance( user.EyePosition );
			body.ApplyImpulse( force * (normal + Vector3.Up * 0.5f) );
		}

		SoundExtensions.BroadcastPlay( "sounds/grab/grab.sound", WorldPosition );
	}

	protected override void OnFixedUpdate()
	{
		if ( IsProxy )
			return;

		if ( picker is null )
		{
			GameObject.WorldScale = Vector3.Lerp( GameObject.WorldScale, Vector3.One, 5f * Time.Delta );
			return;
		}

		GameObject.WorldScale = Vector3.Lerp( GameObject.WorldScale, Vector3.One * 0.01f, 5f * Time.Delta );

		if ( GameObject.WorldScale.x.AlmostEqual( 0.01f, 0.1f ) && !deleting )
		{
			deleting = true;

			var inventory = picker.Components.Get<ContainerComponent>();
			if ( inventory is not null && inventory.Add( new ItemEntry { Prefab = Prefab, Rarity = Rarity } ) )
			{
				MansionGame.Instance?.ShowEventlog( $"You picked up <gray>1x {FullName}." );
				MansionGame.Instance?.PlayEffect( "prefabs/particles/smoke_steal.prefab", WorldPosition, Rotation.Identity );
				picker.TrackStat( GameStats.LootCollected, 1 ); // routed to the picker's own client
				GameObject.Destroy();
			}
			else
			{
				MansionGame.Instance?.ShowEventlog( $"<red>No space for <gray>1x {FullName}." );
				picker = null;
				deleting = false;
			}
		}
	}
}