From 04ead32846375c9eb3c72c50cf844552be01203f Mon Sep 17 00:00:00 2001 From: Ben Calegari Date: Tue, 2 Jun 2026 23:59:44 -0700 Subject: [PATCH] Paint towers with some the colors of the wind --- .gitignore | 3 + .../Prefabs/Towers/BuildSiteVisual.prefab | 2 +- Assets/_Project/Scenes/Levels/9Player.unity | 19 ++ Assets/_Project/Scenes/Levels/Main.unity | 20 ++ Assets/_Project/Scripts/Core/Enums.cs | 16 ++ Assets/_Project/Scripts/Core/PaintColors.cs | 50 ++++ .../_Project/Scripts/Core/PaintColors.cs.meta | 2 + .../Gameplay/BuilderInputController.cs | 32 ++- .../Scripts/Gameplay/SelectionVisualizer.cs | 2 +- .../Scripts/Gameplay/TowerInstance.cs | 102 +++++++-- .../Scripts/Gameplay/TowerPaintController.cs | 216 ++++++++++++++++++ .../Gameplay/TowerPaintController.cs.meta | 2 + Assets/_Project/Scripts/UI/HUDController.cs | 110 ++++++++- Assets/_Project/UI/HUD.uss | 33 +++ Assets/_Project/UI/HUD.uxml | 7 +- 15 files changed, 584 insertions(+), 32 deletions(-) create mode 100644 Assets/_Project/Scripts/Core/PaintColors.cs create mode 100644 Assets/_Project/Scripts/Core/PaintColors.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/TowerPaintController.cs create mode 100644 Assets/_Project/Scripts/Gameplay/TowerPaintController.cs.meta 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