Merge branch 'feature/draft-system'

This commit is contained in:
Matt F 2026-06-24 00:27:03 -07:00
commit b0ca2e1d28
22 changed files with 1060 additions and 430 deletions

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a1b2c3d4e5f60718293a4b5c6d7e8f90
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,59 @@
// Assets/_Project/Scripts/Gameplay/Draft/DraftOption.cs
using UnityEngine;
namespace TD.Gameplay.Draft
{
/// <summary>
/// Base class for one authored draft choice. The draft system offers a player a
/// weighted-random selection of these between waves; the player picks one and the
/// server applies its effect.
/// </summary>
/// <remarks>
/// <para><b>One asset per choice.</b> Concrete subclasses (e.g.
/// <see cref="NewTowerDraftOption"/>) carry the payload and implement
/// <see cref="ServerApply"/>. New choice types (systemic upgrade, builder ability,
/// relic quest, enemy debuff) are added as new subclasses — the draft spine
/// (<see cref="DraftService"/>, <see cref="PlayerDraft"/>, HUD) is type-agnostic.</para>
///
/// <para><b>Identity.</b> Options live in <see cref="DraftPool"/> and are referenced
/// over the network by their pool index (<c>DraftOptionId</c>) — the same
/// stable-within-a-match index pattern used by the tower catalog.</para>
///
/// <para><b>Server-only logic.</b> <see cref="IsValidFor"/> and
/// <see cref="ServerApply"/> run on the server and operate on the target player's
/// per-player components (deck, gold, etc.). The ScriptableObject holds no per-player
/// state, so a single asset is shared across all players and matches.</para>
/// </remarks>
public abstract class DraftOption : ScriptableObject
{
[Header("Presentation")]
[Tooltip("Name shown on the draft card.")]
public string DisplayName;
[Tooltip("Short description shown on the draft card.")]
[TextArea(2, 4)]
public string Description;
[Tooltip("Icon shown on the draft card. Optional for placeholder content.")]
public Sprite Icon;
[Header("Generation")]
[Tooltip("Relative draw weight. Higher = offered more often. Rarer rewards use " +
"lower weights. Must be > 0.")]
[Min(0.0001f)]
public float Weight = 1f;
/// <summary>
/// Server-only: can this option validly be OFFERED to the given player right now?
/// Lets the generator skip options that would be a no-op (e.g. a tower the player
/// already owns). Default true; override when an option has prerequisites.
/// </summary>
public virtual bool IsValidFor(ulong clientId) => true;
/// <summary>
/// Server-only: apply this option's effect to the given player. Returns false if
/// it could not be applied (caller treats as a no-op and logs).
/// </summary>
public abstract bool ServerApply(ulong clientId);
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3f9b1c7a2e8d4a16b0c5e9f2a7d34b81
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,64 @@
// Assets/_Project/Scripts/Gameplay/Draft/DraftPool.cs
using System.Collections.Generic;
using UnityEngine;
namespace TD.Gameplay.Draft
{
/// <summary>
/// Scene singleton holding every <see cref="DraftOption"/> available this match. The
/// array index is the option's <c>DraftOptionId</c> — the stable identifier used to
/// replicate offers and picks over the network (mirrors the tower catalog pattern).
/// </summary>
/// <remarks>
/// Plain MonoBehaviour: identical on every peer (same assets), so there is nothing to
/// sync. The server reads it to generate offers and apply picks; clients read it to
/// render draft cards from the replicated option ids.
/// </remarks>
public class DraftPool : MonoBehaviour
{
public static DraftPool Instance { get; private set; }
[Tooltip("Every DraftOption asset that can be offered this match. Array index is " +
"the DraftOptionId used over the network.")]
[SerializeField] private DraftOption[] options;
private void Awake()
{
if (Instance != null && Instance != this)
{
Debug.LogError("[DraftPool] 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"/> (its DraftOptionId), or null.</summary>
public DraftOption Get(int id)
=> (options != null && id >= 0 && id < options.Length) ? options[id] : null;
/// <summary>
/// Server-only helper: collects the ids of every option currently valid to offer
/// to <paramref name="clientId"/> (skips nulls and options whose
/// <see cref="DraftOption.IsValidFor"/> returns false). Used by
/// <see cref="DraftService"/> as the candidate set for the weighted draw.
/// </summary>
public void CollectValidIds(ulong clientId, List<int> into)
{
into.Clear();
if (options == null) return;
for (int i = 0; i < options.Length; i++)
{
if (options[i] == null) continue;
if (options[i].IsValidFor(clientId)) into.Add(i);
}
}
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5a7e1f3c9d2b4867a1c0e4f8b3d6792a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,132 @@
// 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 at prep end).</summary>
public void ServerAutoResolveAll()
{
if (!IsServer) return;
foreach (var pms in PlayerMatchState.AllPlayers)
PlayerDraft.GetForClient(pms.OwnerClientId)?.ServerAutoResolve();
}
// ----- 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;
}
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9d3a7c5e1b8f42606a2d4e9f1c7b3850
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,52 @@
// Assets/_Project/Scripts/Gameplay/Draft/NewTowerDraftOption.cs
using UnityEngine;
using TD.Towers;
namespace TD.Gameplay.Draft
{
/// <summary>
/// Draft choice #1 — "add a new tower to your arsenal". Grants the player a tower,
/// adding it to their <see cref="PlayerTowerDeck"/>.
/// </summary>
/// <remarks>
/// Only offered when the tower is in the match catalog AND the player doesn't already
/// own it, so the draft never wastes a slot on a no-op.
/// </remarks>
[CreateAssetMenu(fileName = "NewTowerOption", menuName = "TD/Draft/New Tower Option")]
public class NewTowerDraftOption : DraftOption
{
[Header("Payload")]
[Tooltip("The tower this option grants. Must also be present in the " +
"TowerPlacementManager catalog (that's where its TowerTypeId comes from).")]
public TowerDefinition Tower;
public override bool IsValidFor(ulong clientId)
{
var deck = PlayerTowerDeck.GetForClient(clientId);
var pm = TowerPlacementManager.Instance;
if (deck == null || pm == null || Tower == null) return false;
// Not in the catalog → no TypeId → can't be granted or placed.
if (!pm.TryGetTypeId(Tower, out int typeId)) return false;
// Only offer towers the player hasn't unlocked yet.
return !deck.Contains(typeId);
}
public override bool ServerApply(ulong clientId)
{
var deck = PlayerTowerDeck.GetForClient(clientId);
var pm = TowerPlacementManager.Instance;
if (deck == null || pm == null || Tower == null) return false;
if (!pm.TryGetTypeId(Tower, out int typeId))
{
Debug.LogError($"[NewTowerDraftOption] '{Tower.name}' is not in the tower catalog; " +
$"cannot grant. Add it to TowerPlacementManager.towerDefinitions.");
return false;
}
return deck.ServerGrantTower(typeId);
}
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8c2d6e4f1a9b4c73d05e8f1a2b6c9d40
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,167 @@
// Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
namespace TD.Gameplay.Draft
{
/// <summary>
/// Per-player draft state — the set of <see cref="DraftOption"/>s currently offered to
/// one player. Lives on the Player prefab alongside <see cref="PlayerTowerDeck"/>,
/// <see cref="PlayerGoldManager"/>, etc.
/// </summary>
/// <remarks>
/// <para><b>Offered set.</b> <see cref="currentOptionIds"/> holds the DraftOptionIds
/// the player is choosing between (typically 3). Empty means no active draft. The
/// owning client renders cards from these ids; the server reads them to validate a pick.</para>
///
/// <para><b>Authority.</b> The server offers (<see cref="ServerOffer"/>), resolves
/// (<see cref="ServerResolve"/> / <see cref="ServerAutoResolve"/>), and applies the
/// chosen option. The owning client only sends intent via
/// <see cref="RequestPickRpc"/> and <see cref="RequestBuyRerollRpc"/>.</para>
///
/// <para><b>Generation</b> lives in <see cref="DraftService"/>; this component is just
/// the replicated state + the pick/buy entry points.</para>
/// </remarks>
public class PlayerDraft : NetworkBehaviour
{
// ----- Static registry (mirrors PlayerGoldManager / PlayerTowerDeck) -----
private static readonly Dictionary<ulong, PlayerDraft> s_byClientId
= new Dictionary<ulong, PlayerDraft>();
public static PlayerDraft GetForClient(ulong clientId)
{
s_byClientId.TryGetValue(clientId, out var draft);
return draft;
}
public static PlayerDraft Local
{
get
{
var nm = NetworkManager.Singleton;
if (nm == null || !nm.IsClient) return null;
return GetForClient(nm.LocalClientId);
}
}
// ----- Networked state --------------------------------------------
// The DraftOptionIds currently offered to this player. Empty = no active draft.
private NetworkList<int> currentOptionIds;
/// <summary>Fired on every peer when the offered set changes (offered, picked,
/// cleared). The HUD subscribes to show/hide/rebuild the draft overlay.</summary>
public event System.Action OnDraftChanged;
private void Awake()
{
currentOptionIds = new NetworkList<int>();
}
public override void OnNetworkSpawn()
{
s_byClientId[OwnerClientId] = this;
currentOptionIds.OnListChanged += HandleListChanged;
}
public override void OnNetworkDespawn()
{
currentOptionIds.OnListChanged -= HandleListChanged;
if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
s_byClientId.Remove(OwnerClientId);
}
private void HandleListChanged(NetworkListEvent<int> _) => OnDraftChanged?.Invoke();
// ----- Read API ---------------------------------------------------
/// <summary>True if the player currently has options to choose from.</summary>
public bool HasActiveDraft => currentOptionIds.Count > 0;
public int OptionCount => currentOptionIds.Count;
public int GetOptionId(int index) => currentOptionIds[index];
private bool IsOffered(int optionId)
{
for (int i = 0; i < currentOptionIds.Count; i++)
if (currentOptionIds[i] == optionId) return true;
return false;
}
// ----- Server: offer / resolve ------------------------------------
/// <summary>Server-only: replace the offered set with <paramref name="ids"/>.</summary>
public void ServerOffer(IReadOnlyList<int> ids)
{
if (!IsServer) return;
currentOptionIds.Clear();
if (ids == null) return;
for (int i = 0; i < ids.Count; i++)
currentOptionIds.Add(ids[i]);
}
/// <summary>
/// Server-only: apply one of the currently-offered options by id, then clear the
/// draft. No-op (returns false) if the id wasn't offered or the option fails to apply.
/// </summary>
private bool ServerResolve(int optionId)
{
if (!IsServer) return false;
if (!IsOffered(optionId)) return false;
var option = DraftPool.Instance != null ? DraftPool.Instance.Get(optionId) : null;
bool applied = option != null && option.ServerApply(OwnerClientId);
if (!applied)
Debug.LogWarning($"[PlayerDraft] Option {optionId} could not be applied for " +
$"client {OwnerClientId}.");
currentOptionIds.Clear();
return applied;
}
/// <summary>
/// Server-only: if the player still has an unpicked draft, auto-resolve it by
/// applying the first offered option. Called at prep end so a free reward is never
/// wasted by an idle player.
/// </summary>
public void ServerAutoResolve()
{
if (!IsServer) return;
if (currentOptionIds.Count == 0) return;
ServerResolve(currentOptionIds[0]);
}
// ----- Owner → server RPCs ----------------------------------------
/// <summary>Owning client: pick one of the offered options by id.</summary>
[Rpc(SendTo.Server, RequireOwnership = true)]
public void RequestPickRpc(int optionId)
{
ServerResolve(optionId);
}
/// <summary>
/// Owning client: buy a fresh roll of options for gold. Rejected if the player can't
/// afford it or already has an unresolved draft (resolve the current one first so a
/// free pick is never silently overwritten).
/// </summary>
[Rpc(SendTo.Server, RequireOwnership = true)]
public void RequestBuyRerollRpc()
{
if (HasActiveDraft) return; // resolve the pending draft before buying another
var service = DraftService.Instance;
if (service == null) return;
var gold = PlayerGoldManager.GetForClient(OwnerClientId);
int cost = service.RerollCost;
if (gold == null || gold.CurrentGold < cost) return;
gold.DeductGold(cost);
service.ServerOfferTo(OwnerClientId);
}
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2b8f4d1e6c3a497051d8e2f7a9c1b063
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -3,6 +3,7 @@ using System.Collections;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Gameplay.Draft;
using TD.Levels;
using TD.UI;
@ -318,6 +319,11 @@ namespace TD.Gameplay
activeEnemyCount = 0;
spawningComplete = false;
heldEnemies.Clear();
// Force-advancing interrupts the prep phase, so resolve any drafts the players
// had open before skipping ahead.
DraftService.Instance?.ServerAutoResolveAll();
StartNextWave(skipPrep: true);
}
@ -330,6 +336,10 @@ namespace TD.Gameplay
// observe the value via NetworkVariable replication.
if (!skipPrep)
{
// Open a draft for every player at the start of the build phase. They pick
// during prep; any unpicked draft is auto-resolved when the timer expires.
DraftService.Instance?.ServerOfferToAll();
prepCountdown.Value = def.PrepTime;
float remaining = def.PrepTime;
// Throttle network sync to ~10 Hz. NetworkVariable replicates on every
@ -348,6 +358,10 @@ namespace TD.Gameplay
nextSync = remaining - NetworkSyncInterval;
}
}
// Prep timer expired — auto-resolve any draft the player didn't pick so
// the free reward isn't wasted.
DraftService.Instance?.ServerAutoResolveAll();
}
// Ensure the countdown reads zero entering the spawn phase, regardless of
// whether prep was skipped or just expired.