Paint towers with some the colors of the wind

This commit is contained in:
Ben Calegari 2026-06-02 23:59:44 -07:00
parent fec4433691
commit 04ead32846
15 changed files with 584 additions and 32 deletions

View file

@ -43,6 +43,22 @@ namespace TD.Core
}
/// <summary>
/// Paint color applied to a built tower by the Paint tool. Orthogonal to the owner's
/// player color: <see cref="None"/> means "unpainted" and the tower shows its owner
/// color; any other value overrides the visual tint and (in a later iteration) drives
/// projectile behavior (splash / poison-DoT / slow). Backed by byte for compact
/// NetworkVariable replication.
/// </summary>
public enum PaintColor : byte
{
/// <summary>Unpainted — tower shows its owner color. Also the Reset brush.</summary>
None = 0,
Red = 1,
Green = 2,
Blue = 3,
}
/// <summary>
/// Identifies a player slot in a match. Backed by byte to keep grid arrays compact.
/// </summary>

View file

@ -0,0 +1,50 @@
using UnityEngine;
namespace TD.Core
{
/// <summary>
/// Canonical RGB values for the tower Paint tool. Saturated primaries so a painted
/// tower reads clearly against any owner color. Mirrors the <see cref="PlayerColors"/>
/// style (HexRGB constants + a switch lookup).
/// </summary>
/// <remarks>
/// <see cref="PaintColor.None"/> has no color of its own — it means "unpainted", and
/// callers fall back to the owner color (<see cref="PlayerColors.Get"/>) in that case.
/// <see cref="Get"/> returns a neutral gray for <c>None</c> only so the Reset brush /
/// swatch has something to render; tinting code should branch on <c>None</c> before
/// calling this.
/// </remarks>
public static class PaintColors
{
private static readonly Color PaintRed = HexRGB(0xD8, 0x2A, 0x2A);
private static readonly Color PaintGreen = HexRGB(0x2E, 0xC0, 0x3A);
private static readonly Color PaintBlue = HexRGB(0x2E, 0x6A, 0xE0);
// Neutral gray used to represent the Reset / "None" brush in UI swatches and the
// paint cursor. Not applied as a tower tint (None towers revert to owner color).
private static readonly Color ResetGray = HexRGB(0xB0, 0xB0, 0xB8);
/// <summary>
/// Returns the color for a <see cref="PaintColor"/>. <see cref="PaintColor.None"/>
/// returns a neutral gray (for the Reset swatch/cursor) — actual tower tinting
/// should treat <c>None</c> as "use owner color" and not call this for that case.
/// </summary>
public static Color Get(PaintColor color)
{
switch (color)
{
case PaintColor.Red: return PaintRed;
case PaintColor.Green: return PaintGreen;
case PaintColor.Blue: return PaintBlue;
case PaintColor.None:
default:
return ResetGray;
}
}
private static Color HexRGB(byte r, byte g, byte b)
{
return new Color(r / 255f, g / 255f, b / 255f, 1f);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 75443abdf5c5f43ec9547275fb20f25b

View file

@ -82,6 +82,10 @@ namespace TD.Gameplay
// it may not exist at OnNetworkSpawn time.
private TowerPlacementController cachedPlacementController;
// Cached reference to the local TowerPaintController, looked up lazily (same
// rationale as the placement controller).
private TowerPaintController cachedPaintController;
// ----- Lifecycle --------------------------------------------------
public override void OnNetworkSpawn()
@ -114,7 +118,9 @@ namespace TD.Gameplay
var keyboard = Keyboard.current;
if (mouse == null) return;
bool isPlacing = IsLocalPlayerPlacing();
// Placement and paint are both modal: while either is active, the local
// controller for that mode owns left-/right-click, so selection here yields.
bool isModal = IsLocalPlayerPlacing() || IsLocalPlayerPainting();
Vector2 mousePos = mouse.position.ReadValue();
// UI Toolkit dispatches button click events AFTER Update runs, but raw mouse
@ -123,9 +129,9 @@ namespace TD.Gameplay
// and deselects before the button's action fires. Same risk on right-click.
bool pointerOverHud = HUDController.IsPointerOverInteractiveHud(mousePos);
// Left-click: selection. Suppressed during placement mode (left-click is
// the placement-submit gesture there) and when the pointer is over HUD.
if (!isPlacing && !pointerOverHud && mouse.leftButton.wasPressedThisFrame)
// Left-click: selection. Suppressed during a modal mode (placement-submit /
// paint-apply own the click there) and when the pointer is over HUD.
if (!isModal && !pointerOverHud && mouse.leftButton.wasPressedThisFrame)
{
HandleLeftClickSelection(mousePos);
}
@ -140,9 +146,9 @@ namespace TD.Gameplay
SelectionState.Instance?.Clear();
}
// Right-click. Suppressed entirely during placement mode (TowerPlacementController
// handles right-click as cancel-placement there) and when over HUD.
if (isPlacing) return;
// Right-click. Suppressed entirely during a modal mode (placement/paint
// controllers handle right-click as cancel there) and when over HUD.
if (isModal) return;
if (pointerOverHud) return;
if (!mouse.rightButton.wasPressedThisFrame) return;
@ -251,5 +257,17 @@ namespace TD.Gameplay
}
return cachedPlacementController.IsPlacing;
}
private bool IsLocalPlayerPainting()
{
if (cachedPaintController == null)
{
// Find lazily — controller may have been added after this component spawned.
cachedPaintController =
UnityEngine.Object.FindAnyObjectByType<TowerPaintController>();
if (cachedPaintController == null) return false;
}
return cachedPaintController.IsPainting;
}
}
}

View file

@ -19,7 +19,7 @@ namespace TD.Gameplay
/// of interface implementation rather than a whole prefab subtree.</para>
///
/// <para><b>Color isolation.</b> Because the ring is instantiated as a child of
/// THIS visualizer (not of the selectable), <c>TowerInstance.ApplyOwnerColor</c>'s
/// THIS visualizer (not of the selectable), <c>TowerInstance.ApplyTint</c>'s
/// MaterialPropertyBlock writes can never reach it. The ring's authored material
/// (green) shows through regardless of the selectable's own tinting.</para>
///

View file

@ -48,11 +48,12 @@ namespace TD.Gameplay
// ----- Inspector --------------------------------------------------
[Header("Visuals")]
[Tooltip("Mesh renderers tinted with the owner's player color. " +
"Drag in only the tower body's renderers — exclude anything " +
"that has its own color rules (selection rings, range " +
"indicators, FX). If left empty, the tower is NOT tinted " +
"and the prefab's baked materials show through.")]
[Tooltip("Mesh renderers tinted with the owner's player color (and the Paint " +
"tool's color). Drag in only the tower body's renderers to exclude " +
"anything with its own color rules (selection rings, range indicators, " +
"FX). If left EMPTY, every MeshRenderer under the tower is tinted " +
"automatically — fine for most prefabs; populate this only when you need " +
"to exclude specific children.")]
[SerializeField] private MeshRenderer[] tintedRenderers;
// ----- Networked state ------------------------------------------------
@ -82,6 +83,16 @@ namespace TD.Gameplay
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// Paint color applied by the Paint tool. None means "unpainted" — the tower
// shows its owner color. Set server-side via RequestPaintServerRpc (own-tower
// only). Replicated so every client re-tints; later iterations will read this
// to drive projectile behavior.
private readonly NetworkVariable<PaintColor> paintColor =
new NetworkVariable<PaintColor>(
PaintColor.None,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// ----- Local resolved state -------------------------------------------
// Resolved on every client in OnNetworkSpawn from definitionName.
@ -114,6 +125,10 @@ namespace TD.Gameplay
/// <summary>The PlayerSlot that placed this tower.</summary>
public PlayerSlot Owner => ownerSlot.Value;
/// <summary>The paint color applied to this tower, or <see cref="PaintColor.None"/>
/// if unpainted (showing its owner color).</summary>
public PaintColor Paint => paintColor.Value;
/// <summary>The footprint anchor tile (SW corner, world-tile coords).</summary>
public Vector2Int AnchorTile => anchorTile.Value;
@ -209,8 +224,10 @@ namespace TD.Gameplay
// but SetWalkable/SetOccupied are idempotent — double-stamping is safe.
StampFootprint(walkable: false, occupied: true);
// Apply owner color to the mesh renderer.
ApplyOwnerColor();
// Apply the tower tint (paint color if painted, else owner color), and
// re-tint on every client whenever the paint color changes.
ApplyTint();
paintColor.OnValueChanged += HandlePaintColorChanged;
// Register for minimap rendering.
MinimapEntityRegistry.Register(this);
@ -239,6 +256,8 @@ namespace TD.Gameplay
public override void OnNetworkDespawn()
{
paintColor.OnValueChanged -= HandlePaintColorChanged;
// Un-stamp the footprint when the tower is destroyed (sold, wave end, etc.)
// so the tiles become walkable and buildable again.
StampFootprint(walkable: true, occupied: false);
@ -253,6 +272,33 @@ namespace TD.Gameplay
SelectionState.Instance.Clear();
}
// ----- Paint tool -----------------------------------------------------
/// <summary>
/// Client → server request to paint this tower. The server applies the color
/// only if the requesting client owns this tower (matching the placement
/// ownership rule). <see cref="PaintColor.None"/> resets the tower to its owner
/// color. The change replicates back to every client via
/// <see cref="paintColor"/>'s OnValueChanged.
/// </summary>
[Rpc(SendTo.Server)]
public void RequestPaintServerRpc(PaintColor color, RpcParams rpcParams = default)
{
PlayerSlot senderSlot = PlayerMatchState.SlotForClient(rpcParams.Receive.SenderClientId);
if (senderSlot == PlayerSlot.None || senderSlot != ownerSlot.Value)
{
Debug.Log($"[TowerInstance] Paint rejected: client " +
$"{rpcParams.Receive.SenderClientId} ({senderSlot}) does not own " +
$"tower owned by {ownerSlot.Value}.");
return;
}
paintColor.Value = color;
}
// Re-tint on every client (and the server) when the replicated paint color changes.
private void HandlePaintColorChanged(PaintColor previous, PaintColor current) => ApplyTint();
// ----- IMinimapEntity -------------------------------------------------
//
// Towers are static, so WorldPosition is cheap (no movement to track). Color reflects
@ -343,28 +389,50 @@ namespace TD.Gameplay
// silently ignored.
private static readonly int BaseColorPropertyId = Shader.PropertyToID("_BaseColor");
private void ApplyOwnerColor()
private void ApplyTint()
{
Color ownerColor = PlayerColors.Get(ownerSlot.Value);
ownerColor.a = 1f;
// Paint color takes precedence when set; otherwise fall back to the owner
// color. Paint.None means "unpainted" → show owner color.
Color tint = paintColor.Value != PaintColor.None
? PaintColors.Get(paintColor.Value)
: PlayerColors.Get(ownerSlot.Value);
tint.a = 1f;
// MaterialPropertyBlock sets per-renderer properties without allocating
// a new Material object. Safe to reuse across calls on the same instance.
// All Unity standard/URP shaders expose _Color or _BaseColor, so writing
// both lets the tint apply regardless of which shader the prefab uses.
colorPropertyBlock ??= new MaterialPropertyBlock();
colorPropertyBlock.SetColor(ColorPropertyId, ownerColor);
colorPropertyBlock.SetColor(BaseColorPropertyId, ownerColor);
colorPropertyBlock.SetColor(ColorPropertyId, tint);
colorPropertyBlock.SetColor(BaseColorPropertyId, tint);
// Tint only the renderers explicitly listed in the inspector. Avoids
// accidentally re-coloring decorative children, FX, etc. (Mirrors
// Builder.tintedRenderers — same rationale.)
if (tintedRenderers == null) return;
foreach (var rend in tintedRenderers)
foreach (var rend in ResolveTintRenderers())
{
if (rend == null) continue;
rend.SetPropertyBlock(colorPropertyBlock);
}
}
// Renderers actually tinted. Prefer the inspector-assigned list (lets a prefab
// exclude decorative children, FX, etc.). When that list is empty — the common
// case for imported models nobody has hand-wired — fall back to every MeshRenderer
// under the tower so owner-color and paint Just Work without per-prefab setup.
// Cached after the first resolve.
private MeshRenderer[] resolvedTintRenderers;
private MeshRenderer[] ResolveTintRenderers()
{
if (tintedRenderers != null && tintedRenderers.Length > 0)
return tintedRenderers;
if (resolvedTintRenderers == null)
{
resolvedTintRenderers = GetComponentsInChildren<MeshRenderer>(includeInactive: true);
if (resolvedTintRenderers.Length == 0)
Debug.LogWarning($"[TowerInstance] '{name}' has no MeshRenderers to tint — " +
$"owner color and paint will have no visible effect.");
}
return resolvedTintRenderers;
}
}
}

View file

@ -0,0 +1,216 @@
// Assets/_Project/Scripts/Gameplay/TowerPaintController.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using TD.Core;
using TD.UI;
namespace TD.Gameplay
{
/// <summary>
/// Per-client controller for the tower Paint UX. Mirrors
/// <see cref="TowerPlacementController"/>: a plain (non-networked) MonoBehaviour that
/// owns purely local, cosmetic state — the active brush color and the hardware cursor.
/// The authoritative recolor lives on <see cref="TowerInstance"/> via
/// <see cref="TowerInstance.RequestPaintServerRpc"/>.
/// </summary>
/// <remarks>
/// <para><b>Paint mode.</b> <see cref="BeginPaint"/> activates a brush (Red/Green/Blue
/// or the <see cref="PaintColor.None"/> Reset brush) and swaps the OS cursor to a
/// tinted circle. While active, a left-click that hits a <see cref="TowerInstance"/>
/// the local player owns sends a paint request; the player stays in paint mode so
/// several towers can be painted in a row. Right-click or Escape exits.</para>
///
/// <para><b>Active vs. color.</b> <see cref="IsPainting"/> tracks whether a brush is
/// active independently of <see cref="CurrentColor"/>, because <c>None</c> is a valid
/// active brush (Reset) — not the "off" state.</para>
///
/// <para><b>Interaction with selection.</b> <see cref="BuilderInputController"/> checks
/// <see cref="IsPainting"/> and suppresses its own left-/right-click handling while a
/// brush is active, so painting never also moves or deselects the builder.</para>
/// </remarks>
public class TowerPaintController : MonoBehaviour
{
// ----- Inspector --------------------------------------------------
[Tooltip("Maximum raycast distance from the camera into the world. Should be at " +
"least as large as the camera's far clip plane.")]
[SerializeField] private float raycastMaxDistance = 500f;
[Tooltip("Diameter in pixels of the generated paint cursor. Larger than a dot so " +
"it reads clearly as a paint brush.")]
[SerializeField] private int cursorSize = 28;
// ----- State ------------------------------------------------------
private PaintColor currentColor = PaintColor.None;
/// <summary>True while a paint brush is active. Independent of the brush color
/// (the Reset / <see cref="PaintColor.None"/> brush is still an active brush).</summary>
public bool IsPainting { get; private set; }
/// <summary>The active brush color. Only meaningful while <see cref="IsPainting"/>.</summary>
public PaintColor CurrentColor => currentColor;
// Generated cursor textures, cached per color so we don't rebuild every activation.
private readonly Dictionary<PaintColor, Texture2D> cursorTextures =
new Dictionary<PaintColor, Texture2D>();
// ----- Lifecycle --------------------------------------------------
private void OnDisable()
{
// Restore the OS cursor if this is torn down mid-paint.
CancelPaint();
}
private void Update()
{
if (!IsPainting) return;
var mouse = Mouse.current;
if (mouse == null) return;
var keyboard = Keyboard.current;
// Right-click or Escape exits paint mode. (Escape is ignored while a HUD text
// field has focus so it means "cancel typing" there, matching other systems.)
bool escape = keyboard != null
&& keyboard.escapeKey.wasPressedThisFrame
&& !HUDController.IsTextInputActive;
if (mouse.rightButton.wasPressedThisFrame || escape)
{
CancelPaint();
return;
}
// Left-click paints the tower under the cursor (if the local player owns it).
if (!mouse.leftButton.wasPressedThisFrame) return;
Vector2 mousePos = mouse.position.ReadValue();
if (HUDController.IsPointerOverInteractiveHud(mousePos)) return; // clicked the HUD
TryPaintAtCursor(mousePos);
}
// ----- Public API -------------------------------------------------
/// <summary>
/// Activates the given paint brush and swaps the cursor. Calling this with the
/// already-active brush toggles paint mode off (so a second press of the same
/// hotkey cancels). Call from HUD paint swatches.
/// </summary>
public void BeginPaint(PaintColor color)
{
// Toggle off if the same brush is re-selected.
if (IsPainting && color == currentColor)
{
CancelPaint();
return;
}
currentColor = color;
IsPainting = true;
var tex = GetCursorTexture(color);
Vector2 hotspot = new Vector2(tex.width * 0.5f, tex.height * 0.5f);
Cursor.SetCursor(tex, hotspot, CursorMode.Auto);
}
/// <summary>
/// Exits paint mode and restores the default OS cursor. Safe to call when idle.
/// </summary>
public void CancelPaint()
{
if (!IsPainting) return;
IsPainting = false;
Cursor.SetCursor(null, Vector2.zero, CursorMode.Auto);
}
// ----- Painting ---------------------------------------------------
private void TryPaintAtCursor(Vector2 screenPos)
{
var cam = Camera.main;
if (cam == null) return;
Ray ray = cam.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f));
// A tower is identified by its TowerInstance component, not by a physics
// layer — its selection/body colliders may live on different layers. Raycast
// all layers (including triggers, since selection volumes are triggers) and
// keep the nearest hit that resolves to a TowerInstance; non-tower hits
// (terrain, buildable plane, the builder) are simply ignored.
var hits = Physics.RaycastAll(ray, raycastMaxDistance, ~0,
QueryTriggerInteraction.Collide);
TowerInstance tower = null;
float nearest = float.MaxValue;
foreach (var h in hits)
{
var t = h.collider.GetComponentInParent<TowerInstance>();
if (t != null && h.distance < nearest)
{
nearest = h.distance;
tower = t;
}
}
if (tower == null) return; // clicked empty space / non-tower — no-op
// Own-towers-only: skip non-owned towers locally (the server re-validates too).
if (tower.Owner != GetLocalPlayerSlot()) return;
tower.RequestPaintServerRpc(currentColor);
}
private static PlayerSlot GetLocalPlayerSlot()
=> PlayerMatchState.Local?.Slot ?? PlayerSlot.None;
// ----- Cursor texture ---------------------------------------------
// Builds (and caches) a filled-circle cursor texture tinted to the paint color,
// with a darker rim so it reads against any background. The None/Reset brush is a
// neutral gray circle. Pixels outside the circle are transparent.
private Texture2D GetCursorTexture(PaintColor color)
{
if (cursorTextures.TryGetValue(color, out var cached) && cached != null)
return cached;
int size = Mathf.Max(8, cursorSize);
var tex = new Texture2D(size, size, TextureFormat.RGBA32, false);
Color fill = PaintColors.Get(color);
Color rim = fill * 0.45f;
rim.a = 1f;
float center = (size - 1) * 0.5f;
float outer = center; // outer edge of the disc
float rimStart = outer - 2f; // 2px rim
var pixels = new Color[size * size];
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
float dx = x - center;
float dy = y - center;
float dist = Mathf.Sqrt(dx * dx + dy * dy);
Color c;
if (dist > outer) c = new Color(0f, 0f, 0f, 0f); // transparent outside
else if (dist >= rimStart) c = rim; // dark rim
else c = fill; // solid center
pixels[y * size + x] = c;
}
}
tex.SetPixels(pixels);
tex.Apply();
tex.filterMode = FilterMode.Bilinear;
cursorTextures[color] = tex;
return tex;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b81770471b644b3aa0e58d4a108dddd

View file

@ -27,6 +27,9 @@ namespace TD.UI
[Tooltip("The local client's TowerPlacementController.")]
[SerializeField] private TowerPlacementController placementController;
[Tooltip("The local client's TowerPaintController (drives the Paint tab + paint cursor).")]
[SerializeField] private TowerPaintController paintController;
[Tooltip("The TowerPlacementManager NetworkObject in the scene.")]
[SerializeField] private TowerPlacementManager placementManager;
@ -68,6 +71,9 @@ namespace TD.UI
private VisualElement statLines;
private VisualElement commandGrid;
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 Button tabBuild;
private Button tabPaint;
private VisualElement buildProgressContainer; // info-panel sub-view, shown for BuildSiteVisual selections
private VisualElement buildProgressFill; // width driven each frame from progress
private Label buildProgressPercent;
@ -110,6 +116,12 @@ namespace TD.UI
// ----- State ------------------------------------------------------
// Which command-grid tab is active for a Builder selection. Build = tower buttons
// (default), Paint = color swatches. Reset to Build whenever a non-builder is
// selected so reselecting a builder always starts on Build.
private enum CommandTab { Build, Paint }
private CommandTab activeTab = CommandTab.Build;
private Coroutine rejectionFadeCoroutine;
private bool placementManagerReady; // true once TowerPlacementManager.Instance is non-null
private bool uiInitialized;
@ -249,6 +261,12 @@ namespace TD.UI
statLines = Require<VisualElement>(root, "stat-lines");
commandGrid = Require<VisualElement>(root, "command-grid");
actionFrame = Require<VisualElement>(root, "action-frame");
commandTabs = Require<VisualElement>(root, "command-tabs");
tabBuild = Require<Button>(root, "tab-build");
tabPaint = Require<Button>(root, "tab-paint");
if (tabBuild != null) tabBuild.clicked += () => SwitchTab(CommandTab.Build);
if (tabPaint != null) tabPaint.clicked += () => SwitchTab(CommandTab.Paint);
buildProgressContainer = Require<VisualElement>(root, "build-progress");
buildProgressFill = Require<VisualElement>(root, "build-progress-fill");
buildProgressPercent = Require<Label>(root, "build-progress-percent");
@ -681,6 +699,19 @@ namespace TD.UI
if (actionFrame != null)
actionFrame.style.display = hasActions ? DisplayStyle.Flex : DisplayStyle.None;
// Build/Paint tabs only make sense for the builder's build menu. For any
// other selection (or none), reset to the Build tab and exit paint mode so
// the cursor never gets stuck as a brush after deselecting the builder.
bool isBuilder = selection is Builder;
if (!isBuilder)
{
activeTab = CommandTab.Build;
paintController?.CancelPaint();
}
if (commandTabs != null)
commandTabs.style.display = isBuilder ? DisplayStyle.Flex : DisplayStyle.None;
RefreshTabActiveState();
commandGrid.Clear();
if (!hasActions) return; // grid stays empty; frame is hidden anyway
@ -689,12 +720,24 @@ namespace TD.UI
if (selection is Builder)
{
int i = 0;
foreach (var (def, typeId) in placementManager.GetAvailableDefinitions())
if (activeTab == CommandTab.Paint)
{
if (i >= GRID_MAX) break;
cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]);
i++;
// Paint swatches: Red/Green/Blue then a Reset (None) brush, mapped to
// the Q/W/E/R hotkey slots via the shared CreateActionButton wiring.
cells[0] = CreatePaintButton(PaintColor.Red, HotkeyLayout[0]);
cells[1] = CreatePaintButton(PaintColor.Green, HotkeyLayout[1]);
cells[2] = CreatePaintButton(PaintColor.Blue, HotkeyLayout[2]);
cells[3] = CreatePaintButton(PaintColor.None, HotkeyLayout[3]);
}
else
{
int i = 0;
foreach (var (def, typeId) in placementManager.GetAvailableDefinitions())
{
if (i >= GRID_MAX) break;
cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]);
i++;
}
}
}
else if (selection is TowerInstance tower)
@ -745,6 +788,63 @@ namespace TD.UI
return btn;
}
// ----- Build/Paint tabs -------------------------------------------
// Switch the active command-grid tab and rebuild for the current selection.
// Leaving the Paint tab also exits paint mode so the cursor reverts.
private void SwitchTab(CommandTab tab)
{
if (tab == CommandTab.Build)
paintController?.CancelPaint();
activeTab = tab;
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
}
private void RefreshTabActiveState()
{
tabBuild?.EnableInClassList("active", activeTab == CommandTab.Build);
tabPaint?.EnableInClassList("active", activeTab == CommandTab.Paint);
}
// Paint swatch: clicking enters paint mode with that color (None = Reset brush).
// Built on CreateActionButton so the Q/W/E/R hotkey wiring comes for free; the
// icon placeholder is tinted to the swatch color.
private VisualElement CreatePaintButton(PaintColor color, Key hotkey)
{
var btn = CreateActionButton(
costText: "",
hotkey: hotkey,
onClick: () =>
{
if (paintController != null)
paintController.BeginPaint(color);
else
Debug.LogWarning("[HUDController] No TowerPaintController assigned.");
});
var icon = btn.Q<VisualElement>(null, "cmd-icon-placeholder");
if (icon != null)
icon.style.backgroundColor = PaintColors.Get(color);
string label = color == PaintColor.None ? "Reset" : color.ToString();
btn.RegisterCallback<MouseEnterEvent>(_ => ShowPaintTooltip(label, color));
btn.RegisterCallback<MouseLeaveEvent>(_ => ClearTooltip());
return btn;
}
// Lightweight tooltip for paint swatches — reuses the tooltip box (title + desc).
private void ShowPaintTooltip(string label, PaintColor color)
{
if (ttTitle == null) return;
ttTitle.text = label;
ttDesc.text = color == PaintColor.None
? "Clear paint — revert tower to its owner color."
: "Paint your towers this color.";
ttStats.text = "";
ttCost.text = "";
}
private static VisualElement CreateEmptySlot()
{
var slot = new VisualElement();