Decals, ghost textures, placement functionality, builder stub ins, a new camera system, and more.
303 lines
No EOL
14 KiB
C#
303 lines
No EOL
14 KiB
C#
// Assets/_Project/Scripts/Gameplay/Builder.cs
|
|
using System.Collections.Generic;
|
|
using Unity.Netcode;
|
|
using UnityEngine;
|
|
using TD.Core;
|
|
using TD.Levels;
|
|
|
|
namespace TD.Gameplay
|
|
{
|
|
/// <summary>
|
|
/// Per-player avatar that gates tower placement by proximity. Server-authoritative
|
|
/// position; clients submit move requests via Rpc and the server validates and applies.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>Pure visual avatar.</b> 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.</para>
|
|
///
|
|
/// <para><b>Terrain-aware height.</b> Each frame the server casts a ray straight down
|
|
/// from the builder against the <see cref="terrainLayerMask"/> and sets Y to
|
|
/// <c>hit.point.y + heightOffset</c>. If the ray misses, falls back to the buildable
|
|
/// plane Y. Towers are not on the terrain layer, so they don't influence height.</para>
|
|
///
|
|
/// <para><b>Range gating.</b> <see cref="IsTileWithinBuildRange"/> is the public query
|
|
/// that <c>TowerPlacementManager</c> uses to validate placement requests. Builder range
|
|
/// is measured center-of-builder to center-of-anchor-tile in world units.</para>
|
|
///
|
|
/// <para><b>Static registry.</b> Like <see cref="PlayerGoldManager"/>, builders register
|
|
/// themselves in a static dictionary keyed by <c>OwnerClientId</c> on spawn, so server
|
|
/// gameplay code (notably <c>TowerPlacementManager</c>) can find a player's builder without
|
|
/// scene traversal.</para>
|
|
/// </remarks>
|
|
[RequireComponent(typeof(NetworkObject))]
|
|
public class Builder : NetworkBehaviour
|
|
{
|
|
// ----- Static registry --------------------------------------------
|
|
|
|
private static readonly Dictionary<ulong, Builder> s_byClientId
|
|
= new Dictionary<ulong, Builder>();
|
|
|
|
/// <summary>
|
|
/// Returns the Builder owned by the given client, or null if none is currently spawned.
|
|
/// Safe to call on server or client.
|
|
/// </summary>
|
|
public static Builder GetForClient(ulong clientId)
|
|
{
|
|
s_byClientId.TryGetValue(clientId, out var builder);
|
|
return builder;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convenience: the local client's own builder. Returns null on a dedicated server
|
|
/// or before the local player has spawned.
|
|
/// </summary>
|
|
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<Vector3> targetPosition = new NetworkVariable<Vector3>(
|
|
value: Vector3.zero,
|
|
readPerm: NetworkVariableReadPermission.Everyone,
|
|
writePerm: NetworkVariableWritePermission.Server);
|
|
|
|
// ----- Public accessors -------------------------------------------
|
|
|
|
/// <summary>The builder's current world position (its actual transform position,
|
|
/// not the target).</summary>
|
|
public Vector3 CurrentPosition => transform.position;
|
|
|
|
/// <summary>The builder's target position. Server moves toward this each frame.</summary>
|
|
public Vector3 TargetPosition => targetPosition.Value;
|
|
|
|
/// <summary>True if the builder has arrived at its target (within
|
|
/// <see cref="arrivalThreshold"/>).</summary>
|
|
public bool IsAtTarget =>
|
|
Vector3.SqrMagnitude(transform.position - targetPosition.Value)
|
|
< arrivalThreshold * arrivalThreshold;
|
|
|
|
/// <summary>Build range in world units.</summary>
|
|
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<MeshRenderer>())
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Casts a ray straight down at <paramref name="xzPos"/> and returns the hit Y, or
|
|
/// <see cref="GridCoordinates.BUILDABLE_PLANE_Y"/> if nothing was hit on the
|
|
/// terrain layer.
|
|
/// </summary>
|
|
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 --------------------------------------------
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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 ----------------------------------------
|
|
|
|
/// <summary>
|
|
/// Owner-only Rpc: a client requests their builder move to a world position.
|
|
/// Server validates (in-map check) and applies via <see cref="ServerSetMoveTarget"/>.
|
|
/// </summary>
|
|
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
|
public void RequestMoveRpc(Vector3 worldPos)
|
|
{
|
|
ServerSetMoveTarget(worldPos);
|
|
}
|
|
|
|
// ----- Range query (used by TowerPlacementManager) ----------------
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// "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."
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
}
|
|
} |