diff --git a/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs b/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs index 41a7c04..3fd7350 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, InvokePermission = RpcInvokePermission.Owner)] + [Rpc(SendTo.Server, RequireOwnership = true)] 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, InvokePermission = RpcInvokePermission.Owner)] + [Rpc(SendTo.Server, RequireOwnership = true)] 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 52efbdc..506791e 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, InvokePermission = RpcInvokePermission.Owner)] + [Rpc(SendTo.Server, RequireOwnership = true)] public void RequestPurchaseBuffRpc(int categoryIndex) { if (categories == null || categoryIndex < 0 || categoryIndex >= categories.Length) diff --git a/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs b/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs index 4cbd3a4..1cb3170 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs @@ -143,17 +143,11 @@ 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. 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). + /// 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). /// - /// - /// 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) + public void AwardGold(int amount) { if (!IsServer) { @@ -164,7 +158,7 @@ namespace TD.Gameplay if (amount <= 0) return; currentGold.Value += amount; - if (countAsEarned) goldEarnedThisWave.Value += amount; + goldEarnedThisWave.Value += amount; } /// diff --git a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs index 1b01a4a..519ff04 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs @@ -105,24 +105,6 @@ 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. @@ -162,12 +144,6 @@ 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; @@ -278,11 +254,6 @@ 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; } @@ -374,84 +345,6 @@ 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 92b349d..7c4662d 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs @@ -5,7 +5,6 @@ using UnityEngine; using TD.Core; using TD.Levels; using TD.Towers; -using TD.VFX; namespace TD.Gameplay { @@ -351,26 +350,6 @@ 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 8163490..3ce1632 100644 --- a/Assets/_Project/Scripts/Towers/TowerDefinition.cs +++ b/Assets/_Project/Scripts/Towers/TowerDefinition.cs @@ -47,22 +47,6 @@ 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 f581be9..96f50ec 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -1178,30 +1178,19 @@ 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) { - // 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; + int sellValue = tower.Definition != null + ? Mathf.RoundToInt(tower.Definition.GoldCost * 0.7f) + : 0; var btn = CreateActionButton( costText: sellValue > 0 ? $"+{sellValue}g" : "", - hotkey: Key.None, + hotkey: hotkey, onClick: () => { - if (tower != null) - tower.RequestSellServerRpc(); + /* TODO: sell flow */ }); - btn.SetEnabled(ownedByLocal); + btn.SetEnabled(false); return btn; } @@ -1448,11 +1437,7 @@ namespace TD.UI ttStats.text = "(stats pending)"; } - // 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); + int sellValue = Mathf.RoundToInt(def.GoldCost * 0.7f); 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 deleted file mode 100644 index e9d71eb..0000000 --- a/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs +++ /dev/null @@ -1,101 +0,0 @@ -// 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/CoinBurstVfx.cs.meta b/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta deleted file mode 100644 index 58bc699..0000000 --- a/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: bdff685029f23004796671150c7cf39b \ No newline at end of file diff --git a/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs b/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs deleted file mode 100644 index 0b67fc3..0000000 --- a/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs +++ /dev/null @@ -1,83 +0,0 @@ -// 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); - } - } -} diff --git a/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta b/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta deleted file mode 100644 index 7ae5f2a..0000000 --- a/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e2708c6bac8136f45b48c899d3d40630 \ No newline at end of file