Merge pull request 'Adding new tower types and concept of tower deck' (#4) from feature/tower-deck into main
Reviewed-on: #4
This commit is contained in:
commit
3f3ad71741
16 changed files with 463 additions and 19 deletions
150
Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs
Normal file
150
Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs
|
||||
using System.Collections.Generic;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-player set of unlocked tower types — the player's "deck". Lives on the
|
||||
/// Player prefab alongside <see cref="PlayerGoldManager"/>,
|
||||
/// <see cref="PlayerMatchState"/>, and <see cref="PlayerBuffManager"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Roguelike keystone.</b> 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.</para>
|
||||
///
|
||||
/// <para><b>Identity.</b> The deck stores <c>TowerTypeId</c>s — indices into
|
||||
/// <see cref="TowerPlacementManager"/>'s catalog (<c>towerDefinitions[]</c>), the
|
||||
/// same identifier the placement RPC, <see cref="Builder"/>, and
|
||||
/// <see cref="BuildJob"/> already use. No new network identifier is introduced.</para>
|
||||
///
|
||||
/// <para><b>Authority.</b> Server-only mutation (<see cref="ServerInitialize"/> /
|
||||
/// <see cref="ServerGrantTower"/>). The owning client reads its own deck to build
|
||||
/// the HUD command grid; the server reads it to gate placement
|
||||
/// (<see cref="TowerPlacementManager"/> rejects a requested tower that isn't in the
|
||||
/// requesting player's deck — the anti-cheat seam).</para>
|
||||
///
|
||||
/// <para><b>Persistence caveat.</b> <c>TowerTypeId</c> 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 <c>TowerDefinition</c>).</para>
|
||||
/// </remarks>
|
||||
public class PlayerTowerDeck : NetworkBehaviour
|
||||
{
|
||||
// ----- Static registry (mirrors PlayerGoldManager) ----------------
|
||||
|
||||
private static readonly Dictionary<ulong, PlayerTowerDeck> s_byClientId
|
||||
= new Dictionary<ulong, PlayerTowerDeck>();
|
||||
|
||||
/// <summary>Returns the deck owned by the given client, or null.</summary>
|
||||
public static PlayerTowerDeck GetForClient(ulong clientId)
|
||||
{
|
||||
s_byClientId.TryGetValue(clientId, out var deck);
|
||||
return deck;
|
||||
}
|
||||
|
||||
/// <summary>Convenience: the local client's own deck. Null on a dedicated
|
||||
/// server or before the local player has spawned.</summary>
|
||||
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<int> unlockedTypeIds;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public event System.Action OnDeckChanged;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
unlockedTypeIds = new NetworkList<int>();
|
||||
}
|
||||
|
||||
// ----- 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<int> _) => OnDeckChanged?.Invoke();
|
||||
|
||||
// ----- Read API ---------------------------------------------------
|
||||
|
||||
/// <summary>Number of towers unlocked in this deck.</summary>
|
||||
public int Count => unlockedTypeIds.Count;
|
||||
|
||||
/// <summary>The TowerTypeId at the given deck slot (0..Count-1).</summary>
|
||||
public int GetTypeIdAt(int index) => unlockedTypeIds[index];
|
||||
|
||||
/// <summary>True if the given TowerTypeId is unlocked in this deck.</summary>
|
||||
public bool Contains(int towerTypeId)
|
||||
{
|
||||
for (int i = 0; i < unlockedTypeIds.Count; i++)
|
||||
if (unlockedTypeIds[i] == towerTypeId) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ----- Server mutation --------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: replace the deck with the given starting set. Called at match
|
||||
/// start (<see cref="WaveManager"/>) so Retry / return-to-lobby cycles reset
|
||||
/// the deck cleanly. Ignores ids <= 0 (the reserved catalog index) and
|
||||
/// silently de-dupes.
|
||||
/// </summary>
|
||||
public void ServerInitialize(IReadOnlyList<int> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool ServerGrantTower(int towerTypeId)
|
||||
{
|
||||
if (!IsServer) return false;
|
||||
if (towerTypeId <= 0) return false;
|
||||
if (Contains(towerTypeId)) return false;
|
||||
|
||||
unlockedTypeIds.Add(towerTypeId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs.meta
Normal file
11
Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b7e4c91a08f5d2461a3f8e6c25b09d74
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="startingDeck"/> assets into the TypeIds a <see cref="PlayerTowerDeck"/>
|
||||
/// stores.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the configured <see cref="startingDeck"/> 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.
|
||||
/// </summary>
|
||||
public List<int> GetStartingDeckTypeIds()
|
||||
{
|
||||
var ids = new List<int>();
|
||||
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
|
|||
/// <summary>The requested tower type ID is not in the server's definition list.</summary>
|
||||
InvalidTowerType,
|
||||
|
||||
/// <summary>The requested tower exists in the catalog but the placing player has
|
||||
/// not unlocked it in their deck. Draft it first.</summary>
|
||||
TowerNotInDeck,
|
||||
|
||||
/// <summary>An unexpected server-side error occurred (e.g., LevelLoader not loaded,
|
||||
/// client not mapped to a PlayerSlot). Check server logs.</summary>
|
||||
ServerError,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue