281 lines
No EOL
13 KiB
C#
281 lines
No EOL
13 KiB
C#
// Assets/_Project/Scripts/Gameplay/BuilderInputController.cs
|
|
using Unity.Netcode;
|
|
using UnityEngine;
|
|
using UnityEngine.InputSystem;
|
|
using TD.Core;
|
|
using TD.UI;
|
|
|
|
namespace TD.Gameplay
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>Owner-only.</b> This component lives on the same GameObject as
|
|
/// <see cref="Builder"/> but only its owning client processes input. Non-owner
|
|
/// clients have this component but its Update is a no-op.</para>
|
|
///
|
|
/// <para><b>Selection model.</b> Left-click raycast against the
|
|
/// <see cref="selectionLayerMask"/> (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 <see cref="SelectionState"/>.</para>
|
|
///
|
|
/// <para><b>Right-click — selection required.</b> Without selection, right-click
|
|
/// is a no-op. With selection:
|
|
/// <list type="bullet">
|
|
/// <item>Right-click on the local builder's PAUSED build site (raycast hits
|
|
/// <see cref="buildSiteLayerMask"/>): resume that job. Builder walks back
|
|
/// into range; preserved progress picks up where it left off.</item>
|
|
/// <item>Right-click anywhere else on the buildable plane: move-and-pause.
|
|
/// Builder walks to the clicked location. Side effects:
|
|
/// <list type="bullet">
|
|
/// <item>If currently Constructing → pause the active build, refund
|
|
/// tail jobs.</item>
|
|
/// <item>If head is Queued → refund the entire queue.</item>
|
|
/// <item>If head is Paused → no queue change (tail was already
|
|
/// refunded at pause time).</item>
|
|
/// </list>
|
|
/// </item>
|
|
/// </list></para>
|
|
///
|
|
/// <para><b>Escape.</b> Clears selection. No queue cancellation in D2 — that's
|
|
/// deferred to the HUD path.</para>
|
|
///
|
|
/// <para><b>Placement mode interaction.</b> 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.</para>
|
|
/// </remarks>
|
|
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;
|
|
|
|
// Cached reference to the local TowerPaintController, looked up lazily (same
|
|
// rationale as the placement controller).
|
|
private TowerPaintController cachedPaintController;
|
|
|
|
// ----- Lifecycle --------------------------------------------------
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
builder = GetComponent<Builder>();
|
|
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;
|
|
|
|
// Placement and paint are both modal: while either is active, the local
|
|
// controller for that mode owns left-/right-click, so selection here yields.
|
|
bool isModal = IsLocalPlayerPlacing() || IsLocalPlayerPainting();
|
|
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 a modal mode (placement-submit /
|
|
// paint-apply own the click there) and when the pointer is over HUD.
|
|
if (!isModal && !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 a modal mode (placement/paint
|
|
// controllers handle right-click as cancel there) and when over HUD.
|
|
if (isModal) return;
|
|
|
|
// B: toggle buff menu.
|
|
if (keyboard != null && keyboard.bKey.wasPressedThisFrame
|
|
&& !HUDController.IsTextInputActive)
|
|
{
|
|
HUDController.Instance?.ToggleBuffMenu();
|
|
}
|
|
|
|
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<ISelectable>();
|
|
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<BuildSiteVisual>();
|
|
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<NetworkObject>();
|
|
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<TowerPlacementController>();
|
|
if (cachedPlacementController == null) return false;
|
|
}
|
|
return cachedPlacementController.IsPlacing;
|
|
}
|
|
|
|
private bool IsLocalPlayerPainting()
|
|
{
|
|
if (cachedPaintController == null)
|
|
{
|
|
// Find lazily — controller may have been added after this component spawned.
|
|
cachedPaintController =
|
|
UnityEngine.Object.FindAnyObjectByType<TowerPaintController>();
|
|
if (cachedPaintController == null) return false;
|
|
}
|
|
return cachedPaintController.IsPainting;
|
|
}
|
|
}
|
|
} |