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>
155 lines
6.1 KiB
C#
155 lines
6.1 KiB
C#
// Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs
|
|
using System.Collections.Generic;
|
|
using Unity.Netcode;
|
|
using UnityEngine;
|
|
|
|
namespace TD.Gameplay.Draft
|
|
{
|
|
/// <summary>
|
|
/// Server-authoritative draft orchestrator: generates weighted-random option sets and
|
|
/// offers them to players' <see cref="PlayerDraft"/> components. Scene singleton.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Plain MonoBehaviour — the replicated state lives on <see cref="PlayerDraft"/>; this
|
|
/// is pure server logic plus two designer tunables (<see cref="optionsPerDraft"/>,
|
|
/// <see cref="rerollCost"/>). It exists on every peer (scene object) but only the server
|
|
/// drives it; clients read <see cref="RerollCost"/> for the HUD's "buy roll" button.
|
|
///
|
|
/// <para><b>Driven by <see cref="WaveManager"/>:</b> <see cref="ServerOfferToAll"/> at
|
|
/// the start of each prep phase; <see cref="ServerAutoResolveAll"/> at prep end (and on
|
|
/// dev force-advance) so unpicked free drafts aren't wasted.</para>
|
|
/// </remarks>
|
|
public class DraftService : MonoBehaviour
|
|
{
|
|
public static DraftService Instance { get; private set; }
|
|
|
|
[Tooltip("How many options each draft offers (cards shown). Standard roguelike draw is 3.")]
|
|
[SerializeField] private int optionsPerDraft = 3;
|
|
|
|
[Tooltip("Gold cost for a player to buy one extra draft roll from the shop.")]
|
|
[SerializeField] private int rerollCost = 50;
|
|
|
|
/// <summary>Gold cost of one bought reroll. Read by the HUD and the buy RPC.</summary>
|
|
public int RerollCost => rerollCost;
|
|
|
|
// Scratch lists reused across generation to avoid per-draft GC. Server is
|
|
// single-threaded, so sharing them across the per-player loop is safe.
|
|
private readonly List<int> candidateScratch = new List<int>();
|
|
private readonly List<int> resultScratch = new List<int>();
|
|
|
|
private static bool IsServer
|
|
=> NetworkManager.Singleton != null && NetworkManager.Singleton.IsServer;
|
|
|
|
private void Awake()
|
|
{
|
|
if (Instance != null && Instance != this)
|
|
{
|
|
Debug.LogError("[DraftService] Multiple instances detected. Only one per scene.");
|
|
return;
|
|
}
|
|
Instance = this;
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
if (Instance == this) Instance = null;
|
|
}
|
|
|
|
// ----- Server orchestration ---------------------------------------
|
|
|
|
/// <summary>Server-only: offer a fresh draft to every connected player.</summary>
|
|
public void ServerOfferToAll()
|
|
{
|
|
if (!IsServer) return;
|
|
if (DraftPool.Instance == null)
|
|
{
|
|
Debug.LogWarning("[DraftService] No DraftPool in scene — no drafts will be offered.");
|
|
return;
|
|
}
|
|
|
|
foreach (var pms in PlayerMatchState.AllPlayers)
|
|
ServerOfferTo(pms.OwnerClientId);
|
|
}
|
|
|
|
/// <summary>Server-only: offer a fresh draft to one player (also used by the paid reroll).</summary>
|
|
public void ServerOfferTo(ulong clientId)
|
|
{
|
|
if (!IsServer) return;
|
|
var draft = PlayerDraft.GetForClient(clientId);
|
|
if (draft == null) return;
|
|
|
|
GenerateOptionIds(clientId, optionsPerDraft, resultScratch);
|
|
draft.ServerOffer(resultScratch);
|
|
}
|
|
|
|
/// <summary>Server-only: auto-resolve any unpicked drafts (called when the timer expires).</summary>
|
|
public void ServerAutoResolveAll()
|
|
{
|
|
if (!IsServer) return;
|
|
foreach (var pms in PlayerMatchState.AllPlayers)
|
|
PlayerDraft.GetForClient(pms.OwnerClientId)?.ServerAutoResolve();
|
|
}
|
|
|
|
/// <summary>
|
|
/// True once every connected player has resolved their draft. The inter-wave step polls
|
|
/// this to end the draft timer early instead of making everyone wait out the clock.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Returns false when nobody is connected, so an empty lobby can't trip the barrier and
|
|
/// race the sequence forward.
|
|
/// </remarks>
|
|
public static bool AllPlayersPicked
|
|
{
|
|
get
|
|
{
|
|
bool any = false;
|
|
foreach (var pms in PlayerMatchState.AllPlayers)
|
|
{
|
|
any = true;
|
|
var draft = PlayerDraft.GetForClient(pms.OwnerClientId);
|
|
if (draft != null && draft.HasActiveDraft) return false;
|
|
}
|
|
return any;
|
|
}
|
|
}
|
|
|
|
// ----- Generation -------------------------------------------------
|
|
|
|
// Weighted random draw WITHOUT replacement: picks `count` distinct option ids from
|
|
// the set currently valid for this player. If fewer valid options exist than
|
|
// requested, offers what's available (possibly zero).
|
|
private void GenerateOptionIds(ulong clientId, int count, List<int> into)
|
|
{
|
|
into.Clear();
|
|
var pool = DraftPool.Instance;
|
|
if (pool == null) return;
|
|
|
|
pool.CollectValidIds(clientId, candidateScratch);
|
|
|
|
int draws = Mathf.Min(count, candidateScratch.Count);
|
|
for (int n = 0; n < draws; n++)
|
|
{
|
|
float total = 0f;
|
|
for (int i = 0; i < candidateScratch.Count; i++)
|
|
total += OptionWeight(pool, candidateScratch[i]);
|
|
|
|
float r = Random.value * total;
|
|
int chosen = candidateScratch.Count - 1; // fallback to last on float drift
|
|
for (int i = 0; i < candidateScratch.Count; i++)
|
|
{
|
|
r -= OptionWeight(pool, candidateScratch[i]);
|
|
if (r <= 0f) { chosen = i; break; }
|
|
}
|
|
|
|
into.Add(candidateScratch[chosen]);
|
|
candidateScratch.RemoveAt(chosen); // without replacement → distinct options
|
|
}
|
|
}
|
|
|
|
private static float OptionWeight(DraftPool pool, int id)
|
|
{
|
|
var opt = pool.Get(id);
|
|
return opt != null ? Mathf.Max(0.0001f, opt.Weight) : 0.0001f;
|
|
}
|
|
}
|
|
}
|