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>
419 lines
16 KiB
C#
419 lines
16 KiB
C#
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs
|
|
using System.Collections.Generic;
|
|
using Unity.Netcode;
|
|
using UnityEngine;
|
|
using TD.Core;
|
|
using TD.Gameplay.Waves;
|
|
|
|
namespace TD.Gameplay.EnemyUpgrades
|
|
{
|
|
/// <summary>
|
|
/// The shared, <b>open-ballot</b> vote held after each wave clears: players collectively pick
|
|
/// which permanent buff the wave they just beat will carry into the next cycle.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>Ballots are public by design.</b> <see cref="ballots"/> replicates to everyone the
|
|
/// instant a vote is cast, and the HUD paints each voter's slot badge onto the card they chose.
|
|
/// That visibility is the point of the mechanic — seeing two teammates converge on "Flying" is
|
|
/// what lets the third decide whether to join them or split the vote. A private tally would
|
|
/// reduce this to a dice roll with extra steps.</para>
|
|
///
|
|
/// <para><b>Votes stay changeable</b> until the vote resolves, for the same reason. The
|
|
/// consequence is that the last player to vote ends the vote instantly (see
|
|
/// <see cref="AllPlayersVoted"/>), locking their choice in with no chance for anyone to
|
|
/// respond. If that reads badly in playtest, the fix is a short grace period after the final
|
|
/// ballot rather than closing the ballots.</para>
|
|
///
|
|
/// <para><b>Scene singleton, server-authoritative.</b> Unlike <c>PlayerDraft</c> (one per
|
|
/// player) this is one shared object, so votes arrive as plain server RPCs and the server maps
|
|
/// the sender to a <see cref="PlayerSlot"/> itself rather than trusting an owner claim.</para>
|
|
/// </remarks>
|
|
public class WaveVote : NetworkBehaviour
|
|
{
|
|
// ----- Singleton ---------------------------------------------------
|
|
|
|
public static WaveVote Instance { get; private set; }
|
|
|
|
// ----- Inspector ---------------------------------------------------
|
|
|
|
[Tooltip("How many buff cards the vote offers. Standard is 3.")]
|
|
[SerializeField] private int optionsPerVote = 3;
|
|
|
|
// ----- Networked state ---------------------------------------------
|
|
|
|
// The EnemyUpgradeOptionIds on the table. Empty when no vote is open.
|
|
private NetworkList<int> offeredOptionIds;
|
|
|
|
// Ballots indexed by (int)PlayerSlot, sized 10 (slots 0-9; index 0 = PlayerSlot.None and
|
|
// is never written). Value is the option id voted for, or NoVote. Public read permission
|
|
// is deliberate — see the class remarks.
|
|
private NetworkList<int> ballots;
|
|
|
|
/// <summary>Sentinel stored in <see cref="ballots"/> for a player who hasn't voted.</summary>
|
|
public const int NoVote = -1;
|
|
|
|
// Wave slot this vote will upgrade, or -1 when no vote is open.
|
|
private readonly NetworkVariable<int> targetSlot = new NetworkVariable<int>(
|
|
-1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
|
|
|
/// <summary>Fired on every peer when the offered set, any ballot, or the open/closed
|
|
/// state changes. The HUD subscribes to rebuild the vote panel and its voter badges.</summary>
|
|
public event System.Action OnVoteChanged;
|
|
|
|
// ----- Lifecycle ----------------------------------------------------
|
|
|
|
private void Awake()
|
|
{
|
|
offeredOptionIds = new NetworkList<int>();
|
|
ballots = new NetworkList<int>();
|
|
}
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
if (Instance != null && Instance != this)
|
|
{
|
|
Debug.LogError("[WaveVote] Duplicate WaveVote detected. Only one may exist per scene.");
|
|
return;
|
|
}
|
|
Instance = this;
|
|
|
|
if (IsServer)
|
|
{
|
|
// One entry per PlayerSlot value (None + Player1..Player9).
|
|
for (int i = 0; i < 10; i++) ballots.Add(NoVote);
|
|
}
|
|
|
|
offeredOptionIds.OnListChanged += HandleListChanged;
|
|
ballots.OnListChanged += HandleListChanged;
|
|
targetSlot.OnValueChanged += HandleTargetChanged;
|
|
}
|
|
|
|
public override void OnNetworkDespawn()
|
|
{
|
|
offeredOptionIds.OnListChanged -= HandleListChanged;
|
|
ballots.OnListChanged -= HandleListChanged;
|
|
targetSlot.OnValueChanged -= HandleTargetChanged;
|
|
|
|
if (Instance == this) Instance = null;
|
|
}
|
|
|
|
private void HandleListChanged(NetworkListEvent<int> _) => OnVoteChanged?.Invoke();
|
|
private void HandleTargetChanged(int _, int __) => OnVoteChanged?.Invoke();
|
|
|
|
// ----- Read API ------------------------------------------------------
|
|
|
|
/// <summary>True while a vote is open for players to cast or change a ballot.</summary>
|
|
public bool IsOpen => targetSlot.Value >= 0;
|
|
|
|
/// <summary>Wave slot this vote will upgrade, or -1 when closed.</summary>
|
|
public int TargetSlot => targetSlot.Value;
|
|
|
|
public int OptionCount => offeredOptionIds.Count;
|
|
|
|
public int GetOptionId(int index)
|
|
=> (index >= 0 && index < offeredOptionIds.Count) ? offeredOptionIds[index] : -1;
|
|
|
|
/// <summary>The option <paramref name="slot"/> voted for, or <see cref="NoVote"/>.</summary>
|
|
public int GetBallot(PlayerSlot slot)
|
|
{
|
|
int i = (int)slot;
|
|
return (i >= 0 && i < ballots.Count) ? ballots[i] : NoVote;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends every player slot that voted for <paramref name="optionId"/>. Drives the voter
|
|
/// badges the HUD paints onto each card.
|
|
/// </summary>
|
|
public void GetVotersFor(int optionId, List<PlayerSlot> into)
|
|
{
|
|
into.Clear();
|
|
if (optionId < 0) return;
|
|
|
|
foreach (var pms in PlayerMatchState.AllPlayers)
|
|
{
|
|
if (pms.Slot == PlayerSlot.None) continue;
|
|
if (GetBallot(pms.Slot) == optionId) into.Add(pms.Slot);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when every connected player with a real slot has cast a ballot. The inter-wave
|
|
/// step uses this to end the vote early instead of burning the whole timer.
|
|
/// </summary>
|
|
public bool AllPlayersVoted
|
|
{
|
|
get
|
|
{
|
|
if (!IsOpen) return false;
|
|
|
|
bool any = false;
|
|
foreach (var pms in PlayerMatchState.AllPlayers)
|
|
{
|
|
if (pms.Slot == PlayerSlot.None) continue;
|
|
any = true;
|
|
if (GetBallot(pms.Slot) == NoVote) return false;
|
|
}
|
|
return any;
|
|
}
|
|
}
|
|
|
|
// ----- Server: open / resolve ----------------------------------------
|
|
|
|
// Scratch reused across vote generation; server is single-threaded.
|
|
private readonly List<EnemyUpgradePoolEntry> entryScratch = new List<EnemyUpgradePoolEntry>();
|
|
private readonly List<int> idScratch = new List<int>();
|
|
private readonly List<float> weightScratch = new List<float>();
|
|
private readonly List<int> tiedScratch = new List<int>();
|
|
|
|
/// <summary>
|
|
/// Server-only: open a vote for <paramref name="waveSlot"/>, drawing cards from the
|
|
/// current phase's allowed pool. Returns false (and opens nothing) if there is nothing
|
|
/// legal left to offer — a wave that has already collected every card its phase allows
|
|
/// simply gets no vote rather than an empty panel.
|
|
/// </summary>
|
|
public bool ServerOpenVote(int waveSlot)
|
|
{
|
|
if (!IsServer) return false;
|
|
|
|
var run = RunState.Instance;
|
|
var pool = EnemyUpgradePool.Instance;
|
|
if (run == null || pool == null)
|
|
{
|
|
Debug.LogWarning("[WaveVote] No RunState or EnemyUpgradePool in scene — " +
|
|
"skipping the enemy-buff vote.");
|
|
return false;
|
|
}
|
|
|
|
if (!CollectCandidatesFor(waveSlot, run, pool))
|
|
{
|
|
Debug.Log($"[WaveVote] No enemy-buff cards left to offer for wave slot " +
|
|
$"{waveSlot}; skipping the vote.");
|
|
return false;
|
|
}
|
|
|
|
// Weighted draw without replacement.
|
|
offeredOptionIds.Clear();
|
|
int draws = Mathf.Min(optionsPerVote, idScratch.Count);
|
|
for (int n = 0; n < draws; n++)
|
|
{
|
|
int pick = DrawWeightedIndex(weightScratch);
|
|
if (pick < 0) break;
|
|
|
|
offeredOptionIds.Add(idScratch[pick]);
|
|
idScratch.RemoveAt(pick);
|
|
weightScratch.RemoveAt(pick);
|
|
}
|
|
|
|
ServerClearBallots();
|
|
targetSlot.Value = waveSlot;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-only: tally the ballots, apply the winner to the target slot, and close the
|
|
/// vote. Returns the winning option id, or -1 if no vote was open.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Ties resolve randomly among the tied options. At three players and three cards a 1/1/1
|
|
/// split is common, and with open ballots the players can see it coming and break it
|
|
/// themselves — which is the intended pressure, not a flaw in the tie-break.
|
|
///
|
|
/// <para>If nobody voted at all (everyone idle), one of the offered cards is chosen at
|
|
/// random. Skipping the buff entirely would quietly make idling the optimal play.</para>
|
|
/// </remarks>
|
|
public int ServerResolve()
|
|
{
|
|
if (!IsServer || !IsOpen) return -1;
|
|
|
|
int slot = targetSlot.Value;
|
|
int winner = TallyWinner();
|
|
|
|
if (winner >= 0)
|
|
{
|
|
var run = RunState.Instance;
|
|
var pool = EnemyUpgradePool.Instance;
|
|
|
|
run?.ServerAddUpgrade(slot, winner);
|
|
pool?.Get(winner)?.ServerOnApplied(run, slot);
|
|
|
|
Debug.Log($"[WaveVote] Wave slot {slot} gains " +
|
|
$"'{pool?.Get(winner)?.DisplayName ?? winner.ToString()}'.");
|
|
}
|
|
|
|
ServerClose();
|
|
return winner;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-only: apply <paramref name="count"/> buffs to <paramref name="slot"/> outright,
|
|
/// with no vote. Returns how many actually landed. Used by the vote-meta cards that trade
|
|
/// a future vote away for extra buffs.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Deliberately opens no ballot: the card's whole cost is that the players <i>don't</i> get
|
|
/// to choose this time. Drawing from the same weighted, slot-filtered candidate set the
|
|
/// vote would have used keeps the outcome fair rather than worst-case.
|
|
/// </remarks>
|
|
public int ServerAutoApply(int slot, int count)
|
|
{
|
|
if (!IsServer || count <= 0) return 0;
|
|
|
|
var run = RunState.Instance;
|
|
var pool = EnemyUpgradePool.Instance;
|
|
if (run == null || pool == null) return 0;
|
|
|
|
var phase = run.CurrentPhase;
|
|
if (phase == null) return 0;
|
|
|
|
int applied = 0;
|
|
for (int n = 0; n < count; n++)
|
|
{
|
|
// Re-collect each time: applying one buff can invalidate another (a card the slot
|
|
// now has, or one whose prerequisite just changed).
|
|
if (!CollectCandidatesFor(slot, run, pool)) break;
|
|
|
|
int pick = DrawWeightedIndex(weightScratch);
|
|
if (pick < 0) break;
|
|
|
|
int id = idScratch[pick];
|
|
run.ServerAddUpgrade(slot, id);
|
|
pool.Get(id)?.ServerOnApplied(run, slot);
|
|
applied++;
|
|
|
|
Debug.Log($"[WaveVote] Auto-applied '{pool.Get(id)?.DisplayName ?? id.ToString()}' " +
|
|
$"to wave slot {slot}.");
|
|
}
|
|
|
|
return applied;
|
|
}
|
|
|
|
/// <summary>Server-only: close the vote without applying anything.</summary>
|
|
public void ServerClose()
|
|
{
|
|
if (!IsServer) return;
|
|
targetSlot.Value = -1;
|
|
offeredOptionIds.Clear();
|
|
ServerClearBallots();
|
|
}
|
|
|
|
private void ServerClearBallots()
|
|
{
|
|
for (int i = 0; i < ballots.Count; i++) ballots[i] = NoVote;
|
|
}
|
|
|
|
// Highest vote count wins; ties broken at random. No votes cast → random offered card.
|
|
private int TallyWinner()
|
|
{
|
|
if (offeredOptionIds.Count == 0) return -1;
|
|
|
|
int best = 0;
|
|
tiedScratch.Clear();
|
|
|
|
for (int i = 0; i < offeredOptionIds.Count; i++)
|
|
{
|
|
int id = offeredOptionIds[i];
|
|
int count = 0;
|
|
|
|
foreach (var pms in PlayerMatchState.AllPlayers)
|
|
{
|
|
if (pms.Slot == PlayerSlot.None) continue;
|
|
if (GetBallot(pms.Slot) == id) count++;
|
|
}
|
|
|
|
if (count > best)
|
|
{
|
|
best = count;
|
|
tiedScratch.Clear();
|
|
tiedScratch.Add(id);
|
|
}
|
|
else if (count == best && best > 0)
|
|
{
|
|
tiedScratch.Add(id);
|
|
}
|
|
}
|
|
|
|
if (tiedScratch.Count == 0)
|
|
{
|
|
// Nobody voted — pick one of the offered cards rather than letting the wave
|
|
// escape unbuffed, which would make going idle the strongest play.
|
|
return offeredOptionIds[Random.Range(0, offeredOptionIds.Count)];
|
|
}
|
|
|
|
return tiedScratch[Random.Range(0, tiedScratch.Count)];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fills <see cref="idScratch"/>/<see cref="weightScratch"/> with the cards this phase
|
|
/// allows that <paramref name="slot"/> can still meaningfully take. Returns false when
|
|
/// nothing qualifies.
|
|
/// </summary>
|
|
private bool CollectCandidatesFor(int slot, RunState run, EnemyUpgradePool pool)
|
|
{
|
|
var phase = run.CurrentPhase;
|
|
if (phase == null) return false;
|
|
|
|
pool.CollectAllowedIds(phase.EnemyUpgradeGroups, entryScratch, idScratch, weightScratch);
|
|
|
|
// Filter to what this slot can actually take: not already applied, and the card itself
|
|
// agrees it's a meaningful offer.
|
|
for (int i = idScratch.Count - 1; i >= 0; i--)
|
|
{
|
|
int id = idScratch[i];
|
|
var option = pool.Get(id);
|
|
|
|
bool valid = option != null
|
|
&& !run.SlotHasUpgrade(slot, id)
|
|
&& option.IsValidForSlot(run, slot);
|
|
|
|
if (!valid)
|
|
{
|
|
idScratch.RemoveAt(i);
|
|
weightScratch.RemoveAt(i);
|
|
}
|
|
}
|
|
|
|
return idScratch.Count > 0;
|
|
}
|
|
|
|
private static int DrawWeightedIndex(List<float> weights)
|
|
{
|
|
if (weights == null || weights.Count == 0) return -1;
|
|
|
|
float total = 0f;
|
|
for (int i = 0; i < weights.Count; i++) total += Mathf.Max(0f, weights[i]);
|
|
if (total <= 0f) return -1;
|
|
|
|
float roll = Random.value * total;
|
|
for (int i = 0; i < weights.Count; i++)
|
|
{
|
|
roll -= Mathf.Max(0f, weights[i]);
|
|
if (roll <= 0f) return i;
|
|
}
|
|
return weights.Count - 1; // float drift fallback
|
|
}
|
|
|
|
// ----- Client → server ------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Cast or change this player's vote. Any client may send; the server resolves the sender
|
|
/// to a slot itself and ignores anything it can't attribute or that isn't on the table.
|
|
/// </summary>
|
|
[Rpc(SendTo.Server)]
|
|
public void RequestVoteRpc(int optionId, RpcParams rpcParams = default)
|
|
{
|
|
if (!IsOpen) return;
|
|
|
|
var senderSlot = PlayerMatchState.SlotForClient(rpcParams.Receive.SenderClientId);
|
|
if (senderSlot == PlayerSlot.None) return;
|
|
|
|
// Only options actually on the table are accepted — a client can't vote a card in.
|
|
bool offered = false;
|
|
for (int i = 0; i < offeredOptionIds.Count; i++)
|
|
if (offeredOptionIds[i] == optionId) { offered = true; break; }
|
|
if (!offered) return;
|
|
|
|
int index = (int)senderSlot;
|
|
if (index >= 0 && index < ballots.Count) ballots[index] = optionId;
|
|
}
|
|
}
|
|
}
|