From 117c77b4a8bab48daf3e91cae3d61d24e518ac73 Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 23 Jun 2026 21:55:12 -0700 Subject: [PATCH] Adding new tower types and concept of tower deck --- .../Races/BloodAngels_Placeholder.asset | 5 +- .../Races/Ultramarines_Placeholder.asset | 5 +- .../Definitions/Towers/BasicTower.asset | 5 +- .../Definitions/Towers/SiegeCannon.asset | 37 +++++ .../Definitions/Towers/SiegeCannon.asset.meta | 8 + Assets/_Project/Definitions/Towers/Wall.asset | 36 +++++ .../Definitions/Towers/Wall.asset.meta | 8 + Assets/_Project/Prefabs/Player/Player.prefab | 14 ++ Assets/_Project/Scenes/Levels/9Player.unity | 7 + .../_Project/Scripts/Dev/DevWaveControls.cs | 51 +++++- .../Scripts/Gameplay/PlayerTowerDeck.cs | 150 ++++++++++++++++++ .../Scripts/Gameplay/PlayerTowerDeck.cs.meta | 11 ++ .../Scripts/Gameplay/TowerPlacementManager.cs | 67 +++++++- .../Gameplay/TowerPlacementSettings.cs | 5 + .../_Project/Scripts/Gameplay/WaveManager.cs | 20 +++ Assets/_Project/Scripts/UI/HUDController.cs | 53 ++++++- 16 files changed, 463 insertions(+), 19 deletions(-) create mode 100644 Assets/_Project/Definitions/Towers/SiegeCannon.asset create mode 100644 Assets/_Project/Definitions/Towers/SiegeCannon.asset.meta create mode 100644 Assets/_Project/Definitions/Towers/Wall.asset create mode 100644 Assets/_Project/Definitions/Towers/Wall.asset.meta create mode 100644 Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs create mode 100644 Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs.meta diff --git a/Assets/_Project/Definitions/Races/BloodAngels_Placeholder.asset b/Assets/_Project/Definitions/Races/BloodAngels_Placeholder.asset index 7c72d09..58d9e9b 100644 --- a/Assets/_Project/Definitions/Races/BloodAngels_Placeholder.asset +++ b/Assets/_Project/Definitions/Races/BloodAngels_Placeholder.asset @@ -20,4 +20,7 @@ MonoBehaviour: LoreText: Crucified and brought back by God because her job wasn't finished. She's here to bring death to to Xenos scum. BuilderPrefab: {fileID: 116861493430507844, guid: 3398cc5831880954487717577f61b6d7, type: 3} - Towers: [] + Towers: + - {fileID: 11400000, guid: 0f693e29ca953e1439e10cb8f12e4b30, type: 2} + - {fileID: 11400000, guid: c4a1f7e92b6d8a4e3f0c9b1d6e5a2f80, type: 2} + - {fileID: 11400000, guid: d5b2e8fa3c7e9b5f4a1d0c2e7f6b3a91, type: 2} diff --git a/Assets/_Project/Definitions/Races/Ultramarines_Placeholder.asset b/Assets/_Project/Definitions/Races/Ultramarines_Placeholder.asset index e83b9ea..086a765 100644 --- a/Assets/_Project/Definitions/Races/Ultramarines_Placeholder.asset +++ b/Assets/_Project/Definitions/Races/Ultramarines_Placeholder.asset @@ -20,4 +20,7 @@ MonoBehaviour: LoreText: King of the protoss, he's here to kick bubblegum and chew ass, and he's all out of ass. BuilderPrefab: {fileID: 116861493430507844, guid: 3398cc5831880954487717577f61b6d7, type: 3} - Towers: [] + Towers: + - {fileID: 11400000, guid: 0f693e29ca953e1439e10cb8f12e4b30, type: 2} + - {fileID: 11400000, guid: c4a1f7e92b6d8a4e3f0c9b1d6e5a2f80, type: 2} + - {fileID: 11400000, guid: d5b2e8fa3c7e9b5f4a1d0c2e7f6b3a91, type: 2} diff --git a/Assets/_Project/Definitions/Towers/BasicTower.asset b/Assets/_Project/Definitions/Towers/BasicTower.asset index c04cc5c..e32563a 100644 --- a/Assets/_Project/Definitions/Towers/BasicTower.asset +++ b/Assets/_Project/Definitions/Towers/BasicTower.asset @@ -12,8 +12,9 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 7b353a757b6e6774d97e6fb8ba138fcc, type: 3} m_Name: BasicTower m_EditorClassIdentifier: Assembly-CSharp::TD.Towers.TowerDefinition - DisplayName: A basic tower for testing. - Description: + DisplayName: Basic Arrow + Description: Targets ground and air. Fires single-target arrows. The bread and butter + of any maze. FootprintSize: {x: 2, y: 2} GoldCost: 25 BuildTime: 0.5 diff --git a/Assets/_Project/Definitions/Towers/SiegeCannon.asset b/Assets/_Project/Definitions/Towers/SiegeCannon.asset new file mode 100644 index 0000000..e9a41bc --- /dev/null +++ b/Assets/_Project/Definitions/Towers/SiegeCannon.asset @@ -0,0 +1,37 @@ +%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: 7b353a757b6e6774d97e6fb8ba138fcc, type: 3} + m_Name: SiegeCannon + m_EditorClassIdentifier: Assembly-CSharp::TD.Towers.TowerDefinition + DisplayName: Siege Cannon + Description: Ground-only. Lobs a ballistic shell that deals splash damage around the + point of impact. + FootprintSize: {x: 2, y: 2} + GoldCost: 60 + BuildTime: 0.5 + TowerPrefab: {fileID: 6482414459531823157, guid: 1511641f145758b469e64376d2a0d434, type: 3} + DamageType: 0 + TargetPriority: 0 + TargetType: 1 + GroundedOnly: 1 + Damage: 25 + Range: 16 + FireRate: 1 + SplashRadius: 4 + ChainCount: 0 + ChainRange: 0 + SlowFactor: 0 + DotDamagePerSecond: 0 + EffectDuration: 0 + ProjectilePrefab: {fileID: 2664719039363295382, guid: dc2e4a4108e03874a8b2dab88dcc8fba, type: 3} + ProjectileSpeed: 12 + UpgradePaths: [] diff --git a/Assets/_Project/Definitions/Towers/SiegeCannon.asset.meta b/Assets/_Project/Definitions/Towers/SiegeCannon.asset.meta new file mode 100644 index 0000000..27097fd --- /dev/null +++ b/Assets/_Project/Definitions/Towers/SiegeCannon.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c4a1f7e92b6d8a4e3f0c9b1d6e5a2f80 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/Towers/Wall.asset b/Assets/_Project/Definitions/Towers/Wall.asset new file mode 100644 index 0000000..8ee63b7 --- /dev/null +++ b/Assets/_Project/Definitions/Towers/Wall.asset @@ -0,0 +1,36 @@ +%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: 7b353a757b6e6774d97e6fb8ba138fcc, type: 3} + m_Name: Wall + m_EditorClassIdentifier: Assembly-CSharp::TD.Towers.TowerDefinition + DisplayName: Wall + Description: Deals no damage and costs next to nothing. Use it to shape your maze. + FootprintSize: {x: 2, y: 2} + GoldCost: 1 + BuildTime: 0.5 + TowerPrefab: {fileID: 6482414459531823157, guid: 1511641f145758b469e64376d2a0d434, type: 3} + DamageType: 0 + TargetPriority: 0 + TargetType: 0 + GroundedOnly: 0 + Damage: 0 + Range: 0 + FireRate: 0 + SplashRadius: 0 + ChainCount: 0 + ChainRange: 0 + SlowFactor: 0 + DotDamagePerSecond: 0 + EffectDuration: 0 + ProjectilePrefab: {fileID: 0} + ProjectileSpeed: 0 + UpgradePaths: [] diff --git a/Assets/_Project/Definitions/Towers/Wall.asset.meta b/Assets/_Project/Definitions/Towers/Wall.asset.meta new file mode 100644 index 0000000..e8abc02 --- /dev/null +++ b/Assets/_Project/Definitions/Towers/Wall.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d5b2e8fa3c7e9b5f4a1d0c2e7f6b3a91 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab index 4d7c382..b6e83b9 100644 --- a/Assets/_Project/Prefabs/Player/Player.prefab +++ b/Assets/_Project/Prefabs/Player/Player.prefab @@ -14,6 +14,7 @@ GameObject: - component: {fileID: 7845089877743661692} - component: {fileID: 4336209376377567030} - component: {fileID: 2806524246861401760} + - component: {fileID: 2806524246861401799} m_Layer: 0 m_Name: Player m_TagString: Untagged @@ -117,3 +118,16 @@ MonoBehaviour: ShowTopMostFoldoutHeaderGroup: 1 categories: - {fileID: 11400000, guid: d8ed3b9535538f7fc82be878cc307d26, type: 2} +--- !u!114 &2806524246861401799 +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: b7e4c91a08f5d2461a3f8e6c25b09d74, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerTowerDeck + ShowTopMostFoldoutHeaderGroup: 1 diff --git a/Assets/_Project/Scenes/Levels/9Player.unity b/Assets/_Project/Scenes/Levels/9Player.unity index 1869cae..9e21cf2 100644 --- a/Assets/_Project/Scenes/Levels/9Player.unity +++ b/Assets/_Project/Scenes/Levels/9Player.unity @@ -19772,6 +19772,12 @@ MonoBehaviour: towerDefinitions: - {fileID: 0} - {fileID: 11400000, guid: 0f693e29ca953e1439e10cb8f12e4b30, type: 2} + - {fileID: 11400000, guid: c4a1f7e92b6d8a4e3f0c9b1d6e5a2f80, type: 2} + - {fileID: 11400000, guid: d5b2e8fa3c7e9b5f4a1d0c2e7f6b3a91, type: 2} + startingDeck: + - {fileID: 11400000, guid: d5b2e8fa3c7e9b5f4a1d0c2e7f6b3a91, type: 2} + - {fileID: 11400000, guid: 0f693e29ca953e1439e10cb8f12e4b30, type: 2} + - {fileID: 11400000, guid: c4a1f7e92b6d8a4e3f0c9b1d6e5a2f80, type: 2} --- !u!4 &1507514109 Transform: m_ObjectHideFlags: 0 @@ -22520,6 +22526,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Dev.DevWaveControls hotkey: 94 + grantTowerHotkey: 101 --- !u!4 &1731269687 Transform: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Scripts/Dev/DevWaveControls.cs b/Assets/_Project/Scripts/Dev/DevWaveControls.cs index 26e1295..2dfd6ff 100644 --- a/Assets/_Project/Scripts/Dev/DevWaveControls.cs +++ b/Assets/_Project/Scripts/Dev/DevWaveControls.cs @@ -28,15 +28,21 @@ namespace TD.Dev "Set to Key.None to use the OnGUI button only.")] [SerializeField] private Key hotkey = Key.F9; + [Tooltip("Keyboard shortcut to grant the local player the next catalog tower not " + + "yet in their deck. Stand-in for the draft system so deck growth can be " + + "verified now. Set to Key.None to use the OnGUI button only.")] + [SerializeField] private Key grantTowerHotkey = Key.F8; + private void Update() { - if (hotkey == Key.None) return; - var kb = Keyboard.current; if (kb == null) return; // no keyboard connected (e.g. headless server) - if (!kb[hotkey].wasPressedThisFrame) return; - TryForceNextWave(); + if (hotkey != Key.None && kb[hotkey].wasPressedThisFrame) + TryForceNextWave(); + + if (grantTowerHotkey != Key.None && kb[grantTowerHotkey].wasPressedThisFrame) + TryGrantNextTower(); } private void OnGUI() @@ -48,9 +54,44 @@ namespace TD.Dev // Anchored below the top HUD bar so it doesn't overlap gold/wave/lives. const float topOffset = 90f; - GUI.Box(new Rect(10, topOffset, 180, 60), "Dev: Wave Controls"); + GUI.Box(new Rect(10, topOffset, 180, 95), "Dev: Wave Controls"); if (GUI.Button(new Rect(20, topOffset + 25, 160, 28), "Force Next Wave")) TryForceNextWave(); + if (GUI.Button(new Rect(20, topOffset + 58, 160, 28), "Grant Next Tower")) + TryGrantNextTower(); + } + + // Dev stand-in for the draft system: grants the local player the first catalog + // tower they haven't unlocked yet, so deck growth + the HUD's live rebuild can be + // verified before the real draft exists. Server-only (host); ServerGrantTower + // no-ops elsewhere. + private void TryGrantNextTower() + { + if (NetworkManager.Singleton == null || !NetworkManager.Singleton.IsServer) + { + Debug.LogWarning("[DevWaveControls] Grant-tower requested off the server. Ignored."); + return; + } + + var placement = TowerPlacementManager.Instance; + var deck = PlayerTowerDeck.Local; + if (placement == null || deck == null) + { + Debug.LogWarning("[DevWaveControls] Cannot grant tower — TowerPlacementManager " + + "or local PlayerTowerDeck is not ready."); + return; + } + + foreach (var (def, typeId) in placement.GetAvailableDefinitions()) + { + if (deck.Contains(typeId)) continue; + if (deck.ServerGrantTower(typeId)) + Debug.Log($"[DevWaveControls] Granted '{def.DisplayName}' (typeId {typeId}) " + + $"to the local deck."); + return; + } + + Debug.Log("[DevWaveControls] Local deck already contains every catalog tower."); } private void TryForceNextWave() diff --git a/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs b/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs new file mode 100644 index 0000000..eb5687c --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs @@ -0,0 +1,150 @@ +// Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs +using System.Collections.Generic; +using Unity.Netcode; +using UnityEngine; + +namespace TD.Gameplay +{ + /// + /// Per-player set of unlocked tower types — the player's "deck". Lives on the + /// Player prefab alongside , + /// , and . + /// + /// + /// Roguelike keystone. Players no longer see every tower in the match + /// catalog. Each starts with a small base set (the starter towers) and grows the + /// deck through draft choices. This component is the data structure that growth + /// targets. + /// + /// Identity. The deck stores TowerTypeIds — indices into + /// 's catalog (towerDefinitions[]), the + /// same identifier the placement RPC, , and + /// already use. No new network identifier is introduced. + /// + /// Authority. Server-only mutation ( / + /// ). The owning client reads its own deck to build + /// the HUD command grid; the server reads it to gate placement + /// ( rejects a requested tower that isn't in the + /// requesting player's deck — the anti-cheat seam). + /// + /// Persistence caveat. TowerTypeId is a catalog index — stable + /// within a single match but NOT across sessions. Cross-match deck persistence (a + /// later keystone) will need a stable per-tower id (asset GUID or a serialized + /// StableId on TowerDefinition). + /// + public class PlayerTowerDeck : NetworkBehaviour + { + // ----- Static registry (mirrors PlayerGoldManager) ---------------- + + private static readonly Dictionary s_byClientId + = new Dictionary(); + + /// Returns the deck owned by the given client, or null. + public static PlayerTowerDeck GetForClient(ulong clientId) + { + s_byClientId.TryGetValue(clientId, out var deck); + return deck; + } + + /// Convenience: the local client's own deck. Null on a dedicated + /// server or before the local player has spawned. + public static PlayerTowerDeck Local + { + get + { + var nm = NetworkManager.Singleton; + if (nm == null || !nm.IsClient) return null; + return GetForClient(nm.LocalClientId); + } + } + + // ----- Networked state -------------------------------------------- + + // Unlocked tower type ids (indices into TowerPlacementManager's catalog). + // readPerm defaults to Everyone (consistent with the other per-player + // managers); only the owner consumes it for the HUD. writePerm Server. + private NetworkList unlockedTypeIds; + + /// + /// Fired on every peer when the deck contents change (server init, tower + /// granted). The HUD subscribes to rebuild the command grid live when a draft + /// grant lands while the builder is already selected. + /// + public event System.Action OnDeckChanged; + + private void Awake() + { + unlockedTypeIds = new NetworkList(); + } + + // ----- NGO lifecycle ---------------------------------------------- + + public override void OnNetworkSpawn() + { + s_byClientId[OwnerClientId] = this; + unlockedTypeIds.OnListChanged += HandleListChanged; + } + + public override void OnNetworkDespawn() + { + unlockedTypeIds.OnListChanged -= HandleListChanged; + + if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this) + s_byClientId.Remove(OwnerClientId); + } + + private void HandleListChanged(NetworkListEvent _) => OnDeckChanged?.Invoke(); + + // ----- Read API --------------------------------------------------- + + /// Number of towers unlocked in this deck. + public int Count => unlockedTypeIds.Count; + + /// The TowerTypeId at the given deck slot (0..Count-1). + public int GetTypeIdAt(int index) => unlockedTypeIds[index]; + + /// True if the given TowerTypeId is unlocked in this deck. + public bool Contains(int towerTypeId) + { + for (int i = 0; i < unlockedTypeIds.Count; i++) + if (unlockedTypeIds[i] == towerTypeId) return true; + return false; + } + + // ----- Server mutation -------------------------------------------- + + /// + /// Server-only: replace the deck with the given starting set. Called at match + /// start () so Retry / return-to-lobby cycles reset + /// the deck cleanly. Ignores ids <= 0 (the reserved catalog index) and + /// silently de-dupes. + /// + public void ServerInitialize(IReadOnlyList startingTypeIds) + { + if (!IsServer) return; + + unlockedTypeIds.Clear(); + if (startingTypeIds == null) return; + + for (int i = 0; i < startingTypeIds.Count; i++) + { + int id = startingTypeIds[i]; + if (id > 0 && !Contains(id)) unlockedTypeIds.Add(id); + } + } + + /// + /// Server-only: add a tower type to this deck (a draft grant). No-op if the id + /// is invalid or already present. Returns true if it was actually added. + /// + public bool ServerGrantTower(int towerTypeId) + { + if (!IsServer) return false; + if (towerTypeId <= 0) return false; + if (Contains(towerTypeId)) return false; + + unlockedTypeIds.Add(towerTypeId); + return true; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs.meta b/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs.meta new file mode 100644 index 0000000..aedfc4c --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b7e4c91a08f5d2461a3f8e6c25b09d74 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs index d7daf7d..29cd90e 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs @@ -71,12 +71,17 @@ namespace TD.Gameplay "worst-case 90/second (9 players × 10 placements/second).")] [SerializeField] private int requestsPerFrame = 3; - [Tooltip("Tower definitions available in this match, indexed by TowerTypeId. " + - "Populate this with every TowerDefinition asset the current race roster " + - "contains. Index 0 is reserved; valid IDs start at 1. " + - "(Temporary: will be driven by RaceDefinition once Path E is complete.)")] + [Tooltip("Tower CATALOG for this match, indexed by TowerTypeId. This is the full " + + "pool of towers that can exist (be drafted) this match — NOT what any one " + + "player can build. Per-player buildable sets live in PlayerTowerDeck. " + + "Index 0 is reserved; valid IDs start at 1.")] [SerializeField] private TowerDefinition[] towerDefinitions = new TowerDefinition[0]; + [Tooltip("The starter towers every player begins the match with. Each must also " + + "appear in the catalog above (that's where its TowerTypeId comes from). " + + "Resolved to TypeIds and granted to each PlayerTowerDeck at match start.")] + [SerializeField] private TowerDefinition[] startingDeck = new TowerDefinition[0]; + // ----- Internal request queue ------------------------------------- private struct PlacementRequest @@ -188,6 +193,16 @@ namespace TD.Gameplay return; } + // Deck membership — the requesting player must have unlocked this tower. + // Authoritative gate: a modded client cannot place a tower it hasn't drafted, + // even though every tower exists in the shared catalog. + var deck = PlayerTowerDeck.GetForClient(req.SenderClientId); + if (deck == null || !deck.Contains(req.TowerTypeId)) + { + Reject(req, PlacementRejectionReason.TowerNotInDeck); + return; + } + var loader = LevelLoader.Instance; if (loader == null || !loader.IsLoaded) { @@ -652,6 +667,46 @@ namespace TD.Gameplay } } + /// + /// Reverse lookup: returns the TowerTypeId (catalog index) for a given + /// definition asset, or false if it isn't in the catalog. Used to resolve the + /// assets into the TypeIds a + /// stores. + /// + public bool TryGetTypeId(TowerDefinition def, out int typeId) + { + typeId = 0; + if (def == null) return false; + for (int i = 1; i < towerDefinitions.Length; i++) + { + if (towerDefinitions[i] == def) { typeId = i; return true; } + } + return false; + } + + /// + /// Resolves the configured assets to their catalog + /// TypeIds. Logs an error for any starter tower missing from the catalog (it + /// would have no TypeId and couldn't be placed). Called once at match start. + /// + public List GetStartingDeckTypeIds() + { + var ids = new List(); + if (startingDeck == null) return ids; + + foreach (var def in startingDeck) + { + if (def == null) continue; + if (TryGetTypeId(def, out int id)) + ids.Add(id); + else + Debug.LogError($"[TowerPlacementManager] Starting-deck tower '{def.name}' " + + $"is not in the towerDefinitions catalog. Add it to the catalog " + + $"so it has a TowerTypeId, or it can't be granted/placed."); + } + return ids; + } + private static PlayerSlot ClientIdToPlayerSlot(ulong clientId) => PlayerMatchState.SlotForClient(clientId); @@ -707,6 +762,10 @@ namespace TD.Gameplay /// The requested tower type ID is not in the server's definition list. InvalidTowerType, + /// The requested tower exists in the catalog but the placing player has + /// not unlocked it in their deck. Draft it first. + TowerNotInDeck, + /// An unexpected server-side error occurred (e.g., LevelLoader not loaded, /// client not mapped to a PlayerSlot). Check server logs. ServerError, diff --git a/Assets/_Project/Scripts/Gameplay/TowerPlacementSettings.cs b/Assets/_Project/Scripts/Gameplay/TowerPlacementSettings.cs index 74278c1..6d3f79c 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerPlacementSettings.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementSettings.cs @@ -72,6 +72,10 @@ namespace TD.Gameplay "Player must cancel pending jobs or wait for one to complete.")] public string MessageJobLimitReached = "Builder queue is full."; + [Tooltip("Shown when the player tries to place a tower they haven't unlocked in " + + "their deck yet. Draft it first.")] + public string MessageTowerNotInDeck = "You haven't unlocked that tower yet."; + [Tooltip("Shown for unexpected server-side errors (invalid tower type, etc.). " + "Should rarely appear in normal play.")] public string MessageServerError = "Placement failed. Please try again."; @@ -92,6 +96,7 @@ namespace TD.Gameplay case PlacementRejectionReason.OutOfRange: return MessageOutOfRange; case PlacementRejectionReason.BlocksPath: return MessageBlocksPath; case PlacementRejectionReason.JobLimitReached: return MessageJobLimitReached; + case PlacementRejectionReason.TowerNotInDeck: return MessageTowerNotInDeck; case PlacementRejectionReason.InvalidTowerType: case PlacementRejectionReason.ServerError: default: return MessageServerError; diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs b/Assets/_Project/Scripts/Gameplay/WaveManager.cs index 405b148..6f5f6e2 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveManager.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs @@ -160,6 +160,26 @@ namespace TD.Gameplay } } + // Seed each player's tower deck with the starter set. Runs here (after the + // one-frame yield) so the TowerPlacementManager catalog is guaranteed spawned + // and its starting-deck assets resolve to valid TypeIds. Clearing-and-setting + // means Retry / return-to-lobby cycles reset the deck cleanly. + var placement = TowerPlacementManager.Instance; + if (placement != null) + { + var startingTypeIds = placement.GetStartingDeckTypeIds(); + foreach (var pms in PlayerMatchState.AllPlayers) + { + var deck = PlayerTowerDeck.GetForClient(pms.OwnerClientId); + if (deck != null) deck.ServerInitialize(startingTypeIds); + } + } + else + { + Debug.LogWarning("[WaveManager] TowerPlacementManager not found at match start. " + + "Player decks were not seeded."); + } + if (ms.Phase == MatchPhase.Playing) StartNextWave(); } diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index 8dfe824..ccef8f0 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -137,6 +137,8 @@ namespace TD.UI private bool uiInitialized; private bool selectionSubscribed; // true once we've successfully hooked SelectionState.OnSelectionChanged private bool matchStateSubscribed; // true once OnPhaseChanged is hooked + private bool deckSubscribed; // true once we've hooked the local PlayerTowerDeck.OnDeckChanged + private PlayerTowerDeck subscribedDeck; // the deck we hooked, so we can unsubscribe the same instance private MinimapView minimapView; private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us @@ -245,6 +247,26 @@ namespace TD.UI matchStateSubscribed = true; } + // Hook the local player's deck so the command grid rebuilds live when a draft + // grant lands while the builder is already selected. The local deck may not + // exist for the first few frames (spawn race) — retried each Update until it does. + private void TrySubscribeDeck() + { + var deck = PlayerTowerDeck.Local; + if (deck == null) return; + deck.OnDeckChanged += HandleDeckChanged; + subscribedDeck = deck; + deckSubscribed = true; + // The deck may have populated before we subscribed — rebuild now so the + // grid reflects the current contents. + PopulateGridForSelection(SelectionState.Instance?.SelectedObject); + } + + private void HandleDeckChanged() + { + PopulateGridForSelection(SelectionState.Instance?.SelectedObject); + } + private void InitializeUI() { var doc = GetComponent(); @@ -374,6 +396,13 @@ namespace TD.UI MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged; matchStateSubscribed = false; } + + if (deckSubscribed && subscribedDeck != null) + { + subscribedDeck.OnDeckChanged -= HandleDeckChanged; + } + deckSubscribed = false; + subscribedDeck = null; } private void TrySubscribeSelection() @@ -394,6 +423,9 @@ namespace TD.UI if (!matchStateSubscribed) TrySubscribeMatchState(); + if (!deckSubscribed) + TrySubscribeDeck(); + RefreshGoldDisplay(); RefreshMatchStateDisplays(); UpdateBuildProgressIfShown(); @@ -763,14 +795,23 @@ namespace TD.UI } else { - int i = 0; - foreach (var (def, typeId) in placementManager.GetAvailableDefinitions()) + // Build buttons come from the LOCAL player's deck — the per-player + // unlocked set — not the global catalog. Each deck entry is a + // TowerTypeId resolved back to its definition via the catalog. The + // last grid slot is reserved for the Buffs button, so towers fill + // slots 0..GRID_MAX-2. + var deck = PlayerTowerDeck.Local; + if (deck != null) { - if (i >= GRID_MAX) break; - cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]); - i++; + int count = Mathf.Min(deck.Count, GRID_MAX - 1); + for (int i = 0; i < count; i++) + { + int typeId = deck.GetTypeIdAt(i); + var def = TowerPlacementManager.GetDefinition(typeId); + if (def != null) + cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]); + } } - } cells[GRID_MAX - 1] = CreateBuffMenuButton(HotkeyLayout[GRID_MAX - 1]); }