// Assets/_Project/Scripts/Gameplay/BuilderInputController.cs
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
using TD.Core;
using TD.UI;
namespace TD.Gameplay
{
///
/// Owner-only client-side controller for builder input. Handles selection,
/// right-click-to-move (with side-effects: pause active construction and
/// refund tail jobs), right-click-on-paused-build-site (resume), and
/// Escape-to-deselect.
///
///
/// Owner-only. This component lives on the same GameObject as
/// but only its owning client processes input. Non-owner
/// clients have this component but its Update is a no-op.
///
/// Selection model. Left-click raycast against the
/// (the builder's selection trigger collider
/// sits on this layer). Hitting the local builder selects it; hitting empty
/// space or another collider clears the selection. Selection lives in the
/// singleton .
///
/// Right-click — selection required. Without selection, right-click
/// is a no-op. With selection:
///
/// - Right-click on the local builder's PAUSED build site (raycast hits
/// ): resume that job. Builder walks back
/// into range; preserved progress picks up where it left off.
/// - Right-click anywhere else on the buildable plane: move-and-pause.
/// Builder walks to the clicked location. Side effects:
///
/// - If currently Constructing → pause the active build, refund
/// tail jobs.
/// - If head is Queued → refund the entire queue.
/// - If head is Paused → no queue change (tail was already
/// refunded at pause time).
///
///
///
///
/// Escape. Clears selection. No queue cancellation in D2 — that's
/// deferred to the HUD path.
///
/// Placement mode interaction. If the local TowerPlacementController
/// reports IsPlacing == true, all input handled here yields to it (placement-mode
/// right-click cancels placement, placement-mode left-click submits). Selection
/// raycasts are also suppressed so a placement click doesn't accidentally
/// deselect the builder.
///
public class BuilderInputController : NetworkBehaviour
{
// ----- Inspector --------------------------------------------------
[Tooltip("Physics layer mask for the BuildablePlane collider. Cursor is raycast " +
"against this layer to determine the move target.")]
[SerializeField] private LayerMask buildablePlaneLayerMask;
[Tooltip("Physics layer mask for selection trigger colliders. Builder selection " +
"colliders AND tower selection colliders both sit on this layer. The " +
"raycast walks up the hit hierarchy to find an ISelectable component, so " +
"any selectable kind on this layer Just Works. The mask must NOT overlap " +
"with BuildablePlane or TerrainGeometry; selection is a separate concern.")]
[SerializeField] private LayerMask selectionLayerMask;
[Tooltip("Physics layer mask for build-site visual click targets. The " +
"BuildSiteVisual prefab carries a small trigger collider on this layer " +
"so right-click can identify a paused build site for resume. Must NOT " +
"overlap with BuildablePlane, Selection, or TerrainGeometry.")]
[SerializeField] private LayerMask buildSiteLayerMask;
[Tooltip("Maximum raycast distance for cursor → world conversion.")]
[SerializeField] private float raycastMaxDistance = 500f;
// Cached reference to the sibling Builder component.
private Builder builder;
// Cached reference to the local TowerPlacementController, looked up lazily because
// it may not exist at OnNetworkSpawn time.
private TowerPlacementController cachedPlacementController;
// ----- Lifecycle --------------------------------------------------
public override void OnNetworkSpawn()
{
builder = GetComponent();
if (builder == null)
{
Debug.LogError("[BuilderInputController] Missing Builder component on the " +
"same GameObject. Disabling input.");
enabled = false;
return;
}
// Non-owners do nothing.
if (!IsOwner)
{
enabled = false;
}
}
// ----- Input handling ---------------------------------------------
private void Update()
{
// Defensive: enabled is set false for non-owners in OnNetworkSpawn, but keep
// the IsOwner check here in case order-of-operations changes.
if (!IsOwner) return;
var mouse = Mouse.current;
var keyboard = Keyboard.current;
if (mouse == null) return;
bool isPlacing = IsLocalPlayerPlacing();
Vector2 mousePos = mouse.position.ReadValue();
// UI Toolkit dispatches button click events AFTER Update runs, but raw mouse
// input is already true this frame. Without this gate, clicking a HUD button
// also fires HandleLeftClickSelection — the raycast misses the builder collider
// and deselects before the button's action fires. Same risk on right-click.
bool pointerOverHud = HUDController.IsPointerOverInteractiveHud(mousePos);
// Left-click: selection. Suppressed during placement mode (left-click is
// the placement-submit gesture there) and when the pointer is over HUD.
if (!isPlacing && !pointerOverHud && mouse.leftButton.wasPressedThisFrame)
{
HandleLeftClickSelection(mousePos);
}
// Escape: clear selection. Allowed during placement mode too — Escape never
// means anything else here, and clearing selection during placement is fine.
// Suppressed while chat (or any HUD text field) has focus, since Escape there
// means "cancel typing" and should not also clear the unit selection.
if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame
&& !HUDController.IsTextInputActive)
{
SelectionState.Instance?.Clear();
}
// Right-click. Suppressed entirely during placement mode (TowerPlacementController
// handles right-click as cancel-placement there) and when over HUD.
if (isPlacing) return;
if (pointerOverHud) return;
if (!mouse.rightButton.wasPressedThisFrame) return;
HandleRightClick(mousePos);
}
// ----- Selection (left-click) -------------------------------------
private void HandleLeftClickSelection(Vector2 screenPos)
{
var selection = SelectionState.Instance;
if (selection == null) return;
var cam = Camera.main;
if (cam == null) return;
Ray ray = cam.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f));
// Single raycast against the unified Selection layer. Whatever we hit, walk
// up its hierarchy to find an ISelectable component (Builder, Tower, or
// any future kind). The closest hit wins automatically — no priority logic
// needed because builders and towers don't visually overlap in practice.
if (Physics.Raycast(ray, out RaycastHit hit, raycastMaxDistance, selectionLayerMask))
{
var hitSelectable = hit.collider.GetComponentInParent();
if (hitSelectable != null)
{
// Don't let players select someone else's builder. Treat that as
// "clicked empty" so we clear, rather than steal their selection.
if (hitSelectable is Builder b && b != builder)
{
selection.Clear();
return;
}
selection.Select(hitSelectable);
return;
}
}
// Clicked empty space or a non-selectable thing — clear selection.
selection.Clear();
}
// ----- Right-click dispatch ---------------------------------------
private void HandleRightClick(Vector2 screenPos)
{
// Right-click is gated by selection: nothing happens unless the local
// builder is the selected one.
var selection = SelectionState.Instance;
if (selection == null || !selection.IsSelected(builder)) return;
var cam = Camera.main;
if (cam == null) return;
Ray ray = cam.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f));
// Test 1: did we hit a build-site visual? If so, and it belongs to OUR
// builder, AND it's currently shelved, this is a resume command.
// The BuildSite raycast is checked first because the build-site visual
// sits ABOVE the buildable plane visually; a click on the cube should
// be interpreted as a build-site click, not a "move to under the cube" click.
if (Physics.Raycast(ray, out RaycastHit buildSiteHit, raycastMaxDistance,
buildSiteLayerMask))
{
var visual = buildSiteHit.collider.GetComponentInParent();
if (visual != null
&& visual.IsShelved
&& visual.OwnerClientId == NetworkManager.Singleton.LocalClientId)
{
// Resume command. Send the visual's NetworkObjectReference so the
// server can identify which shelved tower the player clicked.
// The server handles pre-empting any in-progress queue work.
var visualNetObj = visual.GetComponent();
if (visualNetObj != null)
{
builder.RequestResumeShelvedRpc(new NetworkObjectReference(visualNetObj));
return;
}
}
// Hit a build-site visual that wasn't shelved-and-ours. Fall through
// to the move case so the click still does something useful (move
// the builder to that approximate world position).
}
// Test 2: hit the buildable plane? Standard move-and-pause command.
if (Physics.Raycast(ray, out RaycastHit planeHit, raycastMaxDistance,
buildablePlaneLayerMask))
{
builder.RequestMoveAndPauseRpc(planeHit.point);
return;
}
// Hit nothing relevant — no-op.
}
// ----- Helpers ----------------------------------------------------
private bool IsLocalPlayerPlacing()
{
if (cachedPlacementController == null)
{
// Find lazily — controller may have been added after this component spawned.
cachedPlacementController =
UnityEngine.Object.FindAnyObjectByType();
if (cachedPlacementController == null) return false;
}
return cachedPlacementController.IsPlacing;
}
}
}