tower-sell #12
7 changed files with 361 additions and 12 deletions
|
|
@ -143,11 +143,17 @@ namespace TD.Gameplay
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-side entry point for awarding gold (wave clear, enemy kill).
|
/// Server-side entry point for awarding gold (wave clear, enemy kill).
|
||||||
/// Direct call — not Rpc-wrapped — because awards always originate
|
/// Direct call — not Rpc-wrapped — because awards always originate
|
||||||
/// from server-authoritative game events. Also increments
|
/// from server-authoritative game events. When <paramref name="countAsEarned"/>
|
||||||
/// <see cref="GoldEarnedThisWave"/> so the HUD's per-wave counter reflects
|
/// is true (the default) it also increments <see cref="GoldEarnedThisWave"/> so the
|
||||||
/// it; spending does not decrement that counter (it tracks earnings, not balance).
|
/// HUD's per-wave counter reflects it; spending does not decrement that counter (it
|
||||||
|
/// tracks earnings, not balance).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void AwardGold(int amount)
|
/// <param name="countAsEarned">
|
||||||
|
/// Pass false for gold returned to the player that isn't income — e.g. a tower-sell
|
||||||
|
/// refund. The player's balance still rises, but the per-wave "earned" counter does not,
|
||||||
|
/// so refunds don't inflate round-income stats.
|
||||||
|
/// </param>
|
||||||
|
public void AwardGold(int amount, bool countAsEarned = true)
|
||||||
{
|
{
|
||||||
if (!IsServer)
|
if (!IsServer)
|
||||||
{
|
{
|
||||||
|
|
@ -158,7 +164,7 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
if (amount <= 0) return;
|
if (amount <= 0) return;
|
||||||
currentGold.Value += amount;
|
currentGold.Value += amount;
|
||||||
goldEarnedThisWave.Value += amount;
|
if (countAsEarned) goldEarnedThisWave.Value += amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,24 @@ namespace TD.Gameplay
|
||||||
readPerm: NetworkVariableReadPermission.Everyone,
|
readPerm: NetworkVariableReadPermission.Everyone,
|
||||||
writePerm: NetworkVariableWritePermission.Server);
|
writePerm: NetworkVariableWritePermission.Server);
|
||||||
|
|
||||||
|
// Total gold sunk into this tower: its placement cost plus any gold later spent
|
||||||
|
// upgrading it. Set on the server at spawn (= placement cost) and grown by the
|
||||||
|
// (future) upgrade system via ServerAddUpgradeInvestment. Replicated so the HUD's
|
||||||
|
// Sell button can preview the exact refund without a server round-trip.
|
||||||
|
private readonly NetworkVariable<int> goldInvested =
|
||||||
|
new NetworkVariable<int>(
|
||||||
|
0,
|
||||||
|
readPerm: NetworkVariableReadPermission.Everyone,
|
||||||
|
writePerm: NetworkVariableWritePermission.Server);
|
||||||
|
|
||||||
|
// Number of upgrades applied to this tower. 0 = never upgraded. Drives the Wall's
|
||||||
|
// "full refund only while un-upgraded" rule and will back tier display later.
|
||||||
|
private readonly NetworkVariable<int> upgradeCount =
|
||||||
|
new NetworkVariable<int>(
|
||||||
|
0,
|
||||||
|
readPerm: NetworkVariableReadPermission.Everyone,
|
||||||
|
writePerm: NetworkVariableWritePermission.Server);
|
||||||
|
|
||||||
// ----- Local resolved state -------------------------------------------
|
// ----- Local resolved state -------------------------------------------
|
||||||
|
|
||||||
// Resolved on every client in OnNetworkSpawn from definitionTypeId via the catalog.
|
// Resolved on every client in OnNetworkSpawn from definitionTypeId via the catalog.
|
||||||
|
|
@ -144,6 +162,12 @@ namespace TD.Gameplay
|
||||||
/// <summary>The footprint anchor tile (SW corner, world-tile coords).</summary>
|
/// <summary>The footprint anchor tile (SW corner, world-tile coords).</summary>
|
||||||
public Vector2Int AnchorTile => anchorTile.Value;
|
public Vector2Int AnchorTile => anchorTile.Value;
|
||||||
|
|
||||||
|
/// <summary>Total gold sunk into this tower so far (placement + upgrades).</summary>
|
||||||
|
public int GoldInvested => goldInvested.Value;
|
||||||
|
|
||||||
|
/// <summary>How many upgrades have been applied to this tower (0 = never upgraded).</summary>
|
||||||
|
public int UpgradeCount => upgradeCount.Value;
|
||||||
|
|
||||||
/// <summary>World-unit height the post-construction drop animation falls from.</summary>
|
/// <summary>World-unit height the post-construction drop animation falls from.</summary>
|
||||||
public float DropHeight => dropHeight;
|
public float DropHeight => dropHeight;
|
||||||
|
|
||||||
|
|
@ -254,6 +278,11 @@ namespace TD.Gameplay
|
||||||
anchorTile.Value = pendingAnchor;
|
anchorTile.Value = pendingAnchor;
|
||||||
ownerSlot.Value = pendingOwner;
|
ownerSlot.Value = pendingOwner;
|
||||||
|
|
||||||
|
// Seed invested gold with the placement cost — the same amount
|
||||||
|
// TowerPlacementManager deducted to build this tower. The upgrade system
|
||||||
|
// grows this later via ServerAddUpgradeInvestment.
|
||||||
|
goldInvested.Value = pendingDefinition != null ? pendingDefinition.GoldCost : 0;
|
||||||
|
|
||||||
// Clear the pending data — it's now committed to NetworkVariables.
|
// Clear the pending data — it's now committed to NetworkVariables.
|
||||||
hasPendingInit = false;
|
hasPendingInit = false;
|
||||||
}
|
}
|
||||||
|
|
@ -345,6 +374,84 @@ namespace TD.Gameplay
|
||||||
// Re-tint on every client (and the server) when the replicated paint color changes.
|
// Re-tint on every client (and the server) when the replicated paint color changes.
|
||||||
private void HandlePaintColorChanged(PaintColor previous, PaintColor current) => ApplyTint();
|
private void HandlePaintColorChanged(PaintColor previous, PaintColor current) => ApplyTint();
|
||||||
|
|
||||||
|
// ----- Selling --------------------------------------------------------
|
||||||
|
|
||||||
|
// Server-only guard: a fast double-click could deliver two sell RPCs before the
|
||||||
|
// despawn propagates. Mirrors BuildSiteVisual.serverCancelled.
|
||||||
|
private bool serverSold;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gold returned if this tower is sold right now. Single source of truth for both
|
||||||
|
/// the server (which awards it) and the HUD (which labels the Sell button). By
|
||||||
|
/// default this is <see cref="TowerDefinition.SellRefundPercent"/> of everything
|
||||||
|
/// invested; a tower flagged <see cref="TowerDefinition.FullRefundIfUnupgraded"/>
|
||||||
|
/// (the Wall) returns the full amount while it has never been upgraded.
|
||||||
|
/// </summary>
|
||||||
|
public int ComputeSellRefund()
|
||||||
|
{
|
||||||
|
int invested = goldInvested.Value;
|
||||||
|
if (resolvedDefinition == null)
|
||||||
|
return Mathf.RoundToInt(invested * 0.75f);
|
||||||
|
|
||||||
|
if (resolvedDefinition.FullRefundIfUnupgraded && upgradeCount.Value == 0)
|
||||||
|
return invested;
|
||||||
|
|
||||||
|
return Mathf.RoundToInt(invested * resolvedDefinition.SellRefundPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-only: records gold spent upgrading this tower (so a later sell refunds a
|
||||||
|
/// share of it) and marks the tower upgraded — which forfeits any
|
||||||
|
/// full-refund-while-unupgraded rule. The upgrade system calls this when it lands.
|
||||||
|
/// </summary>
|
||||||
|
public void ServerAddUpgradeInvestment(int cost)
|
||||||
|
{
|
||||||
|
if (!IsServer) return;
|
||||||
|
if (cost > 0) goldInvested.Value += cost;
|
||||||
|
upgradeCount.Value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client → server request to sell this tower. Accepted only from the tower's owner
|
||||||
|
/// (same ownership rule as placement and paint). The server refunds gold, broadcasts
|
||||||
|
/// the sell VFX/SFX from a persistent object, then despawns the tower —
|
||||||
|
/// <see cref="OnNetworkDespawn"/> restores the footprint's grid state and clears
|
||||||
|
/// selection on every peer.
|
||||||
|
/// </summary>
|
||||||
|
[Rpc(SendTo.Server)]
|
||||||
|
public void RequestSellServerRpc(RpcParams rpcParams = default)
|
||||||
|
{
|
||||||
|
if (!IsServer) return;
|
||||||
|
if (serverSold) return; // idempotent guard against a double-click
|
||||||
|
|
||||||
|
ulong senderClientId = rpcParams.Receive.SenderClientId;
|
||||||
|
PlayerSlot senderSlot = PlayerMatchState.SlotForClient(senderClientId);
|
||||||
|
if (senderSlot == PlayerSlot.None || senderSlot != ownerSlot.Value)
|
||||||
|
{
|
||||||
|
Debug.Log($"[TowerInstance] Sell rejected: client {senderClientId} " +
|
||||||
|
$"({senderSlot}) does not own tower owned by {ownerSlot.Value}.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
serverSold = true;
|
||||||
|
|
||||||
|
int refund = ComputeSellRefund();
|
||||||
|
var goldManager = PlayerGoldManager.GetForClient(senderClientId);
|
||||||
|
if (goldManager != null && refund > 0)
|
||||||
|
// countAsEarned: false — a sell refund returns spent gold, it is not round
|
||||||
|
// income, so it must not inflate the per-wave "earned" counter.
|
||||||
|
goldManager.AwardGold(refund, countAsEarned: false);
|
||||||
|
|
||||||
|
// Broadcast VFX/SFX from the persistent placement manager, capturing the world
|
||||||
|
// position NOW — this NetworkObject despawns below, so it can't carry the RPC to
|
||||||
|
// remote peers itself (same reason WaveManager routes kill/leak popups).
|
||||||
|
var pm = TowerPlacementManager.Instance;
|
||||||
|
if (pm != null) pm.BroadcastSellEffect(transform.position);
|
||||||
|
|
||||||
|
if (NetworkObject != null && NetworkObject.IsSpawned)
|
||||||
|
NetworkObject.Despawn(destroy: true);
|
||||||
|
}
|
||||||
|
|
||||||
// ----- IMinimapEntity -------------------------------------------------
|
// ----- IMinimapEntity -------------------------------------------------
|
||||||
//
|
//
|
||||||
// Towers are static, so WorldPosition is cheap (no movement to track). Color reflects
|
// Towers are static, so WorldPosition is cheap (no movement to track). Color reflects
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using UnityEngine;
|
||||||
using TD.Core;
|
using TD.Core;
|
||||||
using TD.Levels;
|
using TD.Levels;
|
||||||
using TD.Towers;
|
using TD.Towers;
|
||||||
|
using TD.VFX;
|
||||||
|
|
||||||
namespace TD.Gameplay
|
namespace TD.Gameplay
|
||||||
{
|
{
|
||||||
|
|
@ -350,6 +351,26 @@ namespace TD.Gameplay
|
||||||
$"client {req.SenderClientId} ({placingSlot}) at anchor {req.Anchor}.");
|
$"client {req.SenderClientId} ({placingSlot}) at anchor {req.Anchor}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Sell effects (persistent broadcaster) ----------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-only: tells every peer to play the tower-sold VFX/SFX at
|
||||||
|
/// <paramref name="worldPos"/>. Called by <see cref="TowerInstance"/> as it sells,
|
||||||
|
/// because the tower's own NetworkObject despawns the same frame — a persistent
|
||||||
|
/// object must carry the broadcast so it isn't dropped.
|
||||||
|
/// </summary>
|
||||||
|
public void BroadcastSellEffect(Vector3 worldPos)
|
||||||
|
{
|
||||||
|
if (!IsServer) return;
|
||||||
|
PlaySellEffectRpc(worldPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Rpc(SendTo.Everyone)]
|
||||||
|
private void PlaySellEffectRpc(Vector3 worldPos)
|
||||||
|
{
|
||||||
|
SellEffectSpawner.Instance?.Play(worldPos);
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Server-side commit hooks called by Builder ------------------
|
// ----- Server-side commit hooks called by Builder ------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,22 @@ namespace TD.Towers
|
||||||
"successful server-side placement validation.")]
|
"successful server-side placement validation.")]
|
||||||
public int GoldCost;
|
public int GoldCost;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Selling
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Header("Selling")]
|
||||||
|
[Tooltip("Portion of the gold INVESTED in this tower (placement cost plus any gold " +
|
||||||
|
"later spent upgrading it) that is refunded when it is sold, as a 0–1 factor. " +
|
||||||
|
"Default 0.75 = 75%.")]
|
||||||
|
[Range(0f, 1f)]
|
||||||
|
public float SellRefundPercent = 0.75f;
|
||||||
|
|
||||||
|
[Tooltip("When true, this tower refunds 100% of its invested gold while it has never " +
|
||||||
|
"been upgraded (upgradeCount == 0); once upgraded it falls back to " +
|
||||||
|
"SellRefundPercent. Set on the Wall so a fresh wall can be re-mazed for free.")]
|
||||||
|
public bool FullRefundIfUnupgraded = false;
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Construction
|
// Construction
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1178,19 +1178,30 @@ namespace TD.UI
|
||||||
return btn;
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sell action for a completed tower. Refund is computed on the TowerInstance (single
|
||||||
|
// source of truth shared with the server) so the badge shows exactly what the player
|
||||||
|
// gets back. Hotkey is Key.None: the bottom-right slot's letter (B) is already the
|
||||||
|
// global buff-menu toggle — see CreateBuffMenuButton — so binding it here would fire
|
||||||
|
// both. The button stays click-only.
|
||||||
private VisualElement CreateSellButton(TowerInstance tower, Key hotkey)
|
private VisualElement CreateSellButton(TowerInstance tower, Key hotkey)
|
||||||
{
|
{
|
||||||
int sellValue = tower.Definition != null
|
// Only the tower's owner can sell it (the server enforces this too). A non-owner
|
||||||
? Mathf.RoundToInt(tower.Definition.GoldCost * 0.7f)
|
// may have this tower view-selected — show the slot disabled with no refund badge
|
||||||
: 0;
|
// so the action reads as unavailable rather than misleading.
|
||||||
|
bool ownedByLocal = tower != null
|
||||||
|
&& PlayerMatchState.Local != null
|
||||||
|
&& tower.Owner == PlayerMatchState.Local.Slot;
|
||||||
|
|
||||||
|
int sellValue = ownedByLocal ? tower.ComputeSellRefund() : 0;
|
||||||
var btn = CreateActionButton(
|
var btn = CreateActionButton(
|
||||||
costText: sellValue > 0 ? $"+{sellValue}g" : "",
|
costText: sellValue > 0 ? $"+{sellValue}g" : "",
|
||||||
hotkey: hotkey,
|
hotkey: Key.None,
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
{
|
{
|
||||||
/* TODO: sell flow */
|
if (tower != null)
|
||||||
|
tower.RequestSellServerRpc();
|
||||||
});
|
});
|
||||||
btn.SetEnabled(false);
|
btn.SetEnabled(ownedByLocal);
|
||||||
return btn;
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1437,7 +1448,11 @@ namespace TD.UI
|
||||||
ttStats.text = "(stats pending)";
|
ttStats.text = "(stats pending)";
|
||||||
}
|
}
|
||||||
|
|
||||||
int sellValue = Mathf.RoundToInt(def.GoldCost * 0.7f);
|
// Sell preview for a freshly placed (un-upgraded) tower: mirrors
|
||||||
|
// TowerInstance.ComputeSellRefund for invested == GoldCost, upgradeCount == 0.
|
||||||
|
int sellValue = def.FullRefundIfUnupgraded
|
||||||
|
? def.GoldCost
|
||||||
|
: Mathf.RoundToInt(def.GoldCost * def.SellRefundPercent);
|
||||||
ttCost.text = $"Cost: {def.GoldCost}g · Sell: {sellValue}g";
|
ttCost.text = $"Cost: {def.GoldCost}g · Sell: {sellValue}g";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
101
Assets/_Project/Scripts/VFX/CoinBurstVfx.cs
Normal file
101
Assets/_Project/Scripts/VFX/CoinBurstVfx.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Assets/_Project/Scripts/VFX/SellEffectSpawner.cs
Normal file
83
Assets/_Project/Scripts/VFX/SellEffectSpawner.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue