UnityTowerDefense/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs
2026-06-03 00:00:14 -07:00

216 lines
8.8 KiB
C#

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