// Assets/_Project/Scripts/Gameplay/Builder.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Levels;
namespace TD.Gameplay
{
///
/// Per-player avatar that gates tower placement by proximity. Server-authoritative
/// position; clients submit move requests via Rpc and the server validates and applies.
///
///
/// Pure visual avatar. Builders have no collider for gameplay purposes —
/// they don't block enemies, can't be attacked, and aren't selected as targets. They
/// are visible to all players but only their owner can move them.
///
/// Terrain-aware height. Each frame the server casts a ray straight down
/// from the builder against the and sets Y to
/// hit.point.y + heightOffset. If the ray misses, falls back to the buildable
/// plane Y. Towers are not on the terrain layer, so they don't influence height.
///
/// Range gating. is the public query
/// that TowerPlacementManager uses to validate placement requests. Builder range
/// is measured center-of-builder to center-of-anchor-tile in world units.
///
/// Static registry. Like , builders register
/// themselves in a static dictionary keyed by OwnerClientId on spawn, so server
/// gameplay code (notably TowerPlacementManager) can find a player's builder without
/// scene traversal.
///
[RequireComponent(typeof(NetworkObject))]
public class Builder : NetworkBehaviour
{
// ----- Static registry --------------------------------------------
private static readonly Dictionary s_byClientId
= new Dictionary();
///
/// Returns the Builder owned by the given client, or null if none is currently spawned.
/// Safe to call on server or client.
///
public static Builder GetForClient(ulong clientId)
{
s_byClientId.TryGetValue(clientId, out var builder);
return builder;
}
///
/// Convenience: the local client's own builder. Returns null on a dedicated server
/// or before the local player has spawned.
///
public static Builder Local
{
get
{
var nm = NetworkManager.Singleton;
if (nm == null || !nm.IsClient) return null;
return GetForClient(nm.LocalClientId);
}
}
// ----- Inspector --------------------------------------------------
[Header("Movement")]
[Tooltip("Speed at which the builder moves toward its target position, in world " +
"units per second.")]
[SerializeField] private float moveSpeed = 8f;
[Tooltip("Distance below which the builder is considered to have arrived at its " +
"target. Smaller = more precise but more jitter; larger = less precise " +
"but smoother.")]
[SerializeField] private float arrivalThreshold = 0.05f;
[Header("Height tracking")]
[Tooltip("Vertical offset above the terrain at which the builder hovers. " +
"Re-evaluated every server tick by raycasting straight down.")]
[SerializeField] private float heightOffset = 2f;
[Tooltip("Maximum distance to cast downward when sampling terrain height. Should " +
"exceed your map's vertical range.")]
[SerializeField] private float terrainRaycastMaxDistance = 100f;
[Tooltip("Physics layer mask used for terrain height sampling. Towers MUST NOT be " +
"on this layer — only ground geometry. Falls back to the buildable plane Y " +
"if no terrain hit.")]
[SerializeField] private LayerMask terrainLayerMask;
[Header("Build range")]
[Tooltip("Maximum distance from the builder's center to a tower's anchor tile center " +
"for placement to be allowed, measured in world units (== tiles).")]
[SerializeField] private float buildRange = 6f;
// ----- Networked state --------------------------------------------
// Server-authoritative target position. The server moves the builder toward this
// each frame; clients render the builder smoothly via NetworkTransform's interpolation.
// We replicate the target (not the live position) so the server's intent is visible
// to clients, but the rendered position is whatever NetworkTransform interpolates to.
private readonly NetworkVariable targetPosition = new NetworkVariable(
value: Vector3.zero,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// ----- Public accessors -------------------------------------------
/// The builder's current world position (its actual transform position,
/// not the target).
public Vector3 CurrentPosition => transform.position;
/// The builder's target position. Server moves toward this each frame.
public Vector3 TargetPosition => targetPosition.Value;
/// True if the builder has arrived at its target (within
/// ).
public bool IsAtTarget =>
Vector3.SqrMagnitude(transform.position - targetPosition.Value)
< arrivalThreshold * arrivalThreshold;
/// Build range in world units.
public float BuildRange => buildRange;
// ----- Lifecycle --------------------------------------------------
public override void OnNetworkSpawn()
{
s_byClientId[OwnerClientId] = this;
if (IsServer)
{
// Set initial target = current position so the builder doesn't drift on spawn.
// The spawner is responsible for placing this builder at a sensible position
// BEFORE Spawn() — see PlayerSpawnHelper / Player.OnNetworkSpawn.
targetPosition.Value = transform.position;
Debug.Log($"[Builder] Spawned for client {OwnerClientId} at " +
$"{transform.position}.");
}
ApplyOwnerColor();
}
// ----- Owner color tinting ----------------------------------------
// Lazily allocated; reused across renderers. Construction in a field initializer
// would throw on this MonoBehaviour at scene load.
private MaterialPropertyBlock colorPropertyBlock;
// Both _Color (legacy Standard) and _BaseColor (URP Lit) — writing both lets the
// tint apply regardless of which shader the prefab uses. Unknown property writes
// are silently ignored by the shader.
private static readonly int ColorPropertyId = Shader.PropertyToID("_Color");
private static readonly int BaseColorPropertyId = Shader.PropertyToID("_BaseColor");
private void ApplyOwnerColor()
{
// Owner color comes from the slot mapping. Same stub mapping as elsewhere —
// replaced when MatchState lands.
byte slotByte = (byte)(OwnerClientId + 1);
PlayerSlot slot = (slotByte >= 1 && slotByte <= 9) ? (PlayerSlot)slotByte : PlayerSlot.None;
Color c = PlayerColors.Get(slot);
c.a = 1f;
colorPropertyBlock ??= new MaterialPropertyBlock();
colorPropertyBlock.SetColor(ColorPropertyId, c);
colorPropertyBlock.SetColor(BaseColorPropertyId, c);
foreach (var rend in GetComponentsInChildren())
rend.SetPropertyBlock(colorPropertyBlock);
}
public override void OnNetworkDespawn()
{
if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
s_byClientId.Remove(OwnerClientId);
}
// ----- Per-frame movement (server only) ---------------------------
private void Update()
{
if (!IsServer) return;
// Move toward target on the XZ plane.
Vector3 current = transform.position;
Vector3 target = targetPosition.Value;
// Flatten to XZ for distance/movement calculations; Y is driven by terrain raycast.
Vector3 currentXZ = new Vector3(current.x, 0f, current.z);
Vector3 targetXZ = new Vector3(target.x, 0f, target.z);
Vector3 newXZ;
if (Vector3.SqrMagnitude(currentXZ - targetXZ) <= arrivalThreshold * arrivalThreshold)
{
newXZ = targetXZ;
}
else
{
newXZ = Vector3.MoveTowards(currentXZ, targetXZ, moveSpeed * Time.deltaTime);
}
// Resolve Y from terrain.
float groundY = SampleTerrainY(new Vector3(newXZ.x, 0f, newXZ.z));
transform.position = new Vector3(newXZ.x, groundY + heightOffset, newXZ.z);
}
///
/// Casts a ray straight down at and returns the hit Y, or
/// if nothing was hit on the
/// terrain layer.
///
private float SampleTerrainY(Vector3 xzPos)
{
// Ray origin: high above the map. terrainRaycastMaxDistance defines how far to cast.
Vector3 origin = new Vector3(xzPos.x, terrainRaycastMaxDistance, xzPos.z);
if (Physics.Raycast(origin, Vector3.down, out RaycastHit hit,
terrainRaycastMaxDistance * 2f, terrainLayerMask))
{
return hit.point.y;
}
// Fallback: builder hovers above the buildable plane.
return GridCoordinates.BUILDABLE_PLANE_Y;
}
// ----- Server move API --------------------------------------------
///
/// Server-side entry point: directly sets the move target. Called by the input
/// controller's Rpc handler after validation. Out-of-map positions are clamped
/// to the current position (no-op).
///
public void ServerSetMoveTarget(Vector3 worldPos)
{
if (!IsServer)
{
Debug.LogError("[Builder] ServerSetMoveTarget called on a client.");
return;
}
// Clamp to map area: convert XZ to a tile and check IsInMap.
var loader = LevelLoader.Instance;
if (loader != null && loader.IsLoaded)
{
Vector2Int tile = GridCoordinates.WorldToGrid(worldPos);
if (!loader.IsInMap(tile))
{
// Out-of-map move requests are rejected silently. Could log if useful for
// debugging client/server mismatch, but otherwise this is normal.
return;
}
}
// Y is overwritten by terrain raycast each Update; we only honor X and Z here.
targetPosition.Value = new Vector3(worldPos.x, 0f, worldPos.z);
}
// ----- Owner-only move RPC ----------------------------------------
///
/// Owner-only Rpc: a client requests their builder move to a world position.
/// Server validates (in-map check) and applies via .
///
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestMoveRpc(Vector3 worldPos)
{
ServerSetMoveTarget(worldPos);
}
// ----- Range query (used by TowerPlacementManager) ----------------
///
/// True if the tower with the given anchor and footprint size is within build range
/// of this builder's CURRENT position. Range is measured from builder center to the
/// nearest point of the tower footprint, in world units.
///
///
/// "Nearest point of the footprint" rather than "footprint center" so that a tower
/// is reachable when ANY of its tiles is within range, even if the center is
/// slightly outside. Aligns with player intuition that "I can reach this tile."
///
public bool IsTileWithinBuildRange(Vector2Int anchor, Vector2Int footprintSize)
{
Vector3 builderXZ = new Vector3(transform.position.x, 0f, transform.position.z);
// Find the point on the footprint rectangle nearest to the builder.
float minX = anchor.x * GridCoordinates.TILE_SIZE - GridCoordinates.TILE_SIZE * 0.5f;
float maxX = (anchor.x + footprintSize.x - 1) * GridCoordinates.TILE_SIZE
+ GridCoordinates.TILE_SIZE * 0.5f;
float minZ = anchor.y * GridCoordinates.TILE_SIZE - GridCoordinates.TILE_SIZE * 0.5f;
float maxZ = (anchor.y + footprintSize.y - 1) * GridCoordinates.TILE_SIZE
+ GridCoordinates.TILE_SIZE * 0.5f;
float nearestX = Mathf.Clamp(builderXZ.x, minX, maxX);
float nearestZ = Mathf.Clamp(builderXZ.z, minZ, maxZ);
Vector3 nearestPoint = new Vector3(nearestX, 0f, nearestZ);
return Vector3.SqrMagnitude(builderXZ - nearestPoint)
<= buildRange * buildRange;
}
}
}