Merge branch 'feature/draft-system'
This commit is contained in:
commit
b0ca2e1d28
22 changed files with 1060 additions and 430 deletions
8
Assets/_Project/Definitions/Draft.meta
Normal file
8
Assets/_Project/Definitions/Draft.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b2c3d4e5f6071829304a5b6c7d8e9f01
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 8c2d6e4f1a9b4c73d05e8f1a2b6c9d40, type: 3}
|
||||
m_Name: Draft_NewTower_SiegeCannon
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Draft.NewTowerDraftOption
|
||||
DisplayName: Siege Cannon
|
||||
Description: Add the Siege Cannon to your arsenal. Ground-only; lobs a shell that
|
||||
deals splash damage on impact.
|
||||
Icon: {fileID: 0}
|
||||
Weight: 1
|
||||
Tower: {fileID: 11400000, guid: c4a1f7e92b6d8a4e3f0c9b1d6e5a2f80, type: 2}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6b1d3f8a2c5e4097b8a1d0c3e6f9b240
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
19
Assets/_Project/Definitions/Draft/Draft_NewTower_Wall.asset
Normal file
19
Assets/_Project/Definitions/Draft/Draft_NewTower_Wall.asset
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 8c2d6e4f1a9b4c73d05e8f1a2b6c9d40, type: 3}
|
||||
m_Name: Draft_NewTower_Wall
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Draft.NewTowerDraftOption
|
||||
DisplayName: Wall
|
||||
Description: Add the Wall to your arsenal. No attack, near-free; shapes your maze.
|
||||
Icon: {fileID: 0}
|
||||
Weight: 1
|
||||
Tower: {fileID: 11400000, guid: d5b2e8fa3c7e9b5f4a1d0c2e7f6b3a91, type: 2}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7c2e4a9b3d6f51a80c9b2e1d4f7a3b51
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -15,6 +15,7 @@ GameObject:
|
|||
- component: {fileID: 4336209376377567030}
|
||||
- component: {fileID: 2806524246861401760}
|
||||
- component: {fileID: 2806524246861401799}
|
||||
- component: {fileID: 2806524246861401801}
|
||||
m_Layer: 0
|
||||
m_Name: Player
|
||||
m_TagString: Untagged
|
||||
|
|
@ -131,3 +132,16 @@ MonoBehaviour:
|
|||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerTowerDeck
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
--- !u!114 &2806524246861401801
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3493329038866903420}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 2b8f4d1e6c3a497051d8e2f7a9c1b063, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Draft.PlayerDraft
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
|
|
|
|||
|
|
@ -19775,7 +19775,6 @@ MonoBehaviour:
|
|||
- {fileID: 11400000, guid: 0f693e29ca953e1439e10cb8f12e4b30, type: 2}
|
||||
- {fileID: 11400000, guid: 04219895bbea4fb9a94fd62fef35bf2a, type: 2}
|
||||
startingDeck:
|
||||
- {fileID: 11400000, guid: d5b2e8fa3c7e9b5f4a1d0c2e7f6b3a91, type: 2}
|
||||
- {fileID: 11400000, guid: 0f693e29ca953e1439e10cb8f12e4b30, type: 2}
|
||||
- {fileID: 11400000, guid: 04219895bbea4fb9a94fd62fef35bf2a, type: 2}
|
||||
--- !u!4 &1507514109
|
||||
|
|
@ -28370,6 +28369,68 @@ Mesh:
|
|||
- serializedVersion: 1
|
||||
m_IndexStart: 0
|
||||
m_IndexCount: 0
|
||||
--- !u!1 &2139601598
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 2139601601}
|
||||
- component: {fileID: 2139601600}
|
||||
- component: {fileID: 2139601599}
|
||||
m_Layer: 0
|
||||
m_Name: DraftController
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!114 &2139601599
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2139601598}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 9d3a7c5e1b8f42606a2d4e9f1c7b3850, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Draft.DraftService
|
||||
optionsPerDraft: 3
|
||||
rerollCost: 50
|
||||
--- !u!114 &2139601600
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2139601598}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 5a7e1f3c9d2b4867a1c0e4f8b3d6792a, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Draft.DraftPool
|
||||
options:
|
||||
- {fileID: 11400000, guid: 6b1d3f8a2c5e4097b8a1d0c3e6f9b240, type: 2}
|
||||
- {fileID: 11400000, guid: 7c2e4a9b3d6f51a80c9b2e1d4f7a3b51, type: 2}
|
||||
--- !u!4 &2139601601
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2139601598}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 76.27885, y: 0.50001, z: 118.9994}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1 &2142639533
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
|
|
@ -28750,3 +28811,4 @@ SceneRoots:
|
|||
- {fileID: 1430789337}
|
||||
- {fileID: 1097152085}
|
||||
- {fileID: 1889390964}
|
||||
- {fileID: 2139601601}
|
||||
|
|
|
|||
8
Assets/_Project/Scripts/Gameplay/Draft.meta
Normal file
8
Assets/_Project/Scripts/Gameplay/Draft.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a1b2c3d4e5f60718293a4b5c6d7e8f90
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
59
Assets/_Project/Scripts/Gameplay/Draft/DraftOption.cs
Normal file
59
Assets/_Project/Scripts/Gameplay/Draft/DraftOption.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
Assets/_Project/Scripts/Gameplay/Draft/DraftOption.cs.meta
Normal file
11
Assets/_Project/Scripts/Gameplay/Draft/DraftOption.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3f9b1c7a2e8d4a16b0c5e9f2a7d34b81
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
64
Assets/_Project/Scripts/Gameplay/Draft/DraftPool.cs
Normal file
64
Assets/_Project/Scripts/Gameplay/Draft/DraftPool.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Project/Scripts/Gameplay/Draft/DraftPool.cs.meta
Normal file
11
Assets/_Project/Scripts/Gameplay/Draft/DraftPool.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5a7e1f3c9d2b4867a1c0e4f8b3d6792a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
132
Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs
Normal file
132
Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs.meta
Normal file
11
Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9d3a7c5e1b8f42606a2d4e9f1c7b3850
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8c2d6e4f1a9b4c73d05e8f1a2b6c9d40
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
167
Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs
Normal file
167
Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs.meta
Normal file
11
Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2b8f4d1e6c3a497051d8e2f7a9c1b063
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using UnityEngine.SceneManagement;
|
|||
using UnityEngine.UIElements;
|
||||
using TD.Core;
|
||||
using TD.Gameplay;
|
||||
using TD.Gameplay.Draft;
|
||||
using TD.Towers;
|
||||
using TD.UI.Minimap;
|
||||
|
||||
|
|
@ -105,6 +106,15 @@ namespace TD.UI
|
|||
private VisualElement buffMenuContent;
|
||||
private Label matchEndTitle;
|
||||
|
||||
// Draft overlay (roguelike between-waves draft). Non-modal: the card row floats at
|
||||
// top-center so the player can keep building during prep. The "Buy Roll" button is
|
||||
// shown when no draft is pending so a paid roll can be bought any time.
|
||||
private VisualElement draftPanel;
|
||||
private VisualElement draftCardRow;
|
||||
private Button draftBuyButton;
|
||||
private bool draftSubscribed;
|
||||
private PlayerDraft subscribedDraft;
|
||||
|
||||
// Chat panel (bottom-left, above portrait) — programmatic. The container
|
||||
// holds both the scrollable feed and the input. Highlight + scroll
|
||||
// interactivity are toggled on the container when typing.
|
||||
|
|
@ -267,6 +277,163 @@ namespace TD.UI
|
|||
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
|
||||
}
|
||||
|
||||
// Hook the local player's draft so the overlay rebuilds when options are offered,
|
||||
// picked, or rerolled. Retried each Update until the local PlayerDraft exists.
|
||||
private void TrySubscribeDraft()
|
||||
{
|
||||
var draft = PlayerDraft.Local;
|
||||
if (draft == null) return;
|
||||
draft.OnDraftChanged += HandleDraftChanged;
|
||||
subscribedDraft = draft;
|
||||
draftSubscribed = true;
|
||||
RebuildDraftCards();
|
||||
}
|
||||
|
||||
private void HandleDraftChanged()
|
||||
{
|
||||
RebuildDraftCards();
|
||||
}
|
||||
|
||||
// ----- Draft overlay ----------------------------------------------
|
||||
|
||||
// Non-modal draft UI: a card row floats at top-center while a draft is active, and
|
||||
// a "Buy Roll" button shows when no draft is pending. The host panel ignores picks
|
||||
// so empty space clicks through to the world — the player keeps building during prep.
|
||||
private void BuildDraftOverlay(VisualElement root)
|
||||
{
|
||||
draftPanel = new VisualElement();
|
||||
draftPanel.style.position = Position.Absolute;
|
||||
draftPanel.style.left = 0;
|
||||
draftPanel.style.right = 0;
|
||||
draftPanel.style.top = 70; // below the top bar
|
||||
draftPanel.style.alignItems = Align.Center;
|
||||
draftPanel.pickingMode = PickingMode.Ignore;
|
||||
draftPanel.style.display = DisplayStyle.None;
|
||||
|
||||
var box = new VisualElement();
|
||||
box.style.flexDirection = FlexDirection.Column;
|
||||
box.style.alignItems = Align.Center;
|
||||
box.pickingMode = PickingMode.Ignore;
|
||||
|
||||
draftCardRow = new VisualElement();
|
||||
draftCardRow.style.flexDirection = FlexDirection.Row;
|
||||
draftCardRow.pickingMode = PickingMode.Ignore;
|
||||
box.Add(draftCardRow);
|
||||
|
||||
draftBuyButton = new Button(() => PlayerDraft.Local?.RequestBuyRerollRpc())
|
||||
{
|
||||
text = "Buy Roll"
|
||||
};
|
||||
draftBuyButton.style.marginTop = 6;
|
||||
box.Add(draftBuyButton);
|
||||
|
||||
draftPanel.Add(box);
|
||||
root.Add(draftPanel);
|
||||
|
||||
UpdateDraftVisibility();
|
||||
}
|
||||
|
||||
// Rebuilds the option cards from the local player's current offer. Called on
|
||||
// OnDraftChanged (offered / picked / rerolled).
|
||||
private void RebuildDraftCards()
|
||||
{
|
||||
if (draftCardRow == null) return;
|
||||
draftCardRow.Clear();
|
||||
|
||||
var draft = PlayerDraft.Local;
|
||||
var pool = DraftPool.Instance;
|
||||
if (draft != null && pool != null)
|
||||
{
|
||||
for (int i = 0; i < draft.OptionCount; i++)
|
||||
{
|
||||
int id = draft.GetOptionId(i);
|
||||
var option = pool.Get(id);
|
||||
if (option == null) continue;
|
||||
draftCardRow.Add(CreateDraftCard(option, id, draft));
|
||||
}
|
||||
}
|
||||
|
||||
UpdateDraftVisibility();
|
||||
}
|
||||
|
||||
private VisualElement CreateDraftCard(DraftOption option, int optionId, PlayerDraft draft)
|
||||
{
|
||||
var card = new VisualElement();
|
||||
card.style.width = 150;
|
||||
card.style.marginLeft = card.style.marginRight = 4;
|
||||
card.style.paddingTop = card.style.paddingBottom = 10;
|
||||
card.style.paddingLeft = card.style.paddingRight = 10;
|
||||
card.style.backgroundColor = new Color(0.10f, 0.10f, 0.13f, 0.96f);
|
||||
card.style.borderTopWidth = card.style.borderBottomWidth =
|
||||
card.style.borderLeftWidth = card.style.borderRightWidth = 2;
|
||||
var border = new Color(0.4f, 0.4f, 0.45f);
|
||||
card.style.borderTopColor = card.style.borderBottomColor =
|
||||
card.style.borderLeftColor = card.style.borderRightColor = border;
|
||||
card.style.alignItems = Align.Center;
|
||||
|
||||
var title = new Label(option.DisplayName);
|
||||
title.style.fontSize = 14;
|
||||
title.style.color = Color.white;
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
title.style.whiteSpace = WhiteSpace.Normal;
|
||||
title.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
title.style.marginBottom = 6;
|
||||
card.Add(title);
|
||||
|
||||
if (option.Icon != null)
|
||||
{
|
||||
var img = new Image { sprite = option.Icon };
|
||||
img.style.width = 48;
|
||||
img.style.height = 48;
|
||||
img.style.marginBottom = 6;
|
||||
card.Add(img);
|
||||
}
|
||||
|
||||
var desc = new Label(option.Description ?? "");
|
||||
desc.style.fontSize = 11;
|
||||
desc.style.color = new Color(0.82f, 0.82f, 0.82f);
|
||||
desc.style.whiteSpace = WhiteSpace.Normal;
|
||||
desc.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
desc.style.marginBottom = 8;
|
||||
card.Add(desc);
|
||||
|
||||
var pick = new Button(() => draft.RequestPickRpc(optionId)) { text = "Pick" };
|
||||
card.Add(pick);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Per-frame: toggles the card row vs. the buy button and keeps the buy button's
|
||||
// label/enabled state in sync with gold. Cheap (mirrors the gold label's per-frame
|
||||
// refresh); only allocates a string while the buy button is shown.
|
||||
private void UpdateDraftVisibility()
|
||||
{
|
||||
if (draftPanel == null) return;
|
||||
|
||||
var draft = PlayerDraft.Local;
|
||||
var service = DraftService.Instance;
|
||||
var gold = PlayerGoldManager.Local;
|
||||
|
||||
bool hasDraft = draft != null && draft.HasActiveDraft;
|
||||
bool canShowBuy = draft != null && service != null && !hasDraft;
|
||||
|
||||
if (draftCardRow != null)
|
||||
draftCardRow.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
|
||||
if (draftBuyButton != null)
|
||||
{
|
||||
draftBuyButton.style.display = canShowBuy ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
if (canShowBuy)
|
||||
{
|
||||
int cost = service.RerollCost;
|
||||
draftBuyButton.text = $"Buy Roll ({cost}g)";
|
||||
draftBuyButton.SetEnabled(gold != null && gold.CurrentGold >= cost);
|
||||
}
|
||||
}
|
||||
|
||||
draftPanel.style.display = (hasDraft || canShowBuy) ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
}
|
||||
|
||||
private void InitializeUI()
|
||||
{
|
||||
var doc = GetComponent<UIDocument>();
|
||||
|
|
@ -351,6 +518,10 @@ namespace TD.UI
|
|||
// Build the buff menu overlay. Hidden until the player presses B.
|
||||
BuildBuffMenuOverlay(root);
|
||||
|
||||
// Build the draft overlay (roguelike between-waves draft). Hidden until the
|
||||
// local player has an active draft (or to show the paid "Buy Roll" button).
|
||||
BuildDraftOverlay(root);
|
||||
|
||||
// Chat feed + input. Anchored bottom-left, just above the portrait/bottom-ui bar.
|
||||
// Player typing toggled with Enter; system messages (e.g. life lost) post via
|
||||
// ChatService.PostLocalSystem on every peer.
|
||||
|
|
@ -403,6 +574,13 @@ namespace TD.UI
|
|||
}
|
||||
deckSubscribed = false;
|
||||
subscribedDeck = null;
|
||||
|
||||
if (draftSubscribed && subscribedDraft != null)
|
||||
{
|
||||
subscribedDraft.OnDraftChanged -= HandleDraftChanged;
|
||||
}
|
||||
draftSubscribed = false;
|
||||
subscribedDraft = null;
|
||||
}
|
||||
|
||||
private void TrySubscribeSelection()
|
||||
|
|
@ -426,10 +604,14 @@ namespace TD.UI
|
|||
if (!deckSubscribed)
|
||||
TrySubscribeDeck();
|
||||
|
||||
if (!draftSubscribed)
|
||||
TrySubscribeDraft();
|
||||
|
||||
RefreshGoldDisplay();
|
||||
RefreshMatchStateDisplays();
|
||||
UpdateBuildProgressIfShown();
|
||||
UpdateEnemyInfoIfShown();
|
||||
UpdateDraftVisibility();
|
||||
HandleChatInput();
|
||||
|
||||
// Skip gameplay hotkeys while the chat input is focused — letters
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue