Add tower Sell action with refund + coin VFX/SFX

Owner-validated sell RPC refunds SellRefundPercent of invested gold (Wall refunds 100% while un-upgraded), despawns the tower, and broadcasts a coin-burst VFX + rustle SFX. Refunds do not count as per-wave income. Investment/upgrade tracking added as the seam for the future upgrade system.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matt F 2026-07-14 20:05:06 -07:00
parent 2429efbf6a
commit 9be31b1645
7 changed files with 361 additions and 12 deletions

View file

@ -0,0 +1,101 @@
// Assets/_Project/Scripts/VFX/CoinBurstVfx.cs
using UnityEngine;
namespace TD.VFX
{
/// <summary>
/// Self-contained, code-configured gold-coin burst used as the zero-art placeholder for the
/// tower-sell effect. Configures its own <see cref="ParticleSystem"/> for a short radial
/// spray of gold specks thrown up in random arcs under gravity, then destroys itself.
/// </summary>
/// <remarks>
/// Instantiated at runtime by <see cref="SellEffectSpawner"/> when no authored VFX prefab is
/// assigned. It can also be dropped on a prefab in the editor and tuned. Replace with an
/// authored particle / VFX-graph prefab (assigned on the spawner) when art is ready — nothing
/// else needs to change.
/// </remarks>
[RequireComponent(typeof(ParticleSystem))]
public class CoinBurstVfx : MonoBehaviour
{
[Tooltip("How many coin specks to throw.")]
[SerializeField] private int coinCount = 14;
[Tooltip("Longest a speck lives (seconds). The whole effect self-destructs shortly after.")]
[SerializeField] private float lifetime = 0.6f;
[Tooltip("Gold tint applied to the specks.")]
[SerializeField] private Color coinColor = new Color(1f, 0.84f, 0.2f, 1f);
private void Awake()
{
var ps = GetComponent<ParticleSystem>();
if (ps == null) ps = gameObject.AddComponent<ParticleSystem>();
Configure(ps);
}
private void Configure(ParticleSystem ps)
{
// Reconfiguring modules requires the system to be stopped first.
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
var main = ps.main;
main.duration = 0.1f;
main.loop = false;
main.playOnAwake = false;
main.startLifetime = new ParticleSystem.MinMaxCurve(lifetime * 0.6f, lifetime);
main.startSpeed = new ParticleSystem.MinMaxCurve(2.5f, 5f);
main.startSize = new ParticleSystem.MinMaxCurve(0.07f, 0.16f);
main.startRotation = new ParticleSystem.MinMaxCurve(0f, Mathf.PI * 2f);
main.startColor = coinColor;
main.gravityModifier = 2.5f; // arc up then fall back down quickly
main.simulationSpace = ParticleSystemSimulationSpace.World;
main.maxParticles = 64;
main.stopAction = ParticleSystemStopAction.None;
var emission = ps.emission;
emission.enabled = true;
emission.rateOverTime = 0f; // burst only, no continuous stream
emission.SetBursts(new[] { new ParticleSystem.Burst(0f, (short)coinCount) });
// Hemisphere pointing up → specks spray outward and upward in random directions.
var shape = ps.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Hemisphere;
shape.radius = 0.12f;
// Fade the specks out over the back half of their life so they "decay quickly".
var col = ps.colorOverLifetime;
col.enabled = true;
var grad = new Gradient();
grad.SetKeys(
new[] { new GradientColorKey(coinColor, 0f), new GradientColorKey(coinColor, 1f) },
new[]
{
new GradientAlphaKey(1f, 0f),
new GradientAlphaKey(1f, 0.55f),
new GradientAlphaKey(0f, 1f),
});
col.color = new ParticleSystem.MinMaxGradient(grad);
// Give the specks a visible, transparent-capable material even with no assigned art.
var renderer = GetComponent<ParticleSystemRenderer>();
if (renderer != null)
{
renderer.renderMode = ParticleSystemRenderMode.Billboard;
if (renderer.sharedMaterial == null)
{
Shader shader = Shader.Find("Universal Render Pipeline/Particles/Unlit")
?? Shader.Find("Sprites/Default")
?? Shader.Find("Legacy Shaders/Particles/Alpha Blended Premultiply");
if (shader != null)
renderer.material = new Material(shader) { color = coinColor };
}
}
ps.Play();
// Tear down after the last speck has died (plus a small margin).
Destroy(gameObject, lifetime + 0.25f);
}
}
}

View file

@ -0,0 +1,83 @@
// Assets/_Project/Scripts/VFX/SellEffectSpawner.cs
using UnityEngine;
using TD.Audio;
namespace TD.VFX
{
/// <summary>
/// Scene singleton that plays the "tower sold" feedback — a burst of gold coins plus a
/// coin-rustle sound — at a world position. Mirrors <see cref="TD.UI.FloatingTextSpawner"/>:
/// visual-only, plain <c>MonoBehaviour</c>, invoked on every peer via a ClientRpc so all
/// players see and hear a sale locally.
/// </summary>
/// <remarks>
/// <b>Who calls this:</b> <see cref="TD.Gameplay.TowerPlacementManager.BroadcastSellEffect"/>
/// routes a ClientRpc here when a tower is sold.
///
/// <b>Inspector setup:</b> drop this on a <c>SellEffectSpawner</c> GameObject in each Match
/// scene. The VFX prefab is OPTIONAL — leave it empty to use the built-in
/// <see cref="CoinBurstVfx"/> placeholder, or assign an authored particle/VFX-graph prefab.
/// Assign the coin-rustle clip on <see cref="sellSound"/>.
/// </remarks>
public class SellEffectSpawner : MonoBehaviour
{
// ----- Singleton --------------------------------------------------
public static SellEffectSpawner Instance { get; private set; }
// ----- Inspector --------------------------------------------------
[Tooltip("Optional authored VFX prefab spawned at the sale position. Leave empty to " +
"fall back to the built-in code-generated CoinBurstVfx placeholder.")]
[SerializeField] private GameObject coinBurstPrefab;
[Tooltip("Coin-rustle sound played (2D, via AudioManager) when a tower is sold.")]
[SerializeField] private SoundConfig sellSound;
[Tooltip("Vertical offset above the sale position where the coin burst originates, so " +
"it reads as coming from the tower body rather than the ground.")]
[SerializeField] private float verticalOffset = 0.75f;
// ----- Lifecycle --------------------------------------------------
private void Awake()
{
if (Instance != null && Instance != this)
{
Debug.LogWarning("[SellEffectSpawner] Duplicate instance detected. " +
"Only one should exist per scene.");
return;
}
Instance = this;
}
private void OnDestroy()
{
if (Instance == this) Instance = null;
}
// ----- Public API -------------------------------------------------
/// <summary>Plays the coin burst + rustle sound at <paramref name="worldPos"/>.</summary>
public void Play(Vector3 worldPos)
{
Vector3 spawnPos = worldPos + Vector3.up * verticalOffset;
if (coinBurstPrefab != null)
{
Instantiate(coinBurstPrefab, spawnPos, Quaternion.identity);
}
else
{
// Zero-art fallback: a self-configuring, self-destroying particle burst.
var go = new GameObject("CoinBurst");
go.transform.position = spawnPos;
go.AddComponent<CoinBurstVfx>();
}
if (sellSound.clip != null)
AudioManager.Instance?.Play(sellSound.clip, AudioCategory.UI,
sellSound.RandomPitch(), sellSound.volume);
}
}
}