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