// Assets/_Project/Scripts/Gameplay/BuilderInputController.cs using Unity.Netcode; using UnityEngine; using UnityEngine.InputSystem; using TD.Core; 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 the builder selection trigger collider. The " + "builder prefab's child selection collider sits on this layer. 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(); // Left-click: selection. Suppressed during placement mode (left-click is // the placement-submit gesture there). if (!isPlacing && mouse.leftButton.wasPressedThisFrame) { HandleLeftClickSelection(mouse.position.ReadValue()); } // Escape: clear selection. Allowed during placement mode too — Escape never // means anything else here, and clearing selection during placement is fine. if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame) { SelectionState.Instance?.Clear(); } // Right-click. Suppressed entirely during placement mode (TowerPlacementController // handles right-click as cancel-placement there). if (isPlacing) return; if (!mouse.rightButton.wasPressedThisFrame) return; HandleRightClick(mouse.position.ReadValue()); } // ----- 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)); if (Physics.Raycast(ray, out RaycastHit hit, raycastMaxDistance, selectionLayerMask)) { // Walk up the hierarchy to find a Builder component (the selection // collider may sit on a child of the Builder's root). var hitBuilder = hit.collider.GetComponentInParent(); // Only allow selecting OUR builder. A click on someone else's builder // collider clears our selection rather than selecting theirs. if (hitBuilder != null && hitBuilder == builder) { selection.Select(builder); 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; } } }