Paint towers with some the colors of the wind

This commit is contained in:
Ben Calegari 2026-06-02 23:59:44 -07:00
parent fec4433691
commit 04ead32846
15 changed files with 584 additions and 32 deletions

View file

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