Adding a ton of funcitonality to the builder's movement and build queue

This commit is contained in:
Matt F 2026-05-05 22:01:40 -07:00
parent a63cce53e2
commit f05734e19b
31 changed files with 3104 additions and 339 deletions

View file

@ -12,33 +12,29 @@ namespace TD.Gameplay
/// requests to <see cref="TowerPlacementManager"/> via RPC.
/// </summary>
/// <remarks>
/// <para><b>Plain MonoBehaviour.</b> Placement visuals (ghost, cursor color) are
/// <para><b>Plain MonoBehaviour.</b> Placement visuals (cursor ghost, color) are
/// purely cosmetic and local. This component does not need to be a NetworkBehaviour.
/// All server-authoritative logic lives in <see cref="TowerPlacementManager"/>.</para>
///
/// <para><b>Ghost validity check.</b> The ghost checks ownership, placement state,
/// and tile occupancy only. It does NOT run a local path check. If the server rejects
/// because the tower would block the path, the ghost disappears and a rejection
/// message is shown. This avoids the complexity of maintaining a client-side BFS
/// that may be slightly stale.</para>
/// <para><b>Ghost validity check.</b> The cursor ghost checks ownership, placement
/// state, and tile occupancy only. It does NOT run a local path check. If the server
/// rejects because the tower would block the path, the cursor ghost disappears and a
/// rejection message is shown.</para>
///
/// <para><b>Ghost colors.</b>
/// <list type="bullet">
/// <item>White — all local checks pass.</item>
/// <item>Red — any local check fails (wrong zone, not buildable, already occupied).</item>
/// </list>
/// Green "pending construction" ghost is a separate system implemented in Path D.</para>
/// <para><b>Two ghosts, two systems.</b> The <i>cursor ghost</i> (handled here)
/// follows the mouse and turns white/red. The <i>build-site visual</i> (in D2,
/// handled by Builder + BuildSiteVisual) is the green queued ghost or staged
/// construction visual that appears at a confirmed placement site. They are
/// distinct prefabs and lifecycles.</para>
///
/// <para><b>Placement activation.</b> The controller is idle until
/// <see cref="BeginPlacement"/> is called (e.g., from a HUD tower button). The player
/// right-clicks or the placement is confirmed/rejected to return to idle.</para>
/// <para><b>Chained queueing.</b> Holding Shift while left-clicking submits the
/// placement and stays in placement mode for another submission. Releasing Shift
/// before the click submits and exits placement (single-shot). Right-click always
/// cancels.</para>
///
/// <para><b>Input System.</b> Uses the New Input System package. Mouse position and
/// button state are read from <c>Mouse.current</c> each frame.</para>
///
/// <para><b>Player slot.</b> The local player slot is currently a stub
/// (client 0 = Player1, etc.) matching <c>TowerPlacementManager.ClientIdToPlayerSlot</c>.
/// This will be replaced when MatchState carries the authoritative slot assignment.</para>
/// <para><b>Input System.</b> Uses the New Input System package. Mouse and modifier
/// state are read directly from <c>Mouse.current</c> and <c>Keyboard.current</c>
/// each frame. No InputAction asset needed for these placement-specific bindings.</para>
/// </remarks>
public class TowerPlacementController : MonoBehaviour
{
@ -62,7 +58,7 @@ namespace TD.Gameplay
private TowerDefinition activeDef;
private int activeTowerTypeId;
// The ghost GameObject: the tower prefab instantiated with transparent materials.
// The cursor ghost GameObject: the tower prefab instantiated with transparent materials.
// Null when placement mode is inactive.
private GameObject ghostGO;
@ -131,7 +127,7 @@ namespace TD.Gameplay
// Compute the footprint anchor from the hit point.
Vector2Int anchor = ComputeAnchor(hitPoint, activeDef.FootprintSize);
// Position the ghost at the footprint center.
// Position the cursor ghost at the footprint center.
Vector3 ghostPos = GridCoordinates.GetFootprintCenterWorld(anchor, activeDef.FootprintSize);
ghostPos.y = 0.5f; // lift off the plane so the cube base sits flush
ghostGO.transform.position = ghostPos;
@ -151,7 +147,8 @@ namespace TD.Gameplay
// Left-click to attempt placement.
if (mouse.leftButton.wasPressedThisFrame)
{
TrySubmitPlacement(anchor);
bool chained = IsShiftHeld();
TrySubmitPlacement(anchor, chained);
}
}
@ -161,9 +158,6 @@ namespace TD.Gameplay
/// Activates placement mode for the given tower type. The ghost appears
/// immediately under the cursor. Call this from HUD tower buttons.
/// </summary>
/// <param name="def">The TowerDefinition to place.</param>
/// <param name="towerTypeId">The type ID registered in
/// <see cref="TowerPlacementManager"/>.</param>
public void BeginPlacement(TowerDefinition def, int towerTypeId)
{
if (def == null)
@ -199,6 +193,15 @@ namespace TD.Gameplay
/// </summary>
public bool IsPlacing => activeDef != null;
// ----- Modifier helpers -------------------------------------------
private static bool IsShiftHeld()
{
var kb = Keyboard.current;
if (kb == null) return false;
return kb.leftShiftKey.isPressed || kb.rightShiftKey.isPressed;
}
// ----- Ghost management -------------------------------------------
private void CreateGhost(TowerDefinition def)
@ -284,10 +287,6 @@ namespace TD.Gameplay
foreach (var rend in ghostRenderers)
{
rend.sharedMaterial = ghostMat;
// Property block is set empty here — color comes from the material itself.
// If the ghost materials use _Color, override it via the block instead:
// ghostPropertyBlock.SetColor(ColorPropertyId, valid ? Color.white : Color.red);
// rend.SetPropertyBlock(ghostPropertyBlock);
}
}
@ -315,11 +314,6 @@ namespace TD.Gameplay
/// Converts a world hit point to the footprint anchor tile (SW corner) such
/// that the footprint center is as close as possible to the hit point.
/// </summary>
/// <remarks>
/// For a 2×2 footprint: anchor = (Round(hitX - 0.5), Round(hitZ - 0.5))
/// For a 1×1 footprint: anchor = (Round(hitX), Round(hitZ))
/// For a 3×3 footprint: anchor = (Round(hitX - 1.0), Round(hitZ - 1.0))
/// </remarks>
private static Vector2Int ComputeAnchor(Vector3 hitPoint, Vector2Int footprintSize)
{
float t = GridCoordinates.TILE_SIZE;
@ -357,7 +351,7 @@ namespace TD.Gameplay
// ----- Placement submission ---------------------------------------
private void TrySubmitPlacement(Vector2Int anchor)
private void TrySubmitPlacement(Vector2Int anchor, bool chained)
{
var manager = TowerPlacementManager.Instance;
if (manager == null)
@ -369,13 +363,19 @@ namespace TD.Gameplay
// Send the RPC regardless of local validity state — the server is
// authoritative. The local check drives the ghost color only.
// The server will reject and send back a reason if invalid.
manager.RequestPlaceTowerRpc(anchor.x, anchor.y, activeTowerTypeId);
// Exit placement mode immediately after submitting. If the server
// rejects, the rejection message fires via HandlePlacementRejected.
// If it accepts, the TowerInstance NetworkObject spawns and the
// placed tower appears — no ghost lingering is needed.
if (chained)
{
// Stay in placement mode. The server will stamp occupancy on success,
// so when EvaluateLocalValidity runs next frame it will correctly
// report the just-clicked tile as occupied (cursor turns red there).
// Force a re-evaluation by invalidating the cached anchor.
lastAnchorValid = false;
return;
}
// Single-shot: exit placement mode immediately.
CancelPlacement();
}
@ -395,7 +395,6 @@ namespace TD.Gameplay
Debug.Log($"[TowerPlacementController] Placement rejected: {reason} → \"{message}\"");
// Fire the event so HUD components can display the message on screen.
// The HUD that subscribes and renders this is implemented in a later path.
OnRejectionMessageReady?.Invoke(message);
}