Adding tons of new functionality
Decals, ghost textures, placement functionality, builder stub ins, a new camera system, and more.
This commit is contained in:
parent
56dc775c68
commit
a63cce53e2
54 changed files with 4817 additions and 238 deletions
303
Assets/_Project/Scripts/Gameplay/Builder.cs
Normal file
303
Assets/_Project/Scripts/Gameplay/Builder.cs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue