Fix sell hitch: batch footprint un-stamp; warn on missing SellEffectSpawner

TowerInstance.StampFootprint now writes walkability via SetWalkableBatch instead of per-tile SetWalkable, so despawning (selling) a 2x2 tower fires one OnWalkabilityChanged / enemy re-path instead of four — matching placement and removing the frame hitch on sell. PlaySellEffectRpc now logs a one-time warning when no SellEffectSpawner is in the scene, since a missing spawner silently suppressed the coin VFX and sound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matt F 2026-07-14 20:44:11 -07:00
parent 86bc916c49
commit 1794f65b19
2 changed files with 29 additions and 2 deletions

View file

@ -1,4 +1,5 @@
// Assets/_Project/Scripts/Gameplay/TowerInstance.cs // Assets/_Project/Scripts/Gameplay/TowerInstance.cs
using System.Collections.Generic;
using Unity.Netcode; using Unity.Netcode;
using UnityEngine; using UnityEngine;
using TD.Core; using TD.Core;
@ -511,11 +512,20 @@ namespace TD.Gameplay
? resolvedDefinition.FootprintSize ? resolvedDefinition.FootprintSize
: new Vector2Int(2, 2); : 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)) foreach (var tile in GridCoordinates.GetFootprintTiles(anchorTile.Value, footprintSize))
{ {
loader.SetWalkable(tile, walkable); footprint.Add(tile);
loader.SetOccupied(tile, occupied); loader.SetOccupied(tile, occupied);
} }
loader.SetWalkableBatch(footprint, walkable);
} }
// Reused per-instance across color updates to avoid per-call GC allocation. // Reused per-instance across color updates to avoid per-call GC allocation.

View file

@ -365,10 +365,27 @@ namespace TD.Gameplay
PlaySellEffectRpc(worldPos); 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)] [Rpc(SendTo.Everyone)]
private void PlaySellEffectRpc(Vector3 worldPos) private void PlaySellEffectRpc(Vector3 worldPos)
{ {
SellEffectSpawner.Instance?.Play(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 ------------------ // ----- Server-side commit hooks called by Builder ------------------