Compare commits

..

No commits in common. "08a3848bc079e7244e0d1780dee9be3350dbc5ce" and "04ead32846375c9eb3c72c50cf844552be01203f" have entirely different histories.

22 changed files with 64 additions and 710 deletions

View file

@ -1,9 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
<<<<<<< HEAD
guid: 01de85ee5d8a2014594d9910b1a6ff55 guid: 01de85ee5d8a2014594d9910b1a6ff55
=======
guid: cf294cbfb17f5a5d7846479a116fc7b3
>>>>>>> 31b0b5f (adding uncommited files (oops))
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}

View file

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

View file

@ -1,17 +0,0 @@
%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: b01f9aea62ee8a1c1991bb2a097de224, type: 3}
m_Name: ExtraDamage25
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuffDefinition
DisplayName: 25% Increased Damage
Stat: 0
Multiplier: 1.25

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 9ec170e540b1f91cca762dcebe81a856
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,18 +0,0 @@
%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: 516e8faec948a4b3d977ec019376c438, type: 3}
m_Name: OffensiveBuffs
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuffCategory
DisplayName: Offensive Buffs
Cost: 5
Pool:
- {fileID: 11400000, guid: 9ec170e540b1f91cca762dcebe81a856, type: 2}

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: d8ed3b9535538f7fc82be878cc307d26
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View file

@ -13,7 +13,6 @@ GameObject:
- component: {fileID: 2918837822014987993} - component: {fileID: 2918837822014987993}
- component: {fileID: 7845089877743661692} - component: {fileID: 7845089877743661692}
- component: {fileID: 4336209376377567030} - component: {fileID: 4336209376377567030}
- component: {fileID: 2806524246861401760}
m_Layer: 0 m_Layer: 0
m_Name: Player m_Name: Player
m_TagString: Untagged m_TagString: Untagged
@ -48,7 +47,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
GlobalObjectIdHash: 1552073510 GlobalObjectIdHash: 121878297
InScenePlacedSourceGlobalObjectIdHash: 0 InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0 DeferredDespawnTick: 0
Ownership: 1 Ownership: 1
@ -102,18 +101,3 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerMatchState m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerMatchState
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
--- !u!114 &2806524246861401760
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: 7775ee3a2f441b52480aabf54be6c1b6, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerBuffManager
ShowTopMostFoldoutHeaderGroup: 1
categories:
- {fileID: 11400000, guid: d8ed3b9535538f7fc82be878cc307d26, type: 2}

View file

@ -58,11 +58,6 @@ namespace TD.Combat
// Cached on OnNetworkSpawn — avoids GetComponent every Update. // Cached on OnNetworkSpawn — avoids GetComponent every Update.
private TowerInstance towerInstance; private TowerInstance towerInstance;
// Lazily resolved on first attack — the owner's slot isn't guaranteed to be
// set at spawn time, so we defer the lookup until combat actually begins.
private PlayerBuffManager ownerBuffManager;
private bool buffManagerResolved;
// Shared OverlapSphere result buffer. 32 covers any realistic enemy // Shared OverlapSphere result buffer. 32 covers any realistic enemy
// density; size up if profiling reveals overflow. // density; size up if profiling reveals overflow.
private static readonly Collider[] s_overlapBuffer = new Collider[32]; private static readonly Collider[] s_overlapBuffer = new Collider[32];
@ -216,25 +211,6 @@ namespace TD.Combat
ClearTarget(); ClearTarget();
} }
// ----- Buff multiplier lookup -------------------------------------
// Resolved lazily on the first attack because the owner slot NetworkVariable
// may not yet have replicated by the time OnNetworkSpawn runs.
private PlayerBuffManager GetOwnerBuffManager()
{
if (buffManagerResolved) return ownerBuffManager;
buffManagerResolved = true;
var slot = towerInstance.Owner;
if (slot == PlayerSlot.None) return null;
var matchState = PlayerMatchState.GetForSlot(slot);
if (matchState == null) return null;
ownerBuffManager = matchState.GetComponent<PlayerBuffManager>();
return ownerBuffManager;
}
// ----- Attack tick ------------------------------------------------- // ----- Attack tick -------------------------------------------------
private void TickAttack(TowerDefinition def) private void TickAttack(TowerDefinition def)
@ -242,9 +218,7 @@ namespace TD.Combat
attackCooldown -= Time.deltaTime; attackCooldown -= Time.deltaTime;
if (attackCooldown > 0f) return; if (attackCooldown > 0f) return;
float effectiveFireRate = def.FireRate attackCooldown = 1f / def.FireRate;
* (GetOwnerBuffManager()?.GetMultiplier(BuffStat.AttackSpeed) ?? 1f);
attackCooldown = 1f / effectiveFireRate;
Fire(def); Fire(def);
} }
@ -342,9 +316,7 @@ namespace TD.Combat
private void HitEnemy(TowerDefinition def, EnemyHealth target, PlayerSlot owner) private void HitEnemy(TowerDefinition def, EnemyHealth target, PlayerSlot owner)
{ {
float effectiveDamage = def.Damage target.TakeDamage(def.Damage, def.DamageType, owner);
* (GetOwnerBuffManager()?.GetMultiplier(BuffStat.Damage) ?? 1f);
target.TakeDamage(effectiveDamage, def.DamageType, owner);
ApplyStatusEffect(def, target, owner); ApplyStatusEffect(def, target, owner);
} }
@ -368,8 +340,6 @@ namespace TD.Combat
// ----- Projectile spawning ----------------------------------------- // ----- Projectile spawning -----------------------------------------
// TODO this seems wacky. Why do we calculate effective damage twice? Are they both used?
// Spawn projectile is void. Should it return a projectile?
private void SpawnProjectile(TowerDefinition def, EnemyHealth target) private void SpawnProjectile(TowerDefinition def, EnemyHealth target)
{ {
var go = Instantiate(def.ProjectilePrefab, transform.position, Quaternion.identity); var go = Instantiate(def.ProjectilePrefab, transform.position, Quaternion.identity);
@ -384,11 +354,9 @@ namespace TD.Combat
return; return;
} }
float effectiveDamage = def.Damage
* (GetOwnerBuffManager()?.GetMultiplier(BuffStat.Damage) ?? 1f);
proj.InitializeServer( proj.InitializeServer(
target, target,
effectiveDamage, def.Damage,
def.DamageType, def.DamageType,
def.TargetType, def.TargetType,
def.SplashRadius, def.SplashRadius,

View file

@ -1,12 +0,0 @@
// Assets/_Project/Scripts/Core/BuffStat.cs
namespace TD.Core
{
/// <summary>
/// Identifies which tower stat a <see cref="TD.Gameplay.BuffDefinition"/> modifies.
/// </summary>
public enum BuffStat : byte
{
Damage = 0,
AttackSpeed = 1,
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: fc84fec503f24bc7693ee29558f45d27

View file

@ -1,61 +0,0 @@
// Assets/_Project/Scripts/Gameplay/ActiveBuff.cs
using System;
using Unity.Collections;
using Unity.Netcode;
using TD.Core;
namespace TD.Gameplay
{
/// <summary>
/// One buff currently held by a player. Stored in
/// <see cref="PlayerBuffManager"/>'s NetworkList so all peers see the same set.
/// </summary>
/// <remarks>
/// <para><b>IEquatable.</b> NGO's NetworkList indexer setter short-circuits when
/// Equals returns true, silently dropping the write. Every mutable field
/// (only <see cref="IsActive"/>) is included in the comparison so toggling a
/// buff correctly propagates to clients.</para>
/// </remarks>
public struct ActiveBuff : INetworkSerializable, IEquatable<ActiveBuff>
{
/// <summary>Which tower stat this buff multiplies.</summary>
public BuffStat Stat;
/// <summary>Multiplicative factor, e.g. 1.25 for +25%.</summary>
public float Multiplier;
/// <summary>
/// False when disabled (e.g. by an enemy ability). Excluded from
/// <see cref="PlayerBuffManager.GetMultiplier"/> while false.
/// </summary>
public bool IsActive;
/// <summary>Human-readable name for the HUD buff list.</summary>
public FixedString64Bytes DisplayName;
// ----- INetworkSerializable ---------------------------------------
public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
{
byte statByte = (byte)Stat;
serializer.SerializeValue(ref statByte);
Stat = (BuffStat)statByte;
serializer.SerializeValue(ref Multiplier);
serializer.SerializeValue(ref IsActive);
serializer.SerializeValue(ref DisplayName);
}
// ----- IEquatable -------------------------------------------------
public bool Equals(ActiveBuff other)
=> Stat == other.Stat
&& Multiplier == other.Multiplier
&& IsActive == other.IsActive
&& DisplayName == other.DisplayName;
public override bool Equals(object obj) => obj is ActiveBuff other && Equals(other);
public override int GetHashCode() => HashCode.Combine((int)Stat, Multiplier, IsActive);
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: b09203f8acc450bf3b3c4bd8ef3fe84e

View file

@ -1,23 +0,0 @@
// Assets/_Project/Scripts/Gameplay/BuffCategory.cs
using UnityEngine;
namespace TD.Gameplay
{
/// <summary>
/// A purchasable category of buffs. The player pays <see cref="Cost"/> gold
/// and receives a randomly chosen <see cref="BuffDefinition"/> from <see cref="Pool"/>.
/// </summary>
[CreateAssetMenu(fileName = "BuffCategory", menuName = "TD/Buffs/Buff Category")]
public class BuffCategory : ScriptableObject
{
[Tooltip("Name shown on the buff menu purchase button.")]
public string DisplayName;
[Tooltip("Gold cost to purchase one random buff from this category.")]
[Min(0)]
public int Cost;
[Tooltip("Set of buffs the player can receive. One is chosen uniformly at random.")]
public BuffDefinition[] Pool;
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 516e8faec948a4b3d977ec019376c438

View file

@ -1,24 +0,0 @@
// Assets/_Project/Scripts/Gameplay/BuffDefinition.cs
using UnityEngine;
using TD.Core;
namespace TD.Gameplay
{
/// <summary>
/// One possible buff that can be awarded to a player. Lives in a
/// <see cref="BuffCategory"/>'s pool and is drawn randomly on purchase.
/// </summary>
[CreateAssetMenu(fileName = "BuffDefinition", menuName = "TD/Buffs/Buff Definition")]
public class BuffDefinition : ScriptableObject
{
[Tooltip("Name shown in the buff menu and tooltip.")]
public string DisplayName;
[Tooltip("Which tower stat this buff multiplies.")]
public BuffStat Stat;
[Tooltip("Multiplicative factor applied to the stat. 1.25 = +25%.")]
[Min(1f)]
public float Multiplier = 1.25f;
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: b01f9aea62ee8a1c1991bb2a097de224

View file

@ -149,14 +149,6 @@ namespace TD.Gameplay
// Right-click. Suppressed entirely during a modal mode (placement/paint // Right-click. Suppressed entirely during a modal mode (placement/paint
// controllers handle right-click as cancel there) and when over HUD. // controllers handle right-click as cancel there) and when over HUD.
if (isModal) return; if (isModal) return;
// B: toggle buff menu.
if (keyboard != null && keyboard.bKey.wasPressedThisFrame
&& !HUDController.IsTextInputActive)
{
HUDController.Instance?.ToggleBuffMenu();
}
if (pointerOverHud) return; if (pointerOverHud) return;
if (!mouse.rightButton.wasPressedThisFrame) return; if (!mouse.rightButton.wasPressedThisFrame) return;

View file

@ -1,195 +0,0 @@
// Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs
using System.Collections.Generic;
using Unity.Collections;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
namespace TD.Gameplay
{
/// <summary>
/// Holds all buffs currently owned by one player. Lives on the Player prefab
/// alongside <see cref="PlayerGoldManager"/> and <see cref="PlayerMatchState"/>.
/// </summary>
/// <remarks>
/// <para><b>Purchase flow.</b> The owning client calls
/// <see cref="RequestPurchaseBuffRpc"/> with a category index. The server
/// validates gold, deducts the cost, picks a random
/// <see cref="BuffDefinition"/> from the category's pool, and appends an
/// <see cref="ActiveBuff"/> to the replicated <see cref="buffs"/> list.</para>
///
/// <para><b>Multiplier query.</b> <see cref="GetMultiplier"/> is called by
/// <see cref="TD.Combat.TowerCombat"/> on the server each time a tower fires.
/// It multiplies all active buffs for the requested stat together.</para>
///
/// <para><b>Enable / disable.</b> Enemies can call <see cref="ServerDisableBuff"/>
/// and <see cref="ServerEnableBuff"/> by index to temporarily suppress buffs
/// without removing them from the list.</para>
/// </remarks>
public class PlayerBuffManager : NetworkBehaviour
{
// ----- Static registry (mirrors PlayerGoldManager pattern) -----------
private static readonly Dictionary<ulong, PlayerBuffManager> s_byClientId
= new Dictionary<ulong, PlayerBuffManager>();
/// <summary>Returns the PlayerBuffManager owned by the given client, or null.</summary>
public static PlayerBuffManager GetForClient(ulong clientId)
{
s_byClientId.TryGetValue(clientId, out var mgr);
return mgr;
}
/// <summary>Convenience: the local client's own buff manager.</summary>
public static PlayerBuffManager Local
{
get
{
var nm = NetworkManager.Singleton;
if (nm == null) return null;
return GetForClient(nm.LocalClientId);
}
}
// ----- Inspector ------------------------------------------------------
[Tooltip("Purchasable buff categories shown in the buff menu. Index in this " +
"array is the categoryIndex passed to RequestPurchaseBuffRpc.")]
[SerializeField] private BuffCategory[] categories;
private NetworkList<ActiveBuff> buffs;
private void Awake()
{
buffs = new NetworkList<ActiveBuff>();
}
// ----- NGO lifecycle ----------------------------------------------
public override void OnNetworkSpawn()
{
s_byClientId[OwnerClientId] = this;
}
public override void OnNetworkDespawn()
{
if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
s_byClientId.Remove(OwnerClientId);
}
// ----- Public API -------------------------------------------------
/// <summary>
/// Returns the combined multiplier for <paramref name="stat"/> across all
/// active buffs owned by this player. Returns 1.0 if no matching buffs
/// are active. Safe to call from any peer.
/// </summary>
public float GetMultiplier(BuffStat stat)
{
float result = 1f;
for (int i = 0; i < buffs.Count; i++)
{
var buff = buffs[i];
if (buff.Stat == stat && buff.IsActive)
result *= buff.Multiplier;
}
return result;
}
/// <summary>
/// Returns the number of buff categories available, for building the UI.
/// </summary>
public int CategoryCount => categories?.Length ?? 0;
/// <summary>Returns the category at the given index, or null.</summary>
public BuffCategory GetCategory(int index)
=> (categories != null && index >= 0 && index < categories.Length)
? categories[index]
: null;
/// <summary>Read-only access to the buff list for UI rendering.</summary>
public NetworkList<ActiveBuff> Buffs => buffs;
// ----- Server helpers ---------------------------------------------
/// <summary>
/// Server-only: disables the buff at <paramref name="index"/>.
/// The buff remains in the list and can be re-enabled.
/// </summary>
public void ServerDisableBuff(int index)
{
if (!IsServer) return;
if (index < 0 || index >= buffs.Count) return;
var b = buffs[index];
if (!b.IsActive) return;
b.IsActive = false;
buffs[index] = b;
}
/// <summary>Server-only: re-enables the buff at <paramref name="index"/>.</summary>
public void ServerEnableBuff(int index)
{
if (!IsServer) return;
if (index < 0 || index >= buffs.Count) return;
var b = buffs[index];
if (b.IsActive) return;
b.IsActive = true;
buffs[index] = b;
}
// ----- Purchase RPC -----------------------------------------------
/// <summary>
/// Owning-client entry point. Sends a request to the server to purchase
/// a random buff from the category at <paramref name="categoryIndex"/>.
/// The server validates gold and adds the buff if the purchase succeeds.
/// </summary>
[Rpc(SendTo.Server, RequireOwnership = true)]
public void RequestPurchaseBuffRpc(int categoryIndex)
{
if (categories == null || categoryIndex < 0 || categoryIndex >= categories.Length)
{
Debug.LogWarning("[PlayerBuffManager] RequestPurchaseBuff: invalid category index.");
return;
}
var category = categories[categoryIndex];
if (category == null || category.Pool == null || category.Pool.Length == 0)
{
Debug.LogWarning("[PlayerBuffManager] RequestPurchaseBuff: category has no buffs.");
return;
}
var goldManager = PlayerGoldManager.GetForClient(OwnerClientId);
if (goldManager == null)
{
Debug.LogError("[PlayerBuffManager] RequestPurchaseBuff: no PlayerGoldManager found.");
return;
}
if (goldManager.CurrentGold < category.Cost)
{
Debug.Log($"[PlayerBuffManager] Client {OwnerClientId} cannot afford " +
$"'{category.DisplayName}' (cost {category.Cost}, " +
$"gold {goldManager.CurrentGold}).");
return;
}
goldManager.DeductGold(category.Cost);
var def = category.Pool[Random.Range(0, category.Pool.Length)];
buffs.Add(new ActiveBuff
{
Stat = def.Stat,
Multiplier = def.Multiplier,
IsActive = true,
DisplayName = new FixedString64Bytes(def.DisplayName ?? string.Empty),
});
Debug.Log($"[PlayerBuffManager] Client {OwnerClientId} purchased buff " +
$"'{def.DisplayName}' ({def.Stat} ×{def.Multiplier}).");
}
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7775ee3a2f441b52480aabf54be6c1b6

View file

@ -21,60 +21,55 @@ namespace TD.UI
[RequireComponent(typeof(UIDocument))] [RequireComponent(typeof(UIDocument))]
public class HUDController : MonoBehaviour public class HUDController : MonoBehaviour
{ {
public static HUDController Instance { get; private set; }
// ----- Inspector -------------------------------------------------- // ----- Inspector --------------------------------------------------
[Header("Scene References")] [Tooltip("The local client's TowerPlacementController.")] [SerializeField] [Header("Scene References")]
private TowerPlacementController placementController; [Tooltip("The local client's TowerPlacementController.")]
[SerializeField] private TowerPlacementController placementController;
[Tooltip("The local client's TowerPaintController (drives the Paint tab + paint cursor).")] [Tooltip("The local client's TowerPaintController (drives the Paint tab + paint cursor).")]
[SerializeField] private TowerPaintController paintController; [SerializeField] private TowerPaintController paintController;
[Tooltip("The TowerPlacementManager NetworkObject in the scene.")] [SerializeField] [Tooltip("The TowerPlacementManager NetworkObject in the scene.")]
private TowerPlacementManager placementManager; [SerializeField] private TowerPlacementManager placementManager;
[Tooltip("The local client's CameraController. Used by the minimap for click-to-jump " + [Tooltip("The local client's CameraController. Used by the minimap for click-to-jump " +
"and drag-to-pan.")] "and drag-to-pan.")]
[SerializeField] [SerializeField] private CameraController cameraController;
private CameraController cameraController;
[Header("Settings")] [SerializeField] private float rejectionMessageDuration = 2.5f; [Header("Settings")]
[SerializeField] private float rejectionMessageDuration = 2.5f;
[Tooltip("Maximum visible height of the chat feed in pixels. Content past this " + [Tooltip("Maximum visible height of the chat feed in pixels. Content past this " +
"height is clipped — older messages scroll off the top of the visible area " + "height is clipped — older messages scroll off the top of the visible area " +
"but stay in history (scroll up while chat is open to view).")] "but stay in history (scroll up while chat is open to view).")]
[SerializeField] [SerializeField] private float chatMaxHeight = 280f;
private float chatMaxHeight = 280f;
[Tooltip("Maximum messages kept in chat history. Defaults to effectively unlimited " + [Tooltip("Maximum messages kept in chat history. Defaults to effectively unlimited " +
"(int.MaxValue) — every message sent during a match stays scrollable. " + "(int.MaxValue) — every message sent during a match stays scrollable. " +
"Lower the value if a long match ever shows DOM perf issues; this field " + "Lower the value if a long match ever shows DOM perf issues; this field " +
"is the safety valve, not a normal-play limit.")] "is the safety valve, not a normal-play limit.")]
[SerializeField] [SerializeField] private int chatMaxMessages = int.MaxValue;
private int chatMaxMessages = int.MaxValue;
[Tooltip("Color used for SYSTEM chat messages (e.g. 'Life Lost', income changes).")] [SerializeField] [Tooltip("Color used for SYSTEM chat messages (e.g. 'Life Lost', income changes).")]
private Color chatSystemColor = new Color(1f, 0.7f, 0.2f); [SerializeField] private Color chatSystemColor = new Color(1f, 0.7f, 0.2f);
[Tooltip("Color used for PLAYER chat message bodies. Sender prefix uses the player's slot color.")] [Tooltip("Color used for PLAYER chat message bodies. Sender prefix uses the player's slot color.")]
[SerializeField] [SerializeField] private Color chatPlayerColor = new Color(0.92f, 0.92f, 0.92f);
private Color chatPlayerColor = new Color(0.92f, 0.92f, 0.92f);
// ----- Cached UI element references ------------------------------- // ----- Cached UI element references -------------------------------
private Label goldLabel; private Label goldLabel;
private Label waveLabel; private Label waveLabel;
private Label livesLabel; private Label livesLabel;
private Label nextWaveLabel; // prep countdown ("next: 0:12") private Label nextWaveLabel; // prep countdown ("next: 0:12")
private Label leakedLabel; // local player's origin-leak count ("leaked: 3") private Label leakedLabel; // local player's origin-leak count ("leaked: 3")
private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave") private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave")
private VisualElement playerListContainer; // right-panel scoreboard rows private VisualElement playerListContainer; // right-panel scoreboard rows
private Label portraitName; private Label portraitName;
private Label levelLabel; private Label levelLabel;
private VisualElement statLines; private VisualElement statLines;
private VisualElement commandGrid; private VisualElement commandGrid;
private VisualElement actionFrame; // hidden via display:none when no actions are available private VisualElement actionFrame; // hidden via display:none when no actions are available
private VisualElement commandTabs; // Build/Paint tab row — shown only for a Builder selection private VisualElement commandTabs; // Build/Paint tab row — shown only for a Builder selection
private Button tabBuild; private Button tabBuild;
@ -82,7 +77,6 @@ namespace TD.UI
private VisualElement buildProgressContainer; // info-panel sub-view, shown for BuildSiteVisual selections private VisualElement buildProgressContainer; // info-panel sub-view, shown for BuildSiteVisual selections
private VisualElement buildProgressFill; // width driven each frame from progress private VisualElement buildProgressFill; // width driven each frame from progress
private Label buildProgressPercent; private Label buildProgressPercent;
private Label ttTitle; private Label ttTitle;
private Label ttDesc; private Label ttDesc;
private Label ttStats; private Label ttStats;
@ -95,23 +89,19 @@ namespace TD.UI
// without rebuilding the elements. // without rebuilding the elements.
private VisualElement enemyHealthBar; private VisualElement enemyHealthBar;
private VisualElement enemyHealthFill; private VisualElement enemyHealthFill;
private Label enemyHealthText; private Label enemyHealthText;
// Match-end overlay — built once on Start and toggled on Phase changes. // Match-end overlay — built once on Start and toggled on Phase changes.
private VisualElement matchEndOverlay; private VisualElement matchEndOverlay;
private Label matchEndTitle;
// Buff menu overlay — toggled by the B key via ToggleBuffMenu().
private VisualElement buffMenuOverlay;
private VisualElement buffMenuContent;
private Label matchEndTitle;
// Chat panel (bottom-left, above portrait) — programmatic. The container // Chat panel (bottom-left, above portrait) — programmatic. The container
// holds both the scrollable feed and the input. Highlight + scroll // holds both the scrollable feed and the input. Highlight + scroll
// interactivity are toggled on the container when typing. // interactivity are toggled on the container when typing.
private VisualElement chatContainer; private VisualElement chatContainer;
private ScrollView chatFeed; private ScrollView chatFeed;
private TextField chatInput; private TextField chatInput;
private bool chatInputOpen; private bool chatInputOpen;
// Frame on which the chat input was opened or closed. Enter on that frame // Frame on which the chat input was opened or closed. Enter on that frame
// and the next one is ignored to prevent the open/close-triggering keypress // and the next one is ignored to prevent the open/close-triggering keypress
@ -133,12 +123,12 @@ namespace TD.UI
private CommandTab activeTab = CommandTab.Build; private CommandTab activeTab = CommandTab.Build;
private Coroutine rejectionFadeCoroutine; private Coroutine rejectionFadeCoroutine;
private bool placementManagerReady; // true once TowerPlacementManager.Instance is non-null private bool placementManagerReady; // true once TowerPlacementManager.Instance is non-null
private bool uiInitialized; private bool uiInitialized;
private bool selectionSubscribed; // true once we've successfully hooked SelectionState.OnSelectionChanged private bool selectionSubscribed; // true once we've successfully hooked SelectionState.OnSelectionChanged
private bool matchStateSubscribed; // true once OnPhaseChanged is hooked private bool matchStateSubscribed; // true once OnPhaseChanged is hooked
private MinimapView minimapView; private MinimapView minimapView;
private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us
// ----- Hotkeys ---------------------------------------------------- // ----- Hotkeys ----------------------------------------------------
// //
@ -160,15 +150,10 @@ namespace TD.UI
private readonly struct HotkeyBinding private readonly struct HotkeyBinding
{ {
public readonly Key Key; public readonly Key Key;
public readonly VisualElement Button; // for enabledSelf gating public readonly VisualElement Button; // for enabledSelf gating
public readonly System.Action Action; public readonly System.Action Action;
public HotkeyBinding(Key k, VisualElement b, System.Action a) public HotkeyBinding(Key k, VisualElement b, System.Action a)
{ { Key = k; Button = b; Action = a; }
Key = k;
Button = b;
Action = a;
}
} }
// ----- Static hit-test probe -------------------------------------- // ----- Static hit-test probe --------------------------------------
@ -264,7 +249,6 @@ namespace TD.UI
// Cache element references — log a warning for any that are missing // Cache element references — log a warning for any that are missing
// so UXML/USS mismatches surface immediately. // so UXML/USS mismatches surface immediately.
goldLabel = Require<Label>(root, "gold-label"); goldLabel = Require<Label>(root, "gold-label");
waveLabel = Require<Label>(root, "wave-label"); waveLabel = Require<Label>(root, "wave-label");
livesLabel = Require<Label>(root, "lives-label"); livesLabel = Require<Label>(root, "lives-label");
@ -283,15 +267,14 @@ namespace TD.UI
if (tabBuild != null) tabBuild.clicked += () => SwitchTab(CommandTab.Build); if (tabBuild != null) tabBuild.clicked += () => SwitchTab(CommandTab.Build);
if (tabPaint != null) tabPaint.clicked += () => SwitchTab(CommandTab.Paint); if (tabPaint != null) tabPaint.clicked += () => SwitchTab(CommandTab.Paint);
buildProgressContainer = Require<VisualElement>(root, "build-progress"); buildProgressContainer = Require<VisualElement>(root, "build-progress");
buildProgressFill = Require<VisualElement>(root, "build-progress-fill"); buildProgressFill = Require<VisualElement>(root, "build-progress-fill");
buildProgressPercent = Require<Label>(root, "build-progress-percent"); buildProgressPercent = Require<Label>(root, "build-progress-percent");
ttTitle = Require<Label>(root, "tt-title"); ttTitle = Require<Label>(root, "tt-title");
ttDesc = Require<Label>(root, "tt-desc"); ttDesc = Require<Label>(root, "tt-desc");
ttStats = Require<Label>(root, "tt-stats"); ttStats = Require<Label>(root, "tt-stats");
ttCost = Require<Label>(root, "tt-cost"); ttCost = Require<Label>(root, "tt-cost");
rejectionLabel = Require<Label>(root, "rejection-label"); rejectionLabel = Require<Label>(root, "rejection-label");
// Map area and its transparent ancestors must not consume pointer // Map area and its transparent ancestors must not consume pointer
// events so clicks reach the 3D scene underneath. The bottom-ui is now // events so clicks reach the 3D scene underneath. The bottom-ui is now
@ -326,9 +309,6 @@ namespace TD.UI
// MatchState.OnPhaseChanged fires Victory or Defeat. // MatchState.OnPhaseChanged fires Victory or Defeat.
BuildMatchEndOverlay(root); BuildMatchEndOverlay(root);
// Build the buff menu overlay. Hidden until the player presses B.
BuildBuffMenuOverlay(root);
// Chat feed + input. Anchored bottom-left, just above the portrait/bottom-ui bar. // 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 // Player typing toggled with Enter; system messages (e.g. life lost) post via
// ChatService.PostLocalSystem on every peer. // ChatService.PostLocalSystem on every peer.
@ -343,16 +323,11 @@ namespace TD.UI
uiInitialized = true; uiInitialized = true;
} }
private void Awake()
{
Instance = this;
}
private void OnEnable() private void OnEnable()
{ {
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage; TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
WaveManager.OnLifeLost += HandleLifeLost; WaveManager.OnLifeLost += HandleLifeLost;
ChatService.OnMessageReceived += HandleChatMessage; ChatService.OnMessageReceived += HandleChatMessage;
// Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does // Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does
// not guarantee Awake/OnEnable ordering across objects), Start will retry. // not guarantee Awake/OnEnable ordering across objects), Start will retry.
TrySubscribeSelection(); TrySubscribeSelection();
@ -361,14 +336,13 @@ namespace TD.UI
private void OnDisable() private void OnDisable()
{ {
TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage; TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
WaveManager.OnLifeLost -= HandleLifeLost; WaveManager.OnLifeLost -= HandleLifeLost;
ChatService.OnMessageReceived -= HandleChatMessage; ChatService.OnMessageReceived -= HandleChatMessage;
if (selectionSubscribed && SelectionState.Instance != null) if (selectionSubscribed && SelectionState.Instance != null)
{ {
SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged; SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged;
selectionSubscribed = false; selectionSubscribed = false;
} }
if (matchStateSubscribed && MatchState.Instance != null) if (matchStateSubscribed && MatchState.Instance != null)
{ {
MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged; MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged;
@ -433,7 +407,6 @@ namespace TD.UI
buildProgressFill.style.width = buildProgressFill.style.width =
new StyleLength(new Length(progress * 100f, LengthUnit.Percent)); new StyleLength(new Length(progress * 100f, LengthUnit.Percent));
} }
if (buildProgressPercent != null) if (buildProgressPercent != null)
{ {
buildProgressPercent.text = $"{Mathf.RoundToInt(progress * 100f)}%"; buildProgressPercent.text = $"{Mathf.RoundToInt(progress * 100f)}%";
@ -464,8 +437,6 @@ namespace TD.UI
private void OnDestroy() private void OnDestroy()
{ {
if (Instance == this) Instance = null;
minimapView?.Dispose(); minimapView?.Dispose();
minimapView = null; minimapView = null;
@ -621,8 +592,8 @@ namespace TD.UI
nameLabel.style.color = PlayerColors.Get(pms.Slot); nameLabel.style.color = PlayerColors.Get(pms.Slot);
nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold; nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
nameLabel.style.fontSize = 10; nameLabel.style.fontSize = 10;
nameLabel.style.whiteSpace = WhiteSpace.NoWrap; // keep name on one line nameLabel.style.whiteSpace = WhiteSpace.NoWrap; // keep name on one line
nameLabel.style.flexGrow = 1; // takes all leftover width nameLabel.style.flexGrow = 1; // takes all leftover width
nameLabel.style.flexShrink = 1; nameLabel.style.flexShrink = 1;
nameLabel.style.overflow = Overflow.Hidden; nameLabel.style.overflow = Overflow.Hidden;
nameLabel.style.textOverflow = TextOverflow.Ellipsis; nameLabel.style.textOverflow = TextOverflow.Ellipsis;
@ -666,7 +637,6 @@ namespace TD.UI
// gated by the lobby), but the values do. // gated by the lobby), but the values do.
private static readonly System.Text.StringBuilder s_scoreboardSigBuf = private static readonly System.Text.StringBuilder s_scoreboardSigBuf =
new System.Text.StringBuilder(64); new System.Text.StringBuilder(64);
private string ComputeScoreboardSignature(System.Collections.Generic.List<PlayerMatchState> players) private string ComputeScoreboardSignature(System.Collections.Generic.List<PlayerMatchState> players)
{ {
s_scoreboardSigBuf.Clear(); s_scoreboardSigBuf.Clear();
@ -676,14 +646,12 @@ namespace TD.UI
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId); var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
int gold = gm != null ? gm.CurrentGold : 0; int gold = gm != null ? gm.CurrentGold : 0;
int leaks = (wm != null && pms.Slot != PlayerSlot.None) int leaks = (wm != null && pms.Slot != PlayerSlot.None)
? wm.GetZoneLeakCount(pms.Slot) ? wm.GetZoneLeakCount(pms.Slot) : 0;
: 0;
s_scoreboardSigBuf.Append((int)pms.Slot).Append(':') s_scoreboardSigBuf.Append((int)pms.Slot).Append(':')
.Append(pms.DisplayName ?? string.Empty).Append(':') .Append(pms.DisplayName ?? string.Empty).Append(':')
.Append(gold).Append(':') .Append(gold).Append(':')
.Append(leaks).Append(';'); .Append(leaks).Append(';');
} }
return s_scoreboardSigBuf.ToString(); return s_scoreboardSigBuf.ToString();
} }
@ -691,7 +659,7 @@ namespace TD.UI
private const int GRID_COLS = 5; private const int GRID_COLS = 5;
private const int GRID_ROWS = 3; private const int GRID_ROWS = 3;
private const int GRID_MAX = GRID_COLS * GRID_ROWS; private const int GRID_MAX = GRID_COLS * GRID_ROWS;
// First-time check that TowerPlacementManager exists. Once ready, populates the // First-time check that TowerPlacementManager exists. Once ready, populates the
// grid for the current selection. Subsequent populates flow through // grid for the current selection. Subsequent populates flow through
@ -701,7 +669,7 @@ namespace TD.UI
if (placementManager == null) if (placementManager == null)
placementManager = TowerPlacementManager.Instance; placementManager = TowerPlacementManager.Instance;
if (placementManager == null) return; // not spawned yet — retry next frame if (placementManager == null) return; // not spawned yet — retry next frame
placementManagerReady = true; placementManagerReady = true;
PopulateGridForSelection(SelectionState.Instance?.SelectedObject); PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
@ -716,7 +684,7 @@ namespace TD.UI
private void PopulateGridForSelection(ISelectable selection) private void PopulateGridForSelection(ISelectable selection)
{ {
if (commandGrid == null) return; if (commandGrid == null) return;
if (!placementManagerReady) return; // deferred to TryReadyPlacementManager if (!placementManagerReady) return; // deferred to TryReadyPlacementManager
// Selection changed — invalidate the previous frame's hotkey bindings // Selection changed — invalidate the previous frame's hotkey bindings
// before creating new ones. Without this, stale buttons from a previous // before creating new ones. Without this, stale buttons from a previous
@ -726,8 +694,8 @@ namespace TD.UI
// Decide whether any actions exist for this selection. The action frame // Decide whether any actions exist for this selection. The action frame
// is hidden entirely when there are none — matches the WC3-style UX. // is hidden entirely when there are none — matches the WC3-style UX.
bool hasActions = selection is Builder bool hasActions = selection is Builder
|| selection is TowerInstance || selection is TowerInstance
|| selection is BuildSiteVisual; || selection is BuildSiteVisual;
if (actionFrame != null) if (actionFrame != null)
actionFrame.style.display = hasActions ? DisplayStyle.Flex : DisplayStyle.None; actionFrame.style.display = hasActions ? DisplayStyle.Flex : DisplayStyle.None;
@ -745,7 +713,7 @@ namespace TD.UI
RefreshTabActiveState(); RefreshTabActiveState();
commandGrid.Clear(); commandGrid.Clear();
if (!hasActions) return; // grid stays empty; frame is hidden anyway if (!hasActions) return; // grid stays empty; frame is hidden anyway
// Build the 15-cell action layout for the active selection kind. // Build the 15-cell action layout for the active selection kind.
var cells = new VisualElement[GRID_MAX]; var cells = new VisualElement[GRID_MAX];
@ -770,14 +738,12 @@ namespace TD.UI
cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]); cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]);
i++; i++;
} }
} }
cells[GRID_MAX - 1] = CreateBuffMenuButton(HotkeyLayout[GRID_MAX - 1]);
} }
else if (selection is TowerInstance tower) else if (selection is TowerInstance tower)
{ {
// WC3 layout convention: primary action top-left (Q), sell bottom-right (B). // WC3 layout convention: primary action top-left (Q), sell bottom-right (B).
cells[0] = CreateUpgradeButton(tower, HotkeyLayout[0]); cells[0] = CreateUpgradeButton(tower, HotkeyLayout[0]);
cells[GRID_MAX - 1] = CreateSellButton(tower, HotkeyLayout[GRID_MAX - 1]); cells[GRID_MAX - 1] = CreateSellButton(tower, HotkeyLayout[GRID_MAX - 1]);
} }
else if (selection is BuildSiteVisual bsv) else if (selection is BuildSiteVisual bsv)
@ -926,7 +892,6 @@ namespace TD.UI
costLabel.pickingMode = PickingMode.Ignore; costLabel.pickingMode = PickingMode.Ignore;
btn.Add(costLabel); btn.Add(costLabel);
} }
return btn; return btn;
} }
@ -936,12 +901,9 @@ namespace TD.UI
private VisualElement CreateUpgradeButton(TowerInstance tower, Key hotkey) private VisualElement CreateUpgradeButton(TowerInstance tower, Key hotkey)
{ {
var btn = CreateActionButton( var btn = CreateActionButton(
costText: "", // tier cost unknown until upgrade system lands costText: "", // tier cost unknown until upgrade system lands
hotkey: hotkey, hotkey: hotkey,
onClick: () => onClick: () => { /* TODO: upgrade flow */ });
{
/* TODO: upgrade flow */
});
btn.SetEnabled(false); btn.SetEnabled(false);
return btn; return btn;
} }
@ -953,23 +915,12 @@ namespace TD.UI
: 0; : 0;
var btn = CreateActionButton( var btn = CreateActionButton(
costText: sellValue > 0 ? $"+{sellValue}g" : "", costText: sellValue > 0 ? $"+{sellValue}g" : "",
hotkey: hotkey, hotkey: hotkey,
onClick: () => onClick: () => { /* TODO: sell flow */ });
{
/* TODO: sell flow */
});
btn.SetEnabled(false); btn.SetEnabled(false);
return btn; return btn;
} }
private VisualElement CreateBuffMenuButton(Key hotkey)
{
return CreateActionButton(
costText: "Buffs",
hotkey: hotkey,
onClick: () => ToggleBuffMenu());
}
// Cancel action for an in-progress build. Fires the owner-only RPC; the // Cancel action for an in-progress build. Fires the owner-only RPC; the
// server cancels the matching job (or, for shelved sites, refunds + despawns // server cancels the matching job (or, for shelved sites, refunds + despawns
// directly), full gold is refunded, the BuildSiteVisual is despawned, and // directly), full gold is refunded, the BuildSiteVisual is despawned, and
@ -1257,149 +1208,6 @@ namespace TD.UI
// ----- Chat feed + input ----------------------------------------- // ----- Chat feed + input -----------------------------------------
// ----- Buff menu overlay ------------------------------------------
private void BuildBuffMenuOverlay(VisualElement root)
{
buffMenuOverlay = new VisualElement();
buffMenuOverlay.style.position = Position.Absolute;
buffMenuOverlay.style.left = 0;
buffMenuOverlay.style.right = 0;
buffMenuOverlay.style.top = 0;
buffMenuOverlay.style.bottom = 0;
buffMenuOverlay.style.alignItems = Align.Center;
buffMenuOverlay.style.justifyContent = Justify.Center;
buffMenuOverlay.style.backgroundColor = new Color(0f, 0f, 0f, 0.5f);
buffMenuOverlay.style.display = DisplayStyle.None;
buffMenuOverlay.pickingMode = PickingMode.Position;
var panel = new VisualElement();
panel.style.minWidth = 340;
panel.style.paddingTop = 20;
panel.style.paddingBottom = 20;
panel.style.paddingLeft = 28;
panel.style.paddingRight = 28;
panel.style.backgroundColor = new Color(0.08f, 0.08f, 0.10f, 0.95f);
panel.style.borderTopWidth = panel.style.borderBottomWidth =
panel.style.borderLeftWidth = panel.style.borderRightWidth = 2;
var border = new Color(0.4f, 0.4f, 0.45f);
panel.style.borderTopColor = panel.style.borderBottomColor =
panel.style.borderLeftColor = panel.style.borderRightColor = border;
var title = new Label("Buffs");
title.style.fontSize = 24;
title.style.color = Color.white;
title.style.unityFontStyleAndWeight = FontStyle.Bold;
title.style.marginBottom = 12;
panel.Add(title);
// Scrollable content area: current buffs + purchase buttons.
// Rebuilt each time the menu is shown via RefreshBuffMenuContent().
buffMenuContent = new VisualElement();
panel.Add(buffMenuContent);
var closeBtn = new Button(() => SetBuffMenuVisible(false)) { text = "Close [B]" };
closeBtn.style.marginTop = 16;
closeBtn.style.height = 32;
panel.Add(closeBtn);
buffMenuOverlay.Add(panel);
root.Add(buffMenuOverlay);
}
/// <summary>
/// Toggles the buff menu overlay. Called by <see cref="TD.Gameplay.BuilderInputController"/>
/// when the player presses B.
/// </summary>
public void ToggleBuffMenu()
{
bool nowVisible = buffMenuOverlay?.style.display == DisplayStyle.None;
SetBuffMenuVisible(nowVisible);
}
private void SetBuffMenuVisible(bool visible)
{
if (buffMenuOverlay == null) return;
buffMenuOverlay.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None;
if (visible) RefreshBuffMenuContent();
}
private void RefreshBuffMenuContent()
{
if (buffMenuContent == null) return;
buffMenuContent.Clear();
var buffManager = TD.Gameplay.PlayerBuffManager.Local;
// Current buffs section.
var buffsHeader = new Label("Active Buffs");
buffsHeader.style.color = new Color(0.7f, 0.9f, 0.7f);
buffsHeader.style.fontSize = 14;
buffsHeader.style.marginBottom = 4;
buffMenuContent.Add(buffsHeader);
if (buffManager == null || buffManager.Buffs.Count == 0)
{
var none = new Label("None");
none.style.color = new Color(0.55f, 0.55f, 0.55f);
none.style.marginBottom = 8;
buffMenuContent.Add(none);
}
else
{
for (int i = 0; i < buffManager.Buffs.Count; i++)
{
var buff = buffManager.Buffs[i];
string statName = buff.Stat == TD.Core.BuffStat.Damage ? "Damage" : "Attack Speed";
string activeTag = buff.IsActive ? "" : " [DISABLED]";
var row = new Label($"• {buff.DisplayName} ({statName} ×{buff.Multiplier:F2}){activeTag}");
row.style.color = buff.IsActive ? Color.white : new Color(0.5f, 0.5f, 0.5f);
row.style.fontSize = 13;
buffMenuContent.Add(row);
}
buffMenuContent.style.marginBottom = 12;
}
// Purchase buttons section.
var buyHeader = new Label("Purchase");
buyHeader.style.color = new Color(0.9f, 0.8f, 0.5f);
buyHeader.style.fontSize = 14;
buyHeader.style.marginTop = 8;
buyHeader.style.marginBottom = 4;
buffMenuContent.Add(buyHeader);
int categoryCount = buffManager?.CategoryCount ?? 0;
if (categoryCount == 0)
{
var none = new Label("No categories available.");
none.style.color = new Color(0.55f, 0.55f, 0.55f);
buffMenuContent.Add(none);
return;
}
int localGold = TD.Gameplay.PlayerGoldManager.Local?.CurrentGold ?? 0;
for (int i = 0; i < categoryCount; i++)
{
var category = buffManager.GetCategory(i);
if (category == null) continue;
int idx = i; // capture for lambda
var btn = new Button(() =>
{
TD.Gameplay.PlayerBuffManager.Local?.RequestPurchaseBuffRpc(idx);
RefreshBuffMenuContent();
})
{
text = $"{category.DisplayName} — {category.Cost}g"
};
btn.style.height = 34;
btn.style.fontSize = 13;
btn.style.marginBottom = 4;
btn.SetEnabled(localGold >= category.Cost);
buffMenuContent.Add(btn);
}
}
// Bottom-left chat panel. Anchored 12px from the left edge, with the // Bottom-left chat panel. Anchored 12px from the left edge, with the
// bottom edge sitting above the 220px bottom-ui. Layout uses a flex // bottom edge sitting above the 220px bottom-ui. Layout uses a flex
// column: scrollable feed on top, input below. The feed clips at // column: scrollable feed on top, input below. The feed clips at

View file

@ -12,12 +12,11 @@
"com.unity.netcode.gameobjects": "2.11.0", "com.unity.netcode.gameobjects": "2.11.0",
"com.unity.probuilder": "6.0.9", "com.unity.probuilder": "6.0.9",
"com.unity.render-pipelines.universal": "17.4.0", "com.unity.render-pipelines.universal": "17.4.0",
"com.unity.sdk.linux-x86_64": "1.1.0", "com.unity.sdk.linux-x86_64": "1.0.2",
"com.unity.services.multiplayer": "2.1.3", "com.unity.services.multiplayer": "2.1.3",
"com.unity.terrain-tools": "5.3.2", "com.unity.terrain-tools": "5.3.2",
"com.unity.test-framework": "1.6.0", "com.unity.test-framework": "1.6.0",
"com.unity.timeline": "1.8.12", "com.unity.timeline": "1.8.12",
"com.unity.toolchain.linux-x86_64-linux": "1.1.0",
"com.unity.toolchain.win-x86_64-linux": "1.0.2", "com.unity.toolchain.win-x86_64-linux": "1.0.2",
"com.unity.ugui": "2.0.0", "com.unity.ugui": "2.0.0",
"com.unity.visualscripting": "1.9.11", "com.unity.visualscripting": "1.9.11",

View file

@ -221,11 +221,11 @@
"url": "https://packages.unity.com" "url": "https://packages.unity.com"
}, },
"com.unity.sdk.linux-x86_64": { "com.unity.sdk.linux-x86_64": {
"version": "1.1.0", "version": "1.0.2",
"depth": 0, "depth": 0,
"source": "registry", "source": "registry",
"dependencies": { "dependencies": {
"com.unity.sysroot.base": "1.1.0" "com.unity.sysroot.base": "1.0.2"
}, },
"url": "https://packages.unity.com" "url": "https://packages.unity.com"
}, },
@ -355,7 +355,7 @@
"url": "https://packages.unity.com" "url": "https://packages.unity.com"
}, },
"com.unity.sysroot.base": { "com.unity.sysroot.base": {
"version": "1.1.0", "version": "1.0.2",
"depth": 1, "depth": 1,
"source": "registry", "source": "registry",
"dependencies": {}, "dependencies": {},
@ -403,15 +403,6 @@
}, },
"url": "https://packages.unity.com" "url": "https://packages.unity.com"
}, },
"com.unity.toolchain.linux-x86_64-linux": {
"version": "1.1.0",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.sysroot.base": "1.1.0"
},
"url": "https://packages.unity.com"
},
"com.unity.toolchain.win-x86_64-linux": { "com.unity.toolchain.win-x86_64-linux": {
"version": "1.0.2", "version": "1.0.2",
"depth": 0, "depth": 0,