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

@ -143,11 +143,17 @@ namespace TD.Gameplay
/// <summary>
/// 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
/// <see cref="GoldEarnedThisWave"/> 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 <paramref name="countAsEarned"/>
/// is true (the default) it also increments <see cref="GoldEarnedThisWave"/> so the
/// HUD's per-wave counter reflects it; spending does not decrement that counter (it
/// tracks earnings, not balance).
/// </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)
{
@ -158,7 +164,7 @@ namespace TD.Gameplay
if (amount <= 0) return;
currentGold.Value += amount;
goldEarnedThisWave.Value += amount;
if (countAsEarned) goldEarnedThisWave.Value += amount;
}
/// <summary>

View file

@ -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<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 -------------------------------------------
// 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>
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>
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;
/// <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 -------------------------------------------------
//
// Towers are static, so WorldPosition is cheap (no movement to track). Color reflects

View file

@ -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) ----------------------
/// <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 ------------------
/// <summary>