Merge pull request 'tower-sell' (#12) from tower-sell into main

Reviewed-on: #12
This commit is contained in:
matt 2026-07-14 23:54:08 -07:00
commit 52f7bfdf51
28 changed files with 5746 additions and 33 deletions

View file

@ -137,7 +137,7 @@ namespace TD.Gameplay.Draft
// ----- Owner → server RPCs ----------------------------------------
/// <summary>Owning client: pick one of the offered options by id.</summary>
[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).
/// </summary>
[Rpc(SendTo.Server, RequireOwnership = true)]
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestBuyRerollRpc()
{
if (HasActiveDraft) return; // resolve the pending draft before buying another

View file

@ -21,8 +21,10 @@ namespace TD.Gameplay
/// and stores the tile waypoint list.</item>
/// <item>Each frame: moves toward the world center of <c>remainingPath[0]</c>.
/// When within snap distance, pops the waypoint and checks for zone transitions.</item>
/// <item>When <see cref="PathfindingService.OnPathsInvalidated"/> fires (tower placed /
/// sold), <see cref="RecomputePath"/> reruns A* from the current tile.</item>
/// <item>When the maze changes (tower placed / sold), <see cref="PathfindingService"/>'s
/// budgeted scheduler calls <see cref="RecomputePath"/> (registered via
/// <see cref="PathfindingService.RegisterMover"/>), rerunning A* from the current
/// tile — spread across frames so a full wave never spikes.</item>
/// <item>When <c>remainingPath</c> is empty after a pop, the enemy has reached the
/// goal — <see cref="OnReachedGoal"/> fires and the enemy is despawned.</item>
/// </list>
@ -168,15 +170,18 @@ namespace TD.Gameplay
// Recompute when a tower is placed or sold — grounded enemies only.
// Flyers path on the static baked grid, so tower changes can never affect
// their route; subscribing would just trigger needless recomputes.
// their route; registering would just trigger needless recomputes.
// Registration (not a direct event subscription) lets PathfindingService
// spread the recomputes across frames under a per-frame budget, so a maze
// change with a full wave present never spikes a single frame.
if (!isFlying && PathfindingService.Instance != null)
PathfindingService.Instance.OnPathsInvalidated += RecomputePath;
PathfindingService.Instance.RegisterMover(this);
}
public override void OnNetworkDespawn()
{
if (PathfindingService.Instance != null)
PathfindingService.Instance.OnPathsInvalidated -= RecomputePath;
PathfindingService.Instance.UnregisterMover(this);
}
// ----- Server update --------------------------------------------------
@ -274,8 +279,9 @@ namespace TD.Gameplay
// ----- Path invalidation ----------------------------------------------
// Called on server when LevelLoader.OnWalkabilityChanged fires (tower placed/sold).
private void RecomputePath()
// Called by PathfindingService's budgeted scheduler after a walkability change
// (tower placed/sold). Public so the scheduler can drive it across frames.
public void RecomputePath()
{
if (!IsServer) return;

View file

@ -26,13 +26,15 @@ namespace TD.Gameplay
/// <b>Who calls this:</b>
/// <list type="bullet">
/// <item><see cref="EnemyMovement"/> calls <see cref="ComputePath"/> once on
/// spawn and again whenever <see cref="OnPathsInvalidated"/> fires.</item>
/// spawn, then registers via <see cref="RegisterMover"/> to be re-pathed when
/// the maze changes.</item>
/// </list>
///
/// <b>Invalidation:</b> Subscribes to <see cref="LevelLoader.OnWalkabilityChanged"/>.
/// When a tower is placed or sold, <c>LevelLoader.SetWalkable</c> fires that event
/// and <see cref="OnPathsInvalidated"/> is relayed to all active enemies, which
/// each recompute their own path from their current tile.
/// When a tower is placed or sold, <c>LevelLoader.SetWalkable</c> fires that event; the
/// service then enqueues every registered grounded enemy and recomputes them under a
/// per-frame time budget (<c>recomputeBudgetMs</c>), draining across frames so a maze
/// change with a full wave present never spikes a single frame.
///
/// <b>Goal tile set:</b> Built once on <c>Start</c> from
/// <c>LevelLoader.LevelData.Goals[].TileArea</c>. Goal tiles never change at
@ -52,7 +54,10 @@ namespace TD.Gameplay
/// <summary>
/// Fired on every peer when the walkability grid changes (tower placed/sold).
/// <see cref="EnemyMovement"/> subscribes per-instance to recompute its path.
/// Enemies no longer subscribe here — they register with the budgeted re-path
/// scheduler (<see cref="RegisterMover"/>) so recomputes spread across frames.
/// This event remains as an extension point for any non-enemy listener that wants
/// immediate notification of a maze change.
/// </summary>
public event System.Action OnPathsInvalidated;
@ -79,6 +84,28 @@ namespace TD.Gameplay
private readonly Dictionary<Vector2Int, float> gScore = new Dictionary<Vector2Int, float>();
private readonly SimplePriorityQueue openSet = new SimplePriorityQueue();
// ----- Deferred re-path scheduler ---------------------------------
//
// Grounded enemies register here (flyers never re-path — baked grid). On a
// walkability change we enqueue every registered enemy and recompute a
// TIME-BUDGETED number of them per frame, draining the backlog over subsequent
// frames. Recomputing them all synchronously on the change frame spiked to ~0.5s
// with a full wave on this large grid whenever a tower was sold/placed — this
// caps the per-frame cost so no single frame ever hitches. The maze can only be
// opened (sell) or narrowed with a guaranteed remaining route (placement, BFS-
// validated), so an enemy briefly following its slightly-stale path for a few
// frames until its turn comes up is safe and visually negligible.
[Tooltip("Max wall-clock milliseconds spent recomputing enemy paths per frame " +
"after a maze change. The backlog drains over following frames; at least " +
"one enemy is always processed per frame so it converges. Lower = smoother " +
"but slower to fully update; higher = faster to update but larger frame cost.")]
[SerializeField] private float recomputeBudgetMs = 1.5f;
private readonly HashSet<EnemyMovement> movers = new HashSet<EnemyMovement>();
private readonly Queue<EnemyMovement> recomputeQueue = new Queue<EnemyMovement>();
private readonly HashSet<EnemyMovement> queued = new HashSet<EnemyMovement>();
// ----- Lifecycle --------------------------------------------------
private void Awake()
@ -115,6 +142,48 @@ namespace TD.Gameplay
loader.OnWalkabilityChanged -= HandleWalkabilityChanged;
}
// Drains the deferred re-path backlog under a per-frame time budget. Runs on all
// peers, but the queue is only ever populated on the server (clients never register
// movers), so this is a no-op cost on clients. At least one enemy is processed per
// frame whenever the queue is non-empty, so it always converges.
private void Update()
{
if (recomputeQueue.Count == 0) return;
double startMs = Time.realtimeSinceStartupAsDouble * 1000.0;
do
{
var mover = recomputeQueue.Dequeue();
if (!queued.Remove(mover)) continue; // unregistered/cancelled after enqueue
if (mover != null) mover.RecomputePath(); // reads the CURRENT grid state
}
while (recomputeQueue.Count > 0
&& Time.realtimeSinceStartupAsDouble * 1000.0 - startMs < recomputeBudgetMs);
}
// ----- Deferred re-path scheduler API -----------------------------
/// <summary>
/// Registers a grounded enemy to be re-pathed (budgeted, over following frames)
/// whenever the maze changes. Flyers must NOT register — their baked-grid route
/// never changes. Called on the server from <see cref="EnemyMovement"/>.
/// </summary>
public void RegisterMover(EnemyMovement mover)
{
if (mover != null) movers.Add(mover);
}
/// <summary>
/// Removes an enemy from the scheduler (on despawn). Any stale entry still sitting
/// in the pending queue is skipped when dequeued (the <c>queued</c> membership check).
/// </summary>
public void UnregisterMover(EnemyMovement mover)
{
if (mover == null) return;
movers.Remove(mover);
queued.Remove(mover);
}
// ----- Public API -------------------------------------------------
/// <summary>
@ -471,7 +540,16 @@ namespace TD.Gameplay
private void HandleWalkabilityChanged()
{
// Notify any non-enemy listeners immediately (kept for API compatibility).
OnPathsInvalidated?.Invoke();
// Enqueue every registered grounded enemy for a budgeted, deferred recompute
// instead of recomputing them all on this frame. Already-queued enemies are
// deduped, so rapid successive maze changes just lengthen the drain rather
// than compounding into a spike.
foreach (var mover in movers)
if (queued.Add(mover))
recomputeQueue.Enqueue(mover);
}
}

View file

@ -146,7 +146,7 @@ namespace TD.Gameplay
/// a random buff from the category at <paramref name="categoryIndex"/>.
/// The server validates gold and adds the buff if the purchase succeeds.
/// </summary>
[Rpc(SendTo.Server, RequireOwnership = true)]
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestPurchaseBuffRpc(int categoryIndex)
{
if (categories == null || categoryIndex < 0 || categoryIndex >= categories.Length)

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

@ -1,4 +1,5 @@
// Assets/_Project/Scripts/Gameplay/TowerInstance.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
@ -105,6 +106,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 +163,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 +279,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 +375,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
@ -404,11 +512,20 @@ namespace TD.Gameplay
? resolvedDefinition.FootprintSize
: new Vector2Int(2, 2);
// Collect the footprint, then stamp walkability as a BATCH so a single
// OnWalkabilityChanged fires for the whole footprint. Per-tile SetWalkable fires
// that event once PER TILE — on despawn (sell) that meant up to 4 full enemy A*
// re-paths for a 2×2 tower in one frame, which was the sell hitch. Placement
// batches for the same reason (TowerPlacementManager.StampWalkable). Occupancy
// doesn't fire walkability events, so it stays per-tile.
var footprint = new List<Vector2Int>(footprintSize.x * footprintSize.y);
foreach (var tile in GridCoordinates.GetFootprintTiles(anchorTile.Value, footprintSize))
{
loader.SetWalkable(tile, walkable);
footprint.Add(tile);
loader.SetOccupied(tile, occupied);
}
loader.SetWalkableBatch(footprint, walkable);
}
// Reused per-instance across color updates to avoid per-call GC allocation.

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,43 @@ 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);
}
// One-time guard so a missing spawner warns once per peer instead of on every sale.
private static bool s_warnedNoSellSpawner;
[Rpc(SendTo.Everyone)]
private void PlaySellEffectRpc(Vector3 worldPos)
{
var spawner = SellEffectSpawner.Instance;
if (spawner == null)
{
if (!s_warnedNoSellSpawner)
{
Debug.LogWarning("[TowerPlacementManager] A tower was sold, but there is no " +
"SellEffectSpawner in the scene — no coin VFX or sell sound " +
"will play. Add a SellEffectSpawner GameObject to each Match " +
"scene (assign its Sell Sound clip; the coin burst works even " +
"with no VFX prefab).");
s_warnedNoSellSpawner = true;
}
return;
}
spawner.Play(worldPos);
}
// ----- Server-side commit hooks called by Builder ------------------
/// <summary>