// 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; } } }