From 9be31b16455f57b9a3011d35d44d38f2b55eade4 Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 14 Jul 2026 20:05:06 -0700 Subject: [PATCH 1/2] 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 --- .../Scripts/Gameplay/PlayerGoldManager.cs | 16 ++- .../Scripts/Gameplay/TowerInstance.cs | 107 ++++++++++++++++++ .../Scripts/Gameplay/TowerPlacementManager.cs | 21 ++++ .../Scripts/Towers/TowerDefinition.cs | 16 +++ Assets/_Project/Scripts/UI/HUDController.cs | 29 +++-- Assets/_Project/Scripts/VFX/CoinBurstVfx.cs | 101 +++++++++++++++++ .../_Project/Scripts/VFX/SellEffectSpawner.cs | 83 ++++++++++++++ 7 files changed, 361 insertions(+), 12 deletions(-) create mode 100644 Assets/_Project/Scripts/VFX/CoinBurstVfx.cs create mode 100644 Assets/_Project/Scripts/VFX/SellEffectSpawner.cs diff --git a/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs b/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs index 1cb3170..4cbd3a4 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs @@ -143,11 +143,17 @@ namespace TD.Gameplay /// /// Server-side entry point for awarding gold (wave clear, enemy kill). /// Direct call — not Rpc-wrapped — because awards always originate - /// from server-authoritative game events. Also increments - /// so the HUD's per-wave counter reflects - /// it; spending does not decrement that counter (it tracks earnings, not balance). + /// from server-authoritative game events. When + /// is true (the default) it also increments so the + /// HUD's per-wave counter reflects it; spending does not decrement that counter (it + /// tracks earnings, not balance). /// - public void AwardGold(int amount) + /// + /// 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. + /// + public void AwardGold(int amount, bool countAsEarned = true) { if (!IsServer) { @@ -158,7 +164,7 @@ namespace TD.Gameplay if (amount <= 0) return; currentGold.Value += amount; - goldEarnedThisWave.Value += amount; + if (countAsEarned) goldEarnedThisWave.Value += amount; } /// diff --git a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs index 519ff04..1b01a4a 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs @@ -105,6 +105,24 @@ namespace TD.Gameplay readPerm: NetworkVariableReadPermission.Everyone, 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 goldInvested = + new NetworkVariable( + 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 upgradeCount = + new NetworkVariable( + 0, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + // ----- Local resolved state ------------------------------------------- // Resolved on every client in OnNetworkSpawn from definitionTypeId via the catalog. @@ -144,6 +162,12 @@ namespace TD.Gameplay /// The footprint anchor tile (SW corner, world-tile coords). public Vector2Int AnchorTile => anchorTile.Value; + /// Total gold sunk into this tower so far (placement + upgrades). + public int GoldInvested => goldInvested.Value; + + /// How many upgrades have been applied to this tower (0 = never upgraded). + public int UpgradeCount => upgradeCount.Value; + /// World-unit height the post-construction drop animation falls from. public float DropHeight => dropHeight; @@ -254,6 +278,11 @@ namespace TD.Gameplay anchorTile.Value = pendingAnchor; 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. hasPendingInit = false; } @@ -345,6 +374,84 @@ namespace TD.Gameplay // Re-tint on every client (and the server) when the replicated paint color changes. 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; + + /// + /// 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 of everything + /// invested; a tower flagged + /// (the Wall) returns the full amount while it has never been upgraded. + /// + 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); + } + + /// + /// 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. + /// + public void ServerAddUpgradeInvestment(int cost) + { + if (!IsServer) return; + if (cost > 0) goldInvested.Value += cost; + upgradeCount.Value += 1; + } + + /// + /// 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 — + /// restores the footprint's grid state and clears + /// selection on every peer. + /// + [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 ------------------------------------------------- // // Towers are static, so WorldPosition is cheap (no movement to track). Color reflects diff --git a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs index 7c4662d..92b349d 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs @@ -5,6 +5,7 @@ using UnityEngine; using TD.Core; using TD.Levels; using TD.Towers; +using TD.VFX; namespace TD.Gameplay { @@ -350,6 +351,26 @@ namespace TD.Gameplay $"client {req.SenderClientId} ({placingSlot}) at anchor {req.Anchor}."); } + // ----- Sell effects (persistent broadcaster) ---------------------- + + /// + /// Server-only: tells every peer to play the tower-sold VFX/SFX at + /// . Called by 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. + /// + 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 ------------------ /// diff --git a/Assets/_Project/Scripts/Towers/TowerDefinition.cs b/Assets/_Project/Scripts/Towers/TowerDefinition.cs index 3ce1632..8163490 100644 --- a/Assets/_Project/Scripts/Towers/TowerDefinition.cs +++ b/Assets/_Project/Scripts/Towers/TowerDefinition.cs @@ -47,6 +47,22 @@ namespace TD.Towers "successful server-side placement validation.")] 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 // ------------------------------------------------------------------- diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index 96f50ec..f581be9 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -1178,19 +1178,30 @@ namespace TD.UI 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) { - int sellValue = tower.Definition != null - ? Mathf.RoundToInt(tower.Definition.GoldCost * 0.7f) - : 0; + // Only the tower's owner can sell it (the server enforces this too). A non-owner + // may have this tower view-selected — show the slot disabled with no refund badge + // 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( costText: sellValue > 0 ? $"+{sellValue}g" : "", - hotkey: hotkey, + hotkey: Key.None, onClick: () => { - /* TODO: sell flow */ + if (tower != null) + tower.RequestSellServerRpc(); }); - btn.SetEnabled(false); + btn.SetEnabled(ownedByLocal); return btn; } @@ -1437,7 +1448,11 @@ namespace TD.UI 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"; } diff --git a/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs b/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs new file mode 100644 index 0000000..e9d71eb --- /dev/null +++ b/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs @@ -0,0 +1,101 @@ +// Assets/_Project/Scripts/VFX/CoinBurstVfx.cs +using UnityEngine; + +namespace TD.VFX +{ + /// + /// Self-contained, code-configured gold-coin burst used as the zero-art placeholder for the + /// tower-sell effect. Configures its own for a short radial + /// spray of gold specks thrown up in random arcs under gravity, then destroys itself. + /// + /// + /// Instantiated at runtime by 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. + /// + [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(); + if (ps == null) ps = gameObject.AddComponent(); + 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(); + 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); + } + } +} diff --git a/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs b/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs new file mode 100644 index 0000000..0b67fc3 --- /dev/null +++ b/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs @@ -0,0 +1,83 @@ +// Assets/_Project/Scripts/VFX/SellEffectSpawner.cs +using UnityEngine; +using TD.Audio; + +namespace TD.VFX +{ + /// + /// Scene singleton that plays the "tower sold" feedback — a burst of gold coins plus a + /// coin-rustle sound — at a world position. Mirrors : + /// visual-only, plain MonoBehaviour, invoked on every peer via a ClientRpc so all + /// players see and hear a sale locally. + /// + /// + /// Who calls this: + /// routes a ClientRpc here when a tower is sold. + /// + /// Inspector setup: drop this on a SellEffectSpawner GameObject in each Match + /// scene. The VFX prefab is OPTIONAL — leave it empty to use the built-in + /// placeholder, or assign an authored particle/VFX-graph prefab. + /// Assign the coin-rustle clip on . + /// + 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 ------------------------------------------------- + + /// Plays the coin burst + rustle sound at . + 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(); + } + + if (sellSound.clip != null) + AudioManager.Instance?.Play(sellSound.clip, AudioCategory.UI, + sellSound.RandomPitch(), sellSound.volume); + } + } +} From 86bc916c4936d8cbd463fab842cda3629967cc4a Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 14 Jul 2026 20:22:00 -0700 Subject: [PATCH 2/2] Fix NGO RequireOwnership deprecation warnings; add VFX script metas Replace deprecated [Rpc(..., RequireOwnership = true)] with InvokePermission = RpcInvokePermission.Owner on the owner-submitted draft/buff RPCs (PlayerDraft, PlayerBuffManager), matching the pattern already used elsewhere. Also commits the .meta files Unity generated for the new sell-VFX scripts so their GUIDs are stable across machines. Co-Authored-By: Claude Opus 4.8 --- Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs | 4 ++-- Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs | 2 +- Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta | 2 ++ Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta | 2 ++ 4 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta create mode 100644 Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta diff --git a/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs b/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs index 3fd7350..41a7c04 100644 --- a/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs +++ b/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs @@ -137,7 +137,7 @@ namespace TD.Gameplay.Draft // ----- Owner → server RPCs ---------------------------------------- /// Owning client: pick one of the offered options by id. - [Rpc(SendTo.Server, RequireOwnership = true)] + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] public void RequestPickRpc(int optionId) { ServerResolve(optionId); @@ -148,7 +148,7 @@ namespace TD.Gameplay.Draft /// afford it or already has an unresolved draft (resolve the current one first so a /// free pick is never silently overwritten). /// - [Rpc(SendTo.Server, RequireOwnership = true)] + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] public void RequestBuyRerollRpc() { if (HasActiveDraft) return; // resolve the pending draft before buying another diff --git a/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs b/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs index 506791e..52efbdc 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs @@ -146,7 +146,7 @@ namespace TD.Gameplay /// a random buff from the category at . /// The server validates gold and adds the buff if the purchase succeeds. /// - [Rpc(SendTo.Server, RequireOwnership = true)] + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] public void RequestPurchaseBuffRpc(int categoryIndex) { if (categories == null || categoryIndex < 0 || categoryIndex >= categories.Length) diff --git a/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta b/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta new file mode 100644 index 0000000..58bc699 --- /dev/null +++ b/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bdff685029f23004796671150c7cf39b \ No newline at end of file diff --git a/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta b/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta new file mode 100644 index 0000000..7ae5f2a --- /dev/null +++ b/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e2708c6bac8136f45b48c899d3d40630 \ No newline at end of file