Code/TerrainDebrisFx.cs
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.

using System;
using System.Collections.Generic;
using Sandbox;

namespace SubZero.SubTerrain;

/// <summary>
/// Dirt spray and physics clods for crater / dig stamps. Listens to
/// TerrainSculpt.OnApplied after GPU and collision so debris lands in the bowl.
/// Prefabs use engine meshes (models/dev/box.vmdl).
/// </summary>
[Title( "Terrain Debris FX" )]
[Category( "SubTerrain" )]
[Icon( "grain" )]
public sealed class TerrainDebrisFx : Component
{
	const string DefaultClodPath = "prefabs/subterrain/dirt_clod.prefab";
	const string DefaultSprayPath = "prefabs/subterrain/dirt_spray.prefab";
	const FindMode RigidbodyFind = FindMode.Enabled | FindMode.InSelf | FindMode.InDescendants;

	[Property, Group( "Source" )]
	public TerrainSculpt Sculpt { get; set; }

	[Property, Group( "Prefabs" )]
	public PrefabFile ClodPrefab { get; set; }

	[Property, Group( "Prefabs" )]
	public PrefabFile SprayPrefab { get; set; }

	[Property, Group( "Kinds" )]
	public bool Craters { get; set; } = true;

	[Property, Group( "Kinds" )]
	public bool Digs { get; set; } = true;

	[Property, Group( "Clods" ), Range( 0, 48 )]
	public int MinClods { get; set; } = 8;

	[Property, Group( "Clods" ), Range( 1, 64 )]
	public int MaxClods { get; set; } = 24;

	[Property, Group( "Clods" ), Range( 0f, 0.5f )]
	public float ClodsPerRadius { get; set; } = 0.12f;

	[Property, Group( "Clods" ), Range( 0, 128 )]
	public int MaxAlive { get; set; } = 64;

	[Property, Group( "Launch" ), Range( 0f, 4000f )]
	public float Impulse { get; set; } = 640f;

	[Property, Group( "Launch" ), Range( 0f, 3f )]
	public float UpBias { get; set; } = 0.45f;

	[Property, Group( "Launch" ), Range( 0.5f, 4f )]
	public float Outward { get; set; } = 2.2f;

	[Property, Group( "Launch" ), Range( 0.4f, 1.4f )]
	public float RimMin { get; set; } = 0.78f;

	[Property, Group( "Launch" ), Range( 0.5f, 1.6f )]
	public float RimMax { get; set; } = 1.12f;

	[Property, Group( "Launch" ), Range( 0f, 40f )]
	public float Spin { get; set; } = 14f;

	[Property, Group( "Launch" ), Range( 0f, 48f )]
	public float SpawnLift { get; set; } = 20f;

	[Property, Group( "Launch" ), Range( 0.05f, 1.5f )]
	public float ScaleMin { get; set; } = 0.7f;

	[Property, Group( "Launch" ), Range( 0.05f, 2.5f )]
	public float ScaleMax { get; set; } = 1.35f;

	[Property, Group( "Dig" ), Range( 0.1f, 1f )]
	public float DigImpulseScale { get; set; } = 0.5f;

	[Property, Group( "Debug" )]
	public bool DebugLog { get; set; }

	readonly List<GameObject> _alive = new();

	protected override void OnStart()
	{
		if ( !Game.IsPlaying )
			return;

		Sculpt ??= Components.Get<TerrainSculpt>( FindMode.EnabledInSelf );
		ClodPrefab ??= ResourceLibrary.Get<PrefabFile>( DefaultClodPath );
		SprayPrefab ??= ResourceLibrary.Get<PrefabFile>( DefaultSprayPath );

		if ( Sculpt == null || !Sculpt.IsValid() )
		{
			Log.Warning( $"[Debris] {GameObject.Name}: no TerrainSculpt on this object" );
			return;
		}

		Sculpt.OnApplied += OnTerrainApplied;
		Sculpt.OnReset += ClearDebris;
	}

	protected override void OnDisabled()
	{
		Unhook();
		ClearDebris();
	}

	protected override void OnDestroy()
	{
		Unhook();
	}

	void Unhook()
	{
		if ( Sculpt == null || !Sculpt.IsValid() )
			return;

		Sculpt.OnApplied -= OnTerrainApplied;
		Sculpt.OnReset -= ClearDebris;
	}

	void OnTerrainApplied( TerrainEditResult result )
	{
		if ( result.Kind == TerrainEditKind.Flatten )
			return;

		if ( result.Kind == TerrainEditKind.Crater && !Craters )
			return;

		if ( result.Kind == TerrainEditKind.Dig && !Digs )
			return;

		SpawnSpray( result );
		SpawnClods( result );
	}

	void SpawnSpray( TerrainEditResult result )
	{
		if ( SprayPrefab == null )
			return;

		var aimUp = Rotation.LookAt( Vector3.Up, Vector3.Forward );
		var spray = GameObject.Clone( SprayPrefab, new Transform( result.Origin, aimUp ) );
		if ( !spray.IsValid() )
			return;

		Track( spray );
	}

	void SpawnClods( TerrainEditResult result )
	{
		if ( ClodPrefab == null )
			return;

		var count = (int)Math.Clamp( result.Radius * ClodsPerRadius, MinClods, MaxClods );
		if ( result.Kind == TerrainEditKind.Dig )
			count = Math.Max( 3, count / 2 );

		var impulse = Impulse;
		var up = UpBias;
		if ( result.Kind == TerrainEditKind.Dig )
		{
			impulse *= DigImpulseScale;
			up *= 0.65f;
		}

		for ( var i = 0; i < count; i++ )
			SpawnClod( result, impulse, up, i, count );

		Dbg( $"spawned {count} clods kind={result.Kind} r={result.Radius:0.#}" );
	}

	void SpawnClod( TerrainEditResult result, float impulse, float up, int index, int count )
	{
		var yaw = (360f * index / Math.Max( count, 1 )) + Random.Shared.Float( -14f, 14f );
		var outward = Rotation.FromYaw( yaw ).Forward;
		var rimMin = result.Kind == TerrainEditKind.Dig
			? Math.Clamp( RimMin * 0.7f, 0.4f, 1.2f )
			: Math.Clamp( RimMin, 0.4f, 1.4f );
		var rimMax = Math.Max( rimMin + 0.05f, RimMax );
		var radial = result.Radius * Random.Shared.Float( rimMin, rimMax );
		var pos = result.Origin + outward * radial + Vector3.Up * SpawnLift;
		var scale = Random.Shared.Float( ScaleMin, ScaleMax );
		var rot = Rotation.From( new Angles(
			Random.Shared.Float( 0f, 360f ),
			Random.Shared.Float( 0f, 360f ),
			Random.Shared.Float( 0f, 360f )
		) );

		var clod = GameObject.Clone( ClodPrefab, new Transform( pos, rot ) );
		if ( !clod.IsValid() )
			return;

		clod.WorldScale *= scale;

		Track( clod );

		var body = clod.Components.Get<Rigidbody>( RigidbodyFind );
		if ( !body.IsValid() )
			return;

		var dir = (outward * Outward + Vector3.Up * up).Normal;
		var speed = impulse * Random.Shared.Float( 0.85f, 1.25f );
		body.Sleeping = false;
		body.Velocity = dir * speed;
		body.AngularVelocity = new Vector3(
			Random.Shared.Float( -1f, 1f ),
			Random.Shared.Float( -1f, 1f ),
			Random.Shared.Float( -1f, 1f )
		) * Spin;
	}

	void Track( GameObject go )
	{
		Prune();
		_alive.Add( go );

		while ( MaxAlive > 0 && _alive.Count > MaxAlive )
		{
			var oldest = _alive[0];
			_alive.RemoveAt( 0 );
			if ( oldest.IsValid() )
				oldest.Destroy();
		}
	}

	void ClearDebris()
	{
		for ( var i = _alive.Count - 1; i >= 0; i-- )
		{
			if ( _alive[i].IsValid() )
				_alive[i].Destroy();
		}

		_alive.Clear();
	}

	void Prune()
	{
		for ( var i = _alive.Count - 1; i >= 0; i-- )
		{
			if ( !_alive[i].IsValid() )
				_alive.RemoveAt( i );
		}
	}

	void Dbg( string message )
	{
		if ( !DebugLog )
			return;

		Log.Info( $"[Debris] {message}" );
	}
}