Paint towers with some the colors of the wind
This commit is contained in:
parent
fec4433691
commit
04ead32846
15 changed files with 584 additions and 32 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -117,3 +117,6 @@ UnityTowerDefense_BurstDebugInformation_DoNotShip/Data/Plugins/lib_burst_generat
|
|||
|
||||
# VS Code editor settings
|
||||
.vscode/
|
||||
|
||||
# Rider Settings
|
||||
.idea/
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
50
Assets/_Project/Scripts/Core/PaintColors.cs
Normal file
50
Assets/_Project/Scripts/Core/PaintColors.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Core/PaintColors.cs.meta
Normal file
2
Assets/_Project/Scripts/Core/PaintColors.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 75443abdf5c5f43ec9547275fb20f25b
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
216
Assets/_Project/Scripts/Gameplay/TowerPaintController.cs
Normal file
216
Assets/_Project/Scripts/Gameplay/TowerPaintController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6b81770471b644b3aa0e58d4a108dddd
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -356,6 +356,39 @@
|
|||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Build / Paint tab row. Shown only when the builder is selected; hidden
|
||||
(display:none, set from HUDController) for tower/build-site selections. */
|
||||
.command-tabs {
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.cmd-tab {
|
||||
flex: 1;
|
||||
height: 22px;
|
||||
font-size: 11px;
|
||||
-unity-font-style: bold;
|
||||
color: rgb(200, 190, 160);
|
||||
background-color: rgb(26, 26, 10);
|
||||
border-width: 1px;
|
||||
border-color: rgb(58, 58, 26);
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cmd-tab:hover {
|
||||
background-color: rgb(42, 42, 21);
|
||||
border-color: rgb(106, 106, 48);
|
||||
}
|
||||
|
||||
.cmd-tab.active {
|
||||
color: rgb(255, 224, 102);
|
||||
background-color: rgb(42, 38, 16);
|
||||
border-color: rgb(255, 224, 102);
|
||||
}
|
||||
|
||||
/* Command grid: 3 row-containers injected at runtime, each holding 5 buttons. */
|
||||
.command-grid {
|
||||
flex: 1;
|
||||
|
|
|
|||
|
|
@ -63,8 +63,13 @@
|
|||
<ui:Label name="tt-cost" text="" class="tt-cost"/>
|
||||
</ui:VisualElement>
|
||||
|
||||
<!-- Section 5: Action Menu (tall frame). Hides entirely when nothing actionable is selected. -->
|
||||
<!-- Section 5: Action Menu (tall frame). Hides entirely when nothing actionable is selected.
|
||||
The Build/Paint tabs are shown only when the builder is selected. -->
|
||||
<ui:VisualElement name="action-frame" class="action-frame">
|
||||
<ui:VisualElement name="command-tabs" class="command-tabs">
|
||||
<ui:Button name="tab-build" text="Build" class="cmd-tab"/>
|
||||
<ui:Button name="tab-paint" text="Paint" class="cmd-tab"/>
|
||||
</ui:VisualElement>
|
||||
<ui:VisualElement name="command-grid" class="command-grid"/>
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue