diff --git a/.gitignore b/.gitignore
index f6328be..ca4f7dc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -117,3 +117,6 @@ UnityTowerDefense_BurstDebugInformation_DoNotShip/Data/Plugins/lib_burst_generat
# VS Code editor settings
.vscode/
+
+# Rider Settings
+.idea/
\ No newline at end of file
diff --git a/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab b/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab
index 5334e24..66abbc6 100644
--- a/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab
+++ b/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab
@@ -267,7 +267,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
- GlobalObjectIdHash: 800191742
+ GlobalObjectIdHash: 3616792119
InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0
Ownership: 1
diff --git a/Assets/_Project/Scenes/Levels/9Player.unity b/Assets/_Project/Scenes/Levels/9Player.unity
index d2ff8fa..38dedaf 100644
--- a/Assets/_Project/Scenes/Levels/9Player.unity
+++ b/Assets/_Project/Scenes/Levels/9Player.unity
@@ -16037,6 +16037,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.UI.HUDController
placementController: {fileID: 1597884411}
+ paintController: {fileID: 1597884412}
placementManager: {fileID: 1507514108}
cameraController: {fileID: 1239994223}
rejectionMessageDuration: 2.5
@@ -23097,6 +23098,7 @@ GameObject:
m_Component:
- component: {fileID: 1597884409}
- component: {fileID: 1597884411}
+ - component: {fileID: 1597884412}
m_Layer: 0
m_Name: TowerPlacementController
m_TagString: Untagged
@@ -23136,6 +23138,23 @@ MonoBehaviour:
serializedVersion: 2
m_Bits: 64
raycastMaxDistance: 500
+--- !u!114 &1597884412
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1597884408}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 6b81770471b644b3aa0e58d4a108dddd, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.TowerPaintController
+ selectionLayerMask:
+ serializedVersion: 2
+ m_Bits: 64
+ raycastMaxDistance: 500
+ cursorSize: 28
--- !u!1 &1618087688
GameObject:
m_ObjectHideFlags: 0
diff --git a/Assets/_Project/Scenes/Levels/Main.unity b/Assets/_Project/Scenes/Levels/Main.unity
index 2bc67dc..9c9afa2 100644
--- a/Assets/_Project/Scenes/Levels/Main.unity
+++ b/Assets/_Project/Scenes/Levels/Main.unity
@@ -1074,6 +1074,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.UI.HUDController
placementController: {fileID: 1597884411}
+ paintController: {fileID: 1597884412}
placementManager: {fileID: 1507514108}
cameraController: {fileID: 1239994223}
rejectionMessageDuration: 2.5
@@ -1667,6 +1668,7 @@ MonoBehaviour:
- {fileID: 11400000, guid: 65f66289ea1233b4897f46cd997d9c7a, type: 2}
- {fileID: 11400000, guid: 190e39db44aa0794aa808fd60976f7c4, type: 2}
startingLives: 40
+ goldConfig: {fileID: 0}
--- !u!1 &1464027360
GameObject:
m_ObjectHideFlags: 0
@@ -1911,6 +1913,7 @@ GameObject:
m_Component:
- component: {fileID: 1597884409}
- component: {fileID: 1597884411}
+ - component: {fileID: 1597884412}
m_Layer: 0
m_Name: TowerPlacementController
m_TagString: Untagged
@@ -1950,6 +1953,23 @@ MonoBehaviour:
serializedVersion: 2
m_Bits: 64
raycastMaxDistance: 500
+--- !u!114 &1597884412
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1597884408}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 6b81770471b644b3aa0e58d4a108dddd, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.TowerPaintController
+ selectionLayerMask:
+ serializedVersion: 2
+ m_Bits: 64
+ raycastMaxDistance: 500
+ cursorSize: 28
--- !u!1 &1731269685
GameObject:
m_ObjectHideFlags: 0
diff --git a/Assets/_Project/Scripts/Core/Enums.cs b/Assets/_Project/Scripts/Core/Enums.cs
index 78bb9aa..eb72085 100644
--- a/Assets/_Project/Scripts/Core/Enums.cs
+++ b/Assets/_Project/Scripts/Core/Enums.cs
@@ -43,6 +43,22 @@ namespace TD.Core
}
+ ///
+ /// Paint color applied to a built tower by the Paint tool. Orthogonal to the owner's
+ /// player color: 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.
+ ///
+ public enum PaintColor : byte
+ {
+ /// Unpainted — tower shows its owner color. Also the Reset brush.
+ None = 0,
+ Red = 1,
+ Green = 2,
+ Blue = 3,
+ }
+
///
/// Identifies a player slot in a match. Backed by byte to keep grid arrays compact.
///
diff --git a/Assets/_Project/Scripts/Core/PaintColors.cs b/Assets/_Project/Scripts/Core/PaintColors.cs
new file mode 100644
index 0000000..5c6012b
--- /dev/null
+++ b/Assets/_Project/Scripts/Core/PaintColors.cs
@@ -0,0 +1,50 @@
+using UnityEngine;
+
+namespace TD.Core
+{
+ ///
+ /// Canonical RGB values for the tower Paint tool. Saturated primaries so a painted
+ /// tower reads clearly against any owner color. Mirrors the
+ /// style (HexRGB constants + a switch lookup).
+ ///
+ ///
+ /// has no color of its own — it means "unpainted", and
+ /// callers fall back to the owner color () in that case.
+ /// returns a neutral gray for None only so the Reset brush /
+ /// swatch has something to render; tinting code should branch on None before
+ /// calling this.
+ ///
+ 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);
+
+ ///
+ /// Returns the color for a .
+ /// returns a neutral gray (for the Reset swatch/cursor) — actual tower tinting
+ /// should treat None as "use owner color" and not call this for that case.
+ ///
+ 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);
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Core/PaintColors.cs.meta b/Assets/_Project/Scripts/Core/PaintColors.cs.meta
new file mode 100644
index 0000000..bb189cf
--- /dev/null
+++ b/Assets/_Project/Scripts/Core/PaintColors.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 75443abdf5c5f43ec9547275fb20f25b
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs
index a37bc00..5de1f4a 100644
--- a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs
+++ b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs
@@ -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();
+ if (cachedPaintController == null) return false;
+ }
+ return cachedPaintController.IsPainting;
+ }
}
}
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs b/Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
index d60864b..85b603a 100644
--- a/Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
+++ b/Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
@@ -19,7 +19,7 @@ namespace TD.Gameplay
/// of interface implementation rather than a whole prefab subtree.
///
/// Color isolation. Because the ring is instantiated as a child of
- /// THIS visualizer (not of the selectable), TowerInstance.ApplyOwnerColor's
+ /// THIS visualizer (not of the selectable), TowerInstance.ApplyTint's
/// MaterialPropertyBlock writes can never reach it. The ring's authored material
/// (green) shows through regardless of the selectable's own tinting.
///
diff --git a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs
index 9e682b1..b99c0c2 100644
--- a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs
+++ b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs
@@ -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 =
+ new NetworkVariable(
+ 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
/// The PlayerSlot that placed this tower.
public PlayerSlot Owner => ownerSlot.Value;
+ /// The paint color applied to this tower, or
+ /// if unpainted (showing its owner color).
+ public PaintColor Paint => paintColor.Value;
+
/// The footprint anchor tile (SW corner, world-tile coords).
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 -----------------------------------------------------
+
+ ///
+ /// 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). resets the tower to its owner
+ /// color. The change replicates back to every client via
+ /// 's OnValueChanged.
+ ///
+ [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(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;
+ }
}
}
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs b/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs
new file mode 100644
index 0000000..e6c52e0
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs
@@ -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
+{
+ ///
+ /// Per-client controller for the tower Paint UX. Mirrors
+ /// : a plain (non-networked) MonoBehaviour that
+ /// owns purely local, cosmetic state — the active brush color and the hardware cursor.
+ /// The authoritative recolor lives on via
+ /// .
+ ///
+ ///
+ /// Paint mode. activates a brush (Red/Green/Blue
+ /// or the Reset brush) and swaps the OS cursor to a
+ /// tinted circle. While active, a left-click that hits a
+ /// 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.
+ ///
+ /// Active vs. color. tracks whether a brush is
+ /// active independently of , because None is a valid
+ /// active brush (Reset) — not the "off" state.
+ ///
+ /// Interaction with selection. checks
+ /// and suppresses its own left-/right-click handling while a
+ /// brush is active, so painting never also moves or deselects the builder.
+ ///
+ 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;
+
+ /// True while a paint brush is active. Independent of the brush color
+ /// (the Reset / brush is still an active brush).
+ public bool IsPainting { get; private set; }
+
+ /// The active brush color. Only meaningful while .
+ public PaintColor CurrentColor => currentColor;
+
+ // Generated cursor textures, cached per color so we don't rebuild every activation.
+ private readonly Dictionary cursorTextures =
+ new Dictionary();
+
+ // ----- 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 -------------------------------------------------
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// Exits paint mode and restores the default OS cursor. Safe to call when idle.
+ ///
+ 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();
+ 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;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs.meta b/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs.meta
new file mode 100644
index 0000000..0302e09
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6b81770471b644b3aa0e58d4a108dddd
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs
index e7ebada..6c3963f 100644
--- a/Assets/_Project/Scripts/UI/HUDController.cs
+++ b/Assets/_Project/Scripts/UI/HUDController.cs
@@ -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(root, "stat-lines");
commandGrid = Require(root, "command-grid");
actionFrame = Require(root, "action-frame");
+ commandTabs = Require(root, "command-tabs");
+ tabBuild = Require