First pass at full refactor to 2.0 design

Restructures the game around the cyclical run loop from Game Design Doc V2:
5 waves = a cycle, 3 cycles = a phase, each phase ends in a boss.

- New TD.Gameplay.Waves: WaveGroup / PhaseDefinition / RunDefinition author the
  run as draggable weighted pools; RunState owns phase/cycle position, the drawn
  wave slots, and the per-slot enemy buff sets. WaveManager's flat wave array is
  gone -- it now only runs the encounter RunState points at.
- New TD.Gameplay.EnemyUpgrades: the post-wave enemy-buff vote, with public
  live-replicated ballots so the HUD can show who voted for what.
- Inter-wave flow is now strictly sequential: draft -> vote -> build, each stage
  ending early once every player has acted.
- Enemy abilities inverted from per-instance random rolls to deterministic,
  stacking per-wave-slot sets. Six cards ship: Split (reworked), Flight, Blink,
  No Bounty, Gold Theft, Double Up.
- Tower upgrades are a two-step tree: a draft pick unlocks a node, gold converts
  an already-placed tower in place.
- Boss encounters flag their enemies and drive a boss HP bar.
- Player cap reduced to 3 via MatchRules.MaxPlayers.
- GoldConfig is now keyed by global encounter number rather than wave index.

Compiles clean; NOT yet verified in-engine. Editor wiring still required --
see Docs/2.0_Setup_Checklist.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Matt F 2026-07-30 17:45:11 -07:00
parent 7e5c3a8279
commit 4892d7253d
64 changed files with 4023 additions and 344 deletions

View file

@ -303,6 +303,10 @@ namespace TD.Gameplay
ApplyTint();
paintColor.OnValueChanged += HandlePaintColorChanged;
// An upgrade swaps the replicated TypeId out from under every peer; re-resolve so
// clients pick up the new stats and tint instead of holding the pre-upgrade asset.
definitionTypeId.OnValueChanged += HandleDefinitionTypeChanged;
// Register for minimap rendering.
MinimapEntityRegistry.Register(this);
@ -332,7 +336,8 @@ namespace TD.Gameplay
public override void OnNetworkDespawn()
{
paintColor.OnValueChanged -= HandlePaintColorChanged;
paintColor.OnValueChanged -= HandlePaintColorChanged;
definitionTypeId.OnValueChanged -= HandleDefinitionTypeChanged;
// Un-stamp the footprint when the tower is destroyed (sold, wave end, etc.)
// so the tiles become walkable and buildable again.
@ -412,6 +417,152 @@ namespace TD.Gameplay
upgradeCount.Value += 1;
}
// ----- Upgrading ------------------------------------------------------
/// <summary>
/// Fired on every peer when this tower's definition changes (i.e. it was upgraded).
/// The HUD subscribes to relabel a selected tower's action grid.
/// </summary>
public event System.Action OnDefinitionChanged;
/// <summary>
/// Gold cost to convert this tower into <paramref name="target"/>. The target's own
/// <see cref="TowerDefinition.GoldCost"/> is the price — upgrade nodes are never placed
/// directly, so their cost field is free to mean "what this upgrade costs".
/// </summary>
public static int GetUpgradeCost(TowerDefinition target) => target != null ? target.GoldCost : 0;
/// <summary>
/// True if <paramref name="target"/> is a legal upgrade of this tower's current type: a
/// direct child in the upgrade tree, with a matching footprint.
/// </summary>
/// <remarks>
/// <b>The footprint check is not cosmetic.</b> A tower's occupied/unwalkable tiles are
/// stamped into the grid at its current size; converting to a differently-sized node would
/// leave the stamp describing a shape the tower no longer has, corrupting both pathfinding
/// and future placement checks. Growing a tower's footprint needs a stamp-swap (and a
/// re-validation that the new tiles are even free), which the tree doesn't currently need —
/// so it's rejected loudly rather than half-supported.
/// </remarks>
public bool CanUpgradeTo(TowerDefinition target)
{
if (target == null || resolvedDefinition == null) return false;
if (resolvedDefinition.UpgradePaths == null) return false;
bool isChild = false;
foreach (var path in resolvedDefinition.UpgradePaths)
{
if (path == target) { isChild = true; break; }
}
if (!isChild) return false;
return target.FootprintSize == resolvedDefinition.FootprintSize;
}
/// <summary>
/// Collects the upgrades <paramref name="clientId"/> can currently apply to this tower:
/// direct children of its type that the player has unlocked. Does not filter on gold —
/// the HUD shows unaffordable upgrades disabled rather than hiding them, so players can
/// see what they're saving for.
/// </summary>
public void CollectAvailableUpgrades(ulong clientId, List<(TowerDefinition Def, int TypeId)> into)
{
into.Clear();
var unlocked = PlayerTowerUpgrades.GetForClient(clientId);
var pm = TowerPlacementManager.Instance;
if (unlocked == null || pm == null || resolvedDefinition?.UpgradePaths == null) return;
foreach (var path in resolvedDefinition.UpgradePaths)
{
if (path == null) continue;
if (!pm.TryGetTypeId(path, out int typeId)) continue;
if (!unlocked.Contains(typeId)) continue;
if (!CanUpgradeTo(path)) continue;
into.Add((path, typeId));
}
}
/// <summary>
/// Client → server request to convert this tower into <paramref name="targetTypeId"/>.
/// Accepted only from the owner, only for a node they've unlocked, only along a real tree
/// edge, and only if they can pay.
/// </summary>
/// <remarks>
/// Every check is repeated here even though the HUD already filters — the HUD is a
/// convenience, this is the authority. Same posture as placement, paint, and sell.
/// </remarks>
[Rpc(SendTo.Server)]
public void RequestUpgradeServerRpc(int targetTypeId, RpcParams rpcParams = default)
{
if (!IsServer) return;
if (serverSold) return; // mid-sell; nothing to upgrade
ulong senderClientId = rpcParams.Receive.SenderClientId;
PlayerSlot senderSlot = PlayerMatchState.SlotForClient(senderClientId);
if (senderSlot == PlayerSlot.None || senderSlot != ownerSlot.Value)
{
Debug.Log($"[TowerInstance] Upgrade rejected: client {senderClientId} " +
$"({senderSlot}) does not own tower owned by {ownerSlot.Value}.");
return;
}
var target = TowerPlacementManager.GetDefinition(targetTypeId);
if (target == null)
{
Debug.Log($"[TowerInstance] Upgrade rejected: TypeId {targetTypeId} is not in the catalog.");
return;
}
if (!CanUpgradeTo(target))
{
Debug.Log($"[TowerInstance] Upgrade rejected: '{target.name}' is not a " +
$"same-footprint child of '{resolvedDefinition?.name}'.");
return;
}
var unlocked = PlayerTowerUpgrades.GetForClient(senderClientId);
if (unlocked == null || !unlocked.Contains(targetTypeId))
{
Debug.Log($"[TowerInstance] Upgrade rejected: client {senderClientId} has not " +
$"unlocked '{target.name}'.");
return;
}
int cost = GetUpgradeCost(target);
var gold = PlayerGoldManager.GetForClient(senderClientId);
if (gold == null || gold.CurrentGold < cost)
{
Debug.Log($"[TowerInstance] Upgrade rejected: client {senderClientId} cannot " +
$"afford '{target.name}' ({cost}g).");
return;
}
if (cost > 0) gold.DeductGold(cost);
// Record the spend before switching type, so the refund reflects everything sunk in
// and the tower loses any full-refund-while-unupgraded status.
ServerAddUpgradeInvestment(cost);
// Switching the replicated TypeId is the upgrade: TowerCombat re-reads Definition
// every tick, so the new stats take effect on the next shot with nothing to notify.
definitionTypeId.Value = targetTypeId;
resolvedDefinition = target;
OnDefinitionChanged?.Invoke();
}
// Re-resolve and re-tint on clients when the type changes under them (an upgrade landed).
private void HandleDefinitionTypeChanged(int previous, int current)
{
if (previous == current) return;
resolvedDefinition = TowerPlacementManager.GetDefinition(current);
ApplyTint();
OnDefinitionChanged?.Invoke();
}
/// <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