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