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:
parent
7e5c3a8279
commit
4892d7253d
64 changed files with 4023 additions and 344 deletions
|
|
@ -0,0 +1,62 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs
|
||||
using UnityEngine;
|
||||
using TD.Gameplay.EnemyAbilities;
|
||||
using TD.Gameplay.Waves;
|
||||
|
||||
namespace TD.Gameplay.EnemyUpgrades
|
||||
{
|
||||
/// <summary>
|
||||
/// The workhorse enemy-buff card: grants an <see cref="EnemyAbilityDefinition"/> to a wave
|
||||
/// slot. Every enemy spawned by that wave, in every later cycle of the phase, carries the
|
||||
/// ability.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// There is nothing to do at vote-resolution time — recording the option id on the slot IS the
|
||||
/// effect, because the spawn path builds each enemy's ability set from the slot's recorded
|
||||
/// options. <see cref="Ability"/> is what that lookup resolves to.
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "EnemyUpgrade_Ability",
|
||||
menuName = "TD/Enemy Upgrades/Ability Card", order = 20)]
|
||||
public class AbilityEnemyUpgradeOption : EnemyUpgradeOption
|
||||
{
|
||||
[Header("Payload")]
|
||||
[Tooltip("The ability every enemy in this wave gains. Must also be present in the scene's " +
|
||||
"EnemyAbilityPool so it can be resolved at spawn time.")]
|
||||
public EnemyAbilityDefinition Ability;
|
||||
|
||||
public override bool IsValidForSlot(RunState run, int slot)
|
||||
{
|
||||
if (Ability == null) return false;
|
||||
|
||||
// Don't offer an ability the slot already has by another card's hand. Exact-id repeats
|
||||
// are filtered by the caller, but two different cards can grant the same ability, and
|
||||
// stacking one twice is a no-op the players would rightly feel cheated by.
|
||||
return !SlotAlreadyHasAbility(run, slot);
|
||||
}
|
||||
|
||||
// Shared scratch: validation runs on the server during vote generation, which is
|
||||
// single-threaded, so one reused list beats allocating per candidate per slot.
|
||||
private static readonly System.Collections.Generic.List<int> s_idScratch
|
||||
= new System.Collections.Generic.List<int>();
|
||||
|
||||
private bool SlotAlreadyHasAbility(RunState run, int slot)
|
||||
{
|
||||
var pool = EnemyUpgradePool.Instance;
|
||||
if (run == null || pool == null) return false;
|
||||
|
||||
var ids = s_idScratch;
|
||||
run.GetUpgradesForSlot(slot, ids);
|
||||
|
||||
foreach (int id in ids)
|
||||
{
|
||||
if (pool.Get(id) is AbilityEnemyUpgradeOption other && other.Ability == Ability)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Inherits nothing automatically — <see cref="EnemyAbilityDefinition"/> carries no
|
||||
/// icon, so the card's own <see cref="EnemyUpgradeOption.Icon"/> is the only source.</summary>
|
||||
public override Sprite ResolveIcon() => Icon;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d075c66b381872a4781b3916296ccdc9
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs
|
||||
using UnityEngine;
|
||||
using TD.Gameplay.Waves;
|
||||
|
||||
namespace TD.Gameplay.EnemyUpgrades
|
||||
{
|
||||
/// <summary>
|
||||
/// Vote-meta card: the next time this wave is cleared, it takes several buffs automatically
|
||||
/// and the players get no say in which.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Not an enemy ability.</b> Nothing about the enemies changes when this is voted in — it
|
||||
/// changes how the <i>next vote</i> on this slot resolves. That's why it derives from
|
||||
/// <see cref="EnemyUpgradeOption"/> directly instead of wrapping an
|
||||
/// <c>EnemyAbilityDefinition</c>: the spawn path builds ability sets only from ability cards,
|
||||
/// so this one is recorded on the slot and otherwise invisible to enemies.
|
||||
///
|
||||
/// <para><b>The trade is agency for information.</b> Voting this in is choosing to make one
|
||||
/// future wave worse in exchange for it not being the worst option — the auto-draw is weighted
|
||||
/// and slot-filtered exactly like a real vote, so it's a gamble on the pool, not a punishment.
|
||||
/// It also disarms itself after firing (<c>ServerConsumeAutoApply</c>), so a slot can only be
|
||||
/// doubled once per card.</para>
|
||||
///
|
||||
/// <para>Repeats are prevented by the normal offer filter: the slot records this card like any
|
||||
/// other, so it can't be offered to the same slot twice in a phase.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "EnemyUpgrade_DoubleUp",
|
||||
menuName = "TD/Enemy Upgrades/Double Up Card", order = 22)]
|
||||
public class DoubleUpEnemyUpgradeOption : EnemyUpgradeOption
|
||||
{
|
||||
[Header("Payload")]
|
||||
[Tooltip("How many buffs the wave takes automatically in place of its next vote.")]
|
||||
[Min(2)]
|
||||
public int BuffsToAutoApply = 2;
|
||||
|
||||
public override bool IsValidForSlot(RunState run, int slot)
|
||||
{
|
||||
// Pointless to offer while the slot is already armed — the player would be trading a
|
||||
// vote away for nothing.
|
||||
return run != null && run.GetAutoApplyCount(slot) <= 0;
|
||||
}
|
||||
|
||||
public override void ServerOnApplied(RunState run, int slot)
|
||||
{
|
||||
run?.ServerSetAutoApply(slot, BuffsToAutoApply);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f5e90fe6b8e439e4f99769a6923c2262
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay.EnemyUpgrades
|
||||
{
|
||||
/// <summary>
|
||||
/// One weighted candidate inside an <see cref="EnemyUpgradeGroup"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Weight is relative and normalized at draw time — see <see cref="PoolWeightAttribute"/>.
|
||||
/// Kept on the entry rather than on the option asset for the same reason wave weights are:
|
||||
/// a card can be a staple of one phase's pool and a rarity in another's.
|
||||
/// </remarks>
|
||||
[Serializable]
|
||||
public class EnemyUpgradePoolEntry
|
||||
{
|
||||
[Tooltip("The enemy-buff card that may be offered from this group.")]
|
||||
public EnemyUpgradeOption Option;
|
||||
|
||||
[Tooltip("Relative draw weight within the pool this group contributes to. Entries at " +
|
||||
"equal weight are equally likely; 0.5 is half as likely as 1.0.")]
|
||||
[PoolWeight("buff")]
|
||||
public float Weight = 1f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named, reusable bag of enemy-buff cards. Groups are the unit designers move between
|
||||
/// phases: dragging a group into Phase 2's list makes every card in it votable from phase 2
|
||||
/// onward, without touching the cards themselves.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Buffs are gated by <b>phase</b>, not by cycle — every card a phase allows is votable from
|
||||
/// its very first wave. Gating by cycle was considered and rejected: the whole point of the
|
||||
/// cyclical structure is that players choose how hard to make their own next lap, and holding
|
||||
/// the interesting cards back until cycle 3 would flatten that decision into a formality.
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "EnemyUpgradeGroup",
|
||||
menuName = "TD/Enemy Upgrades/Upgrade Group", order = 21)]
|
||||
public class EnemyUpgradeGroup : ScriptableObject
|
||||
{
|
||||
[Tooltip("Designer-facing name, shown in validation messages. Falls back to the asset name.")]
|
||||
public string DisplayName;
|
||||
|
||||
[Tooltip("Cards in this group, each with a relative draw weight.")]
|
||||
public EnemyUpgradePoolEntry[] Options;
|
||||
|
||||
/// <summary>Name used in validation and log messages. Falls back to the asset name.</summary>
|
||||
public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
|
||||
|
||||
/// <summary>
|
||||
/// Appends every non-null, non-zero-weight entry across <paramref name="groups"/> into
|
||||
/// <paramref name="into"/>. Zero-weight entries are filtered here so "weight 0 means it
|
||||
/// can never appear" lives in exactly one place.
|
||||
/// </summary>
|
||||
public static void CollectCandidates(EnemyUpgradeGroup[] groups, List<EnemyUpgradePoolEntry> into)
|
||||
{
|
||||
into.Clear();
|
||||
if (groups == null) return;
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group?.Options == null) continue;
|
||||
foreach (var entry in group.Options)
|
||||
{
|
||||
if (entry?.Option == null) continue;
|
||||
if (entry.Weight <= 0f) continue;
|
||||
into.Add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4202a767d96e70f41b6a7e84eb08a832
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs
|
||||
using UnityEngine;
|
||||
using TD.Gameplay.Waves;
|
||||
|
||||
namespace TD.Gameplay.EnemyUpgrades
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for one card in the post-wave enemy-buff vote. Players collectively choose one
|
||||
/// of these to permanently attach to the wave they just cleared; it takes effect the next time
|
||||
/// that wave comes round in the cycle.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Mirrors <c>DraftOption</c> deliberately.</b> Same shape — abstract SO, one asset
|
||||
/// per card, concrete subclasses carry the payload — so the two authoring surfaces read the
|
||||
/// same way. The difference is who it targets: a draft option applies to a player, an enemy
|
||||
/// upgrade applies to a <i>wave slot</i>.</para>
|
||||
///
|
||||
/// <para><b>Identity.</b> Options live in <see cref="EnemyUpgradePool"/> and cross the network
|
||||
/// as their pool index, the same stable-within-a-match id pattern used by the tower catalog
|
||||
/// and draft pool.</para>
|
||||
///
|
||||
/// <para><b>Recording is not this type's job.</b> <c>WaveVote</c> records the winning option id
|
||||
/// onto the slot via <c>RunState.ServerAddUpgrade</c>; <see cref="ServerOnApplied"/> is only
|
||||
/// for cards that need a side effect <i>beyond</i> being recorded (the vote-meta cards).
|
||||
/// Ability cards need nothing here — the spawn path reads the slot's recorded set.</para>
|
||||
/// </remarks>
|
||||
public abstract class EnemyUpgradeOption : ScriptableObject
|
||||
{
|
||||
[Header("Presentation")]
|
||||
[Tooltip("Name shown on the vote card.")]
|
||||
public string DisplayName;
|
||||
|
||||
[Tooltip("Short description shown on the vote card. Say what it does to the enemies, in " +
|
||||
"the players' terms — this is the only thing they get to judge the vote on.")]
|
||||
[TextArea(2, 4)]
|
||||
public string Description;
|
||||
|
||||
[Tooltip("OPTIONAL override for the vote card icon. Leave empty to inherit the icon of " +
|
||||
"whatever this option grants.")]
|
||||
public Sprite Icon;
|
||||
|
||||
/// <summary>
|
||||
/// Icon actually shown on the vote card. Subclasses override to fall back to the icon of
|
||||
/// the thing they grant; the base has nothing to inherit from, so it returns
|
||||
/// <see cref="Icon"/> (which may be null).
|
||||
/// </summary>
|
||||
public virtual Sprite ResolveIcon() => Icon;
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: may this option be OFFERED for <paramref name="slot"/> right now? Default
|
||||
/// true; override for cards with prerequisites or ones that would be a no-op.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Callers already skip options the slot has taken before, so implementations don't need
|
||||
/// to re-check that.
|
||||
/// </remarks>
|
||||
public virtual bool IsValidForSlot(RunState run, int slot) => true;
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: side effect to run after this option has been recorded onto
|
||||
/// <paramref name="slot"/>. Default no-op — most cards do their work at enemy-spawn time
|
||||
/// by virtue of being on the slot, not at vote-resolution time.
|
||||
/// </summary>
|
||||
public virtual void ServerOnApplied(RunState run, int slot) { }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6b1e9770be72a694a96169d9a9032528
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.Gameplay.EnemyUpgrades
|
||||
{
|
||||
/// <summary>
|
||||
/// Scene singleton holding every <see cref="EnemyUpgradeOption"/> that can appear anywhere in
|
||||
/// this match. The array index is the option's <c>EnemyUpgradeOptionId</c> — the stable
|
||||
/// identifier used to replicate vote offers, ballots, and the upgrades recorded on wave slots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Why a flat pool <i>and</i> per-phase groups.</b> The pool supplies network identity: one
|
||||
/// array, one index per card, identical on every peer. <c>PhaseDefinition.EnemyUpgradeGroups</c>
|
||||
/// supplies gating: which of those cards a given phase may offer. Folding gating into the pool
|
||||
/// would make ids phase-relative and therefore unstable across a phase change, which is
|
||||
/// exactly when replicated slot upgrades are being cleared and rewritten.
|
||||
///
|
||||
/// <para>Plain MonoBehaviour — identical assets on every peer, so there is nothing to sync.
|
||||
/// The server reads it to generate and resolve votes; clients read it to render vote cards
|
||||
/// from replicated ids.</para>
|
||||
/// </remarks>
|
||||
public class EnemyUpgradePool : MonoBehaviour
|
||||
{
|
||||
public static EnemyUpgradePool Instance { get; private set; }
|
||||
|
||||
[Tooltip("Every EnemyUpgradeOption asset that can appear this match. Array index is the " +
|
||||
"id used over the network — reordering mid-development is fine, but not between " +
|
||||
"a host and its clients.")]
|
||||
[SerializeField] private EnemyUpgradeOption[] options;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Debug.LogError("[EnemyUpgradePool] Multiple instances detected. Only one per scene.");
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
/// <summary>Number of options in the pool.</summary>
|
||||
public int Count => options?.Length ?? 0;
|
||||
|
||||
/// <summary>Returns the option at <paramref name="id"/>, or null if out of range.</summary>
|
||||
public EnemyUpgradeOption Get(int id)
|
||||
=> (options != null && id >= 0 && id < options.Length) ? options[id] : null;
|
||||
|
||||
/// <summary>Resolves an option asset back to its pool id, or -1 if it isn't in the pool.</summary>
|
||||
public int GetId(EnemyUpgradeOption option)
|
||||
{
|
||||
if (options == null || option == null) return -1;
|
||||
for (int i = 0; i < options.Length; i++)
|
||||
if (options[i] == option) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects the pool ids of every card the given phase groups allow. Cards missing from
|
||||
/// the pool are reported once and skipped — an authored group referencing an unpooled
|
||||
/// card is a wiring mistake that would otherwise silently shrink the vote.
|
||||
/// </summary>
|
||||
public void CollectAllowedIds(EnemyUpgradeGroup[] phaseGroups,
|
||||
List<EnemyUpgradePoolEntry> entryScratch,
|
||||
List<int> intoIds,
|
||||
List<float> intoWeights)
|
||||
{
|
||||
intoIds.Clear();
|
||||
intoWeights.Clear();
|
||||
|
||||
EnemyUpgradeGroup.CollectCandidates(phaseGroups, entryScratch);
|
||||
|
||||
foreach (var entry in entryScratch)
|
||||
{
|
||||
int id = GetId(entry.Option);
|
||||
if (id < 0)
|
||||
{
|
||||
Debug.LogWarning($"[EnemyUpgradePool] '{entry.Option.name}' is referenced by a " +
|
||||
$"phase's upgrade groups but is not in the pool — it can " +
|
||||
$"never be offered. Add it to the pool's options array.");
|
||||
continue;
|
||||
}
|
||||
|
||||
intoIds.Add(id);
|
||||
intoWeights.Add(entry.Weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c4d98a01d9b9a7b438a6f91b0b7f498f
|
||||
419
Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs
Normal file
419
Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a18321dc2d138034fbdb5f705f19f893
|
||||
Loading…
Add table
Add a link
Reference in a new issue