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
468
Assets/_Project/Scripts/Gameplay/Waves/RunState.cs
Normal file
468
Assets/_Project/Scripts/Gameplay/Waves/RunState.cs
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
// Assets/_Project/Scripts/Gameplay/Waves/RunState.cs
|
||||
using System.Collections.Generic;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.Gameplay.Waves
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome of advancing the run by one encounter. Returned by
|
||||
/// <see cref="RunState.ServerAdvance"/> so <c>WaveManager</c> can react without
|
||||
/// re-deriving the progression maths.
|
||||
/// </summary>
|
||||
public enum RunAdvance : byte
|
||||
{
|
||||
/// <summary>Moved to the next wave inside the current cycle.</summary>
|
||||
NextWave = 0,
|
||||
/// <summary>Moved to the first wave of a new cycle — the same waves come round again,
|
||||
/// now carrying whatever upgrades players voted onto them.</summary>
|
||||
NextCycle = 1,
|
||||
/// <summary>Every cycle in the phase is done; the boss is up next.</summary>
|
||||
BossReady = 2,
|
||||
/// <summary>The boss died and a new phase was drawn. Upgrades were wiped.</summary>
|
||||
NextPhase = 3,
|
||||
/// <summary>The final phase's boss died — the run is won.</summary>
|
||||
RunComplete = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-authoritative owner of run progression: which phase and cycle we're in, which waves
|
||||
/// this phase drew, and which upgrades the players have voted onto each wave slot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Split of duties with <c>WaveManager</c>.</b> This type is the run's <i>state</i> —
|
||||
/// counters, draws, upgrade sets — and knows nothing about spawning. <c>WaveManager</c> is the
|
||||
/// <i>driver</i>: it runs an encounter, and when the field is clear it calls
|
||||
/// <see cref="ServerAdvance"/> and acts on the returned <see cref="RunAdvance"/>. Keeping them
|
||||
/// apart is what stops WaveManager (already a large class) from also owning pool draws and
|
||||
/// upgrade bookkeeping.
|
||||
///
|
||||
/// <para><b>Linear encounter index.</b> Rather than replicate cycle and wave separately and
|
||||
/// keep them in sync, one <see cref="encounterInPhase"/> counter runs
|
||||
/// <c>0 .. CyclesPerPhase*WavesPerCycle</c> inclusive, with the final value meaning "boss".
|
||||
/// Cycle and wave slot are derived from it, so they cannot disagree.</para>
|
||||
///
|
||||
/// <para><b>Upgrades are keyed by wave slot, not enemy type.</b> The phase draw guarantees the
|
||||
/// slots hold distinct enemy types, which makes slot and type interchangeable as a key — and
|
||||
/// slot is the one that survives a phase change cleanly, since the next phase reassigns every
|
||||
/// slot to a fresh enemy anyway.</para>
|
||||
/// </remarks>
|
||||
public class RunState : NetworkBehaviour
|
||||
{
|
||||
// ----- Singleton --------------------------------------------------
|
||||
|
||||
public static RunState Instance { get; private set; }
|
||||
|
||||
// ----- Inspector --------------------------------------------------
|
||||
|
||||
[Tooltip("The run this match plays: phase pools, waves per cycle, cycles per phase. " +
|
||||
"Required — without it no waves can be drawn.")]
|
||||
[SerializeField] private RunDefinition runDefinition;
|
||||
|
||||
// ----- Networked state --------------------------------------------
|
||||
|
||||
private readonly NetworkVariable<int> phaseIndex = new NetworkVariable<int>(
|
||||
0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
||||
|
||||
// 0 .. (CyclesPerPhase * WavesPerCycle), where the top value means "boss".
|
||||
private readonly NetworkVariable<int> encounterInPhase = new NetworkVariable<int>(
|
||||
0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
||||
|
||||
// Wave ids (indices into RunDefinition's flat index) drawn for this phase's slots.
|
||||
// Exactly WavesPerCycle entries once a phase has been drawn.
|
||||
private NetworkList<int> drawnWaveIds;
|
||||
|
||||
private readonly NetworkVariable<int> bossWaveId = new NetworkVariable<int>(
|
||||
-1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
||||
|
||||
// Upgrades voted onto wave slots, packed as (slot << SlotShift) | optionId. A flat
|
||||
// append-only list rather than a per-slot structure because NGO has no nested-collection
|
||||
// support and the counts here are tiny (a handful per slot, per phase).
|
||||
private NetworkList<int> waveUpgrades;
|
||||
|
||||
private const int SlotShift = 16;
|
||||
private const int OptionMask = (1 << SlotShift) - 1;
|
||||
|
||||
// Per-slot count of buffs to apply automatically, skipping the next vote for that slot.
|
||||
// Indexed by upgrade slot (0..WavesPerCycle, the last being the boss). Zero means a normal
|
||||
// vote. Written by the vote-meta cards, consumed by the inter-wave step.
|
||||
private NetworkList<int> autoApplyCounts;
|
||||
|
||||
/// <summary>
|
||||
/// Fired on every peer whenever run progression or the drawn/upgraded wave set changes.
|
||||
/// The HUD subscribes to relabel without polling.
|
||||
/// </summary>
|
||||
public event System.Action OnRunChanged;
|
||||
|
||||
// ----- Lifecycle ---------------------------------------------------
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
drawnWaveIds = new NetworkList<int>();
|
||||
waveUpgrades = new NetworkList<int>();
|
||||
autoApplyCounts = new NetworkList<int>();
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Debug.LogError("[RunState] Duplicate RunState detected. Only one may exist per scene.");
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
|
||||
// Freeze the wave index on EVERY peer (not just the server) before any id crosses
|
||||
// the wire. Same asset, same declaration-order walk, same numbering.
|
||||
runDefinition?.RebuildIndex();
|
||||
|
||||
// One auto-apply counter per upgrade slot, plus one for the boss slot.
|
||||
if (IsServer)
|
||||
{
|
||||
for (int i = 0; i <= WavesPerCycle; i++) autoApplyCounts.Add(0);
|
||||
}
|
||||
|
||||
phaseIndex.OnValueChanged += HandleIntChanged;
|
||||
encounterInPhase.OnValueChanged += HandleIntChanged;
|
||||
bossWaveId.OnValueChanged += HandleIntChanged;
|
||||
drawnWaveIds.OnListChanged += HandleListChanged;
|
||||
waveUpgrades.OnListChanged += HandleListChanged;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
phaseIndex.OnValueChanged -= HandleIntChanged;
|
||||
encounterInPhase.OnValueChanged -= HandleIntChanged;
|
||||
bossWaveId.OnValueChanged -= HandleIntChanged;
|
||||
drawnWaveIds.OnListChanged -= HandleListChanged;
|
||||
waveUpgrades.OnListChanged -= HandleListChanged;
|
||||
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
private void HandleIntChanged(int _, int __) => OnRunChanged?.Invoke();
|
||||
private void HandleListChanged(NetworkListEvent<int> _) => OnRunChanged?.Invoke();
|
||||
|
||||
// ----- Structure accessors -----------------------------------------
|
||||
|
||||
/// <summary>The run asset driving this match, or null if unassigned (designer error).</summary>
|
||||
public RunDefinition Definition => runDefinition;
|
||||
|
||||
public int WavesPerCycle => runDefinition != null ? Mathf.Max(1, runDefinition.WavesPerCycle) : 5;
|
||||
public int CyclesPerPhase => runDefinition != null ? Mathf.Max(1, runDefinition.CyclesPerPhase) : 3;
|
||||
public int PhaseCount => runDefinition?.Phases?.Length ?? 0;
|
||||
|
||||
/// <summary>Encounters in one phase, counting its boss.</summary>
|
||||
public int EncountersPerPhase => CyclesPerPhase * WavesPerCycle + 1;
|
||||
|
||||
// ----- Progression accessors ---------------------------------------
|
||||
|
||||
/// <summary>Zero-based phase index.</summary>
|
||||
public int PhaseIndex => phaseIndex.Value;
|
||||
|
||||
/// <summary>
|
||||
/// The phase currently being played, or null if the run isn't set up. Read by the vote
|
||||
/// generator to find which enemy-buff cards this phase allows.
|
||||
/// </summary>
|
||||
public PhaseDefinition CurrentPhase
|
||||
{
|
||||
get
|
||||
{
|
||||
var phases = runDefinition?.Phases;
|
||||
if (phases == null) return null;
|
||||
int i = phaseIndex.Value;
|
||||
return (i >= 0 && i < phases.Length) ? phases[i] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Zero-based cycle index within the current phase. Reads as the last cycle
|
||||
/// while the boss is up (the boss belongs to the phase, not to a fourth cycle).</summary>
|
||||
public int CycleIndex => Mathf.Min(encounterInPhase.Value / WavesPerCycle, CyclesPerPhase - 1);
|
||||
|
||||
/// <summary>Zero-based wave slot within the current cycle. Meaningless while
|
||||
/// <see cref="IsBossStage"/> is true.</summary>
|
||||
public int WaveSlot => encounterInPhase.Value % WavesPerCycle;
|
||||
|
||||
/// <summary>True when the next/current encounter is this phase's boss.</summary>
|
||||
public bool IsBossStage => encounterInPhase.Value >= CyclesPerPhase * WavesPerCycle;
|
||||
|
||||
/// <summary>
|
||||
/// 1-based count of encounters since the run began, including bosses. This is the key
|
||||
/// <c>GoldConfig</c> is indexed by, so its per-wave entries scale monotonically across the
|
||||
/// whole run rather than resetting every cycle.
|
||||
/// </summary>
|
||||
public int GlobalEncounterNumber
|
||||
=> phaseIndex.Value * EncountersPerPhase + encounterInPhase.Value + 1;
|
||||
|
||||
/// <summary>Human-readable progress line for the HUD, e.g. "Phase 1 · Cycle 2 · Wave 3/5".</summary>
|
||||
public string ProgressLabel
|
||||
=> IsBossStage
|
||||
? $"Phase {phaseIndex.Value + 1} · BOSS"
|
||||
: $"Phase {phaseIndex.Value + 1} · Cycle {CycleIndex + 1} · " +
|
||||
$"Wave {WaveSlot + 1}/{WavesPerCycle}";
|
||||
|
||||
// ----- Wave accessors ----------------------------------------------
|
||||
|
||||
/// <summary>The wave asset drawn for <paramref name="slot"/>, or null if not drawn yet.</summary>
|
||||
public WaveDefinition GetSlotWave(int slot)
|
||||
{
|
||||
if (runDefinition == null) return null;
|
||||
if (slot < 0 || slot >= drawnWaveIds.Count) return null;
|
||||
return runDefinition.GetWave(drawnWaveIds[slot]);
|
||||
}
|
||||
|
||||
/// <summary>This phase's boss wave, or null if not drawn yet.</summary>
|
||||
public WaveDefinition BossWave
|
||||
=> runDefinition != null ? runDefinition.GetWave(bossWaveId.Value) : null;
|
||||
|
||||
/// <summary>The wave the run is currently pointing at — boss or cycle wave.</summary>
|
||||
public WaveDefinition CurrentWave => IsBossStage ? BossWave : GetSlotWave(WaveSlot);
|
||||
|
||||
/// <summary>
|
||||
/// The slot key upgrades attach to for the current encounter. The boss occupies the slot
|
||||
/// one past the last cycle wave, so boss upgrades (if ever voted) don't collide with it.
|
||||
/// </summary>
|
||||
public int CurrentUpgradeSlot => IsBossStage ? WavesPerCycle : WaveSlot;
|
||||
|
||||
// ----- Upgrade accessors -------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Collects the upgrade option ids voted onto <paramref name="slot"/>, in the order they
|
||||
/// were applied. Safe on any peer.
|
||||
/// </summary>
|
||||
public void GetUpgradesForSlot(int slot, List<int> into)
|
||||
{
|
||||
into.Clear();
|
||||
for (int i = 0; i < waveUpgrades.Count; i++)
|
||||
{
|
||||
int packed = waveUpgrades[i];
|
||||
if ((packed >> SlotShift) == slot) into.Add(packed & OptionMask);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How many upgrades are stacked on <paramref name="slot"/>.</summary>
|
||||
public int CountUpgradesForSlot(int slot)
|
||||
{
|
||||
int n = 0;
|
||||
for (int i = 0; i < waveUpgrades.Count; i++)
|
||||
if ((waveUpgrades[i] >> SlotShift) == slot) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>True if <paramref name="slot"/> already carries <paramref name="optionId"/>.
|
||||
/// Used to keep the vote from offering a buff a wave already has.</summary>
|
||||
public bool SlotHasUpgrade(int slot, int optionId)
|
||||
{
|
||||
int packed = (slot << SlotShift) | (optionId & OptionMask);
|
||||
for (int i = 0; i < waveUpgrades.Count; i++)
|
||||
if (waveUpgrades[i] == packed) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Server-only: stack an upgrade onto a wave slot. Ignores exact duplicates.</summary>
|
||||
public void ServerAddUpgrade(int slot, int optionId)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (slot < 0 || optionId < 0) return;
|
||||
if (SlotHasUpgrade(slot, optionId)) return;
|
||||
|
||||
waveUpgrades.Add((slot << SlotShift) | (optionId & OptionMask));
|
||||
}
|
||||
|
||||
// ----- Auto-apply (vote-meta cards) --------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// How many buffs <paramref name="slot"/> will take automatically instead of holding its
|
||||
/// next vote. Zero means a normal vote.
|
||||
/// </summary>
|
||||
public int GetAutoApplyCount(int slot)
|
||||
=> (slot >= 0 && slot < autoApplyCounts.Count) ? autoApplyCounts[slot] : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: arm <paramref name="slot"/> to auto-take <paramref name="count"/> buffs in
|
||||
/// place of its next vote. Takes the larger value if already armed, so two vote-meta cards
|
||||
/// on one slot don't cancel each other down.
|
||||
/// </summary>
|
||||
public void ServerSetAutoApply(int slot, int count)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (slot < 0 || slot >= autoApplyCounts.Count || count <= 0) return;
|
||||
autoApplyCounts[slot] = Mathf.Max(autoApplyCounts[slot], count);
|
||||
}
|
||||
|
||||
/// <summary>Server-only: disarm <paramref name="slot"/> after its auto-apply has fired.</summary>
|
||||
public void ServerConsumeAutoApply(int slot)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (slot < 0 || slot >= autoApplyCounts.Count) return;
|
||||
autoApplyCounts[slot] = 0;
|
||||
}
|
||||
|
||||
private void ServerClearAutoApply()
|
||||
{
|
||||
for (int i = 0; i < autoApplyCounts.Count; i++) autoApplyCounts[i] = 0;
|
||||
}
|
||||
|
||||
// ----- Server: run control ------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: start the run at phase 0 and draw its waves. Returns false if the run
|
||||
/// definition is missing or unplayable — the caller should treat that as fatal rather
|
||||
/// than limping along with an empty schedule.
|
||||
/// </summary>
|
||||
public bool ServerBeginRun()
|
||||
{
|
||||
if (!IsServer) return false;
|
||||
|
||||
if (runDefinition == null)
|
||||
{
|
||||
Debug.LogError("[RunState] No RunDefinition assigned — cannot start a run.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!runDefinition.Validate(out string report))
|
||||
{
|
||||
Debug.LogError($"[RunState] RunDefinition '{runDefinition.name}' is not playable:\n{report}");
|
||||
return false;
|
||||
}
|
||||
|
||||
phaseIndex.Value = 0;
|
||||
encounterInPhase.Value = 0;
|
||||
waveUpgrades.Clear();
|
||||
ServerClearAutoApply();
|
||||
|
||||
return ServerDrawPhase(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: advance one encounter, drawing a new phase (and wiping upgrades) when the
|
||||
/// boss falls. See <see cref="RunAdvance"/> for what the caller should do with each result.
|
||||
/// </summary>
|
||||
public RunAdvance ServerAdvance()
|
||||
{
|
||||
if (!IsServer) return RunAdvance.NextWave;
|
||||
|
||||
// Boss just died: the phase is over. Either draw the next one or win the run.
|
||||
if (IsBossStage)
|
||||
{
|
||||
int next = phaseIndex.Value + 1;
|
||||
if (next >= PhaseCount) return RunAdvance.RunComplete;
|
||||
|
||||
phaseIndex.Value = next;
|
||||
encounterInPhase.Value = 0;
|
||||
|
||||
// A new phase means five brand-new waves, so every upgrade voted onto the old
|
||||
// slots is meaningless — wiped by design, not by omission. Armed auto-applies go
|
||||
// with them: they were promises made about waves that no longer exist.
|
||||
waveUpgrades.Clear();
|
||||
ServerClearAutoApply();
|
||||
ServerDrawPhase(next);
|
||||
return RunAdvance.NextPhase;
|
||||
}
|
||||
|
||||
int previousCycle = CycleIndex;
|
||||
encounterInPhase.Value++;
|
||||
|
||||
if (IsBossStage) return RunAdvance.BossReady;
|
||||
if (CycleIndex != previousCycle) return RunAdvance.NextCycle;
|
||||
return RunAdvance.NextWave;
|
||||
}
|
||||
|
||||
// ----- Server: phase draw -------------------------------------------
|
||||
|
||||
// Scratch list reused across draws; server is single-threaded so sharing is safe.
|
||||
private readonly List<WavePoolEntry> candidateScratch = new List<WavePoolEntry>();
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: draw this phase's cycle waves and boss. Cycle waves are drawn by relative
|
||||
/// weight <b>without replacement and with distinct enemy types</b>; the boss is a plain
|
||||
/// weighted draw.
|
||||
/// </summary>
|
||||
private bool ServerDrawPhase(int index)
|
||||
{
|
||||
if (!IsServer) return false;
|
||||
|
||||
var phases = runDefinition?.Phases;
|
||||
if (phases == null || index < 0 || index >= phases.Length || phases[index] == null)
|
||||
{
|
||||
Debug.LogError($"[RunState] Phase {index + 1} is missing — cannot draw waves.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var phase = phases[index];
|
||||
int needed = WavesPerCycle;
|
||||
|
||||
phase.CollectWaveCandidates(candidateScratch);
|
||||
drawnWaveIds.Clear();
|
||||
|
||||
for (int drawn = 0; drawn < needed; drawn++)
|
||||
{
|
||||
var pick = DrawWeighted(candidateScratch);
|
||||
if (pick == null)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[RunState] {phase.Label}: ran out of candidates after {drawn} of " +
|
||||
$"{needed} wave(s). The phase needs {needed} waves with DISTINCT enemy " +
|
||||
$"types — check the RunDefinition inspector's phase-capacity readout.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!runDefinition.TryGetWaveId(pick.Wave, out int waveId))
|
||||
{
|
||||
Debug.LogError($"[RunState] Wave '{pick.Wave.name}' is not in the run's wave " +
|
||||
$"index. This should be impossible — the index is built from " +
|
||||
$"the same pools. Skipping.");
|
||||
candidateScratch.Remove(pick);
|
||||
drawn--;
|
||||
continue;
|
||||
}
|
||||
|
||||
drawnWaveIds.Add(waveId);
|
||||
|
||||
// Retire every remaining candidate sharing this enemy type, so distinctness is
|
||||
// enforced by shrinking the pool rather than by reject-and-retry.
|
||||
var type = pick.Wave.PrimaryEnemyType;
|
||||
candidateScratch.RemoveAll(
|
||||
e => e.Wave == pick.Wave || (type != null && e.Wave.PrimaryEnemyType == type));
|
||||
}
|
||||
|
||||
// Boss draw — independent pool, no distinctness constraint.
|
||||
phase.CollectBossCandidates(candidateScratch);
|
||||
var boss = DrawWeighted(candidateScratch);
|
||||
if (boss == null || !runDefinition.TryGetWaveId(boss.Wave, out int bossId))
|
||||
{
|
||||
Debug.LogError($"[RunState] {phase.Label}: no boss could be drawn.");
|
||||
bossWaveId.Value = -1;
|
||||
return false;
|
||||
}
|
||||
bossWaveId.Value = bossId;
|
||||
|
||||
Debug.Log($"[RunState] Drew {phase.Label}: {needed} wave(s) + boss " +
|
||||
$"'{boss.Wave.name}'.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Relative-weight draw. Weights are normalized against the candidate set's total, so a
|
||||
// pool of three 1.0 entries is uniform and a 0.5 entry is half as likely as a 1.0 one.
|
||||
// Zero-weight entries never reach here — PhaseDefinition filters them during collection.
|
||||
private static WavePoolEntry DrawWeighted(List<WavePoolEntry> candidates)
|
||||
{
|
||||
if (candidates == null || candidates.Count == 0) return null;
|
||||
|
||||
float total = 0f;
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
total += Mathf.Max(0f, candidates[i].Weight);
|
||||
|
||||
if (total <= 0f) return null;
|
||||
|
||||
float roll = Random.value * total;
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
roll -= Mathf.Max(0f, candidates[i].Weight);
|
||||
if (roll <= 0f) return candidates[i];
|
||||
}
|
||||
|
||||
return candidates[candidates.Count - 1]; // float drift fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue