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