UnityTowerDefense/Assets/_Project/Scripts/Gameplay/CameraController.cs
2026-05-10 22:26:55 -07:00

466 lines
No EOL
20 KiB
C#

// Assets/_Project/Scripts/Gameplay/CameraController.cs
using UnityEngine;
using UnityEngine.InputSystem;
using TD.Core;
using TD.Levels;
using TD.UI;
namespace TD.Gameplay
{
/// <summary>
/// Per-client RTS-style camera controller. Lives on a "camera rig" GameObject (the pivot)
/// with a child <see cref="Camera"/> GameObject. The pivot tracks the focus point on the
/// buildable plane; the camera child orbits the pivot at a configurable pitch and dolly
/// distance. Yaw is fixed.
/// </summary>
/// <remarks>
/// <para><b>Rig math.</b> The pivot's world rotation is set to <c>(pitch, 0, 0)</c>. The
/// camera child sits at local position <c>(0, 0, -dolly)</c> with identity local rotation.
/// When the pivot rotates, the child's world position rotates with it, placing the camera
/// up-and-back from the pivot at the right angle to look down at it. At pitch 0° the camera
/// is horizontal (worm's-eye); at 90° it's directly overhead (top-down).</para>
///
/// <para><b>Inputs.</b>
/// <list type="bullet">
/// <item><b>WASD or Arrow keys</b> — pan the pivot across the buildable plane.</item>
/// <item><b>Mouse near screen edge</b> — edge-pan, when enabled.</item>
/// <item><b>Scroll wheel</b> — dolly zoom (cursor-anchored).</item>
/// <item><b>Alt + Scroll wheel</b> — adjust pitch.</item>
/// </list></para>
///
/// <para><b>Bounds clamping.</b> The pivot's tile is required to satisfy
/// <see cref="LevelLoader.IsInMap"/>. When a frame's combined movement would leave the map,
/// the controller tries each axis independently and applies whichever still lands in-map —
/// producing wall-sliding behavior. If the loader isn't ready yet, clamping is skipped.</para>
///
/// <para><b>Speed scaling.</b> Pan speed scales linearly with the current dolly distance, so
/// zoomed-out panning covers more ground per second and zoomed-in panning is precise.</para>
///
/// <para><b>Public API for minimap.</b> <see cref="JumpTo"/>, <see cref="BeginDrag"/>,
/// <see cref="UpdateDrag"/>, <see cref="EndDrag"/> exist so the minimap (when implemented)
/// can drive the camera without this controller knowing about it.</para>
/// </remarks>
public class CameraController : MonoBehaviour
{
// ----- Inspector --------------------------------------------------
[Header("Rig")]
[Tooltip("The camera GameObject that lives as a child of this rig pivot. " +
"Auto-found via GetComponentInChildren if left empty.")]
[SerializeField] private Camera cameraChild;
[Header("Pan")]
[Tooltip("Base pan speed in world units per second at minimum zoom. " +
"Effective speed scales linearly with current dolly distance.")]
[SerializeField] private float basePanSpeed = 12f;
[Tooltip("Distance in pixels from each screen edge that triggers edge-pan. " +
"Smaller = needs precise mouse positioning; larger = activates more easily.")]
[SerializeField] private float edgePanMarginPixels = 16f;
[Tooltip("Whether edge-panning is enabled. Toggleable at runtime via " +
"EdgePanEnabled property — wire this to a settings menu when one exists.")]
[SerializeField] private bool edgePanEnabled = true;
[Header("Zoom")]
[Tooltip("Closest zoom (smallest dolly distance from pivot to camera).")]
[SerializeField] private float minDollyDistance = 5f;
[Tooltip("Farthest zoom (largest dolly distance from pivot to camera).")]
[SerializeField] private float maxDollyDistance = 50f;
[Tooltip("Starting dolly distance at match start.")]
[SerializeField] private float startDollyDistance = 20f;
[Tooltip("Dolly distance change per scroll wheel tick (positive value; sign is " +
"applied based on scroll direction). Tuned for ±1-per-click scroll input. " +
"Adjust to taste.")]
[SerializeField] private float zoomSpeed = 3f;
[Tooltip("If true, zooming pulls the world toward the cursor — the screen point under " +
"the cursor stays roughly anchored to the same world point. If false, zoom " +
"centers on the screen midpoint.")]
[SerializeField] private bool cursorAnchoredZoom = true;
[Header("Pitch")]
[Tooltip("Minimum pitch angle in degrees. 0° is horizontal; 90° is top-down.")]
[SerializeField] private float minPitchDegrees = 30f;
[Tooltip("Maximum pitch angle in degrees. 0° is horizontal; 90° is top-down.")]
[SerializeField] private float maxPitchDegrees = 75f;
[Tooltip("Starting pitch angle at match start.")]
[SerializeField] private float startPitchDegrees = 60f;
[Tooltip("Pitch change in degrees per Alt+Scroll tick. Tuned for ±1-per-click scroll " +
"input. Adjust to taste.")]
[SerializeField] private float pitchSpeed = 4f;
// ----- Runtime state ----------------------------------------------
private float currentDolly;
private float currentPitch;
// True between BeginDrag and EndDrag (minimap drag mode). Suppresses normal
// input handling so minimap-driven movement isn't fighting keyboard/edge-pan.
private bool isExternalDragActive;
// ----- Public API -------------------------------------------------
/// <summary>Whether edge-panning is currently active. Wire to settings UI.</summary>
public bool EdgePanEnabled
{
get => edgePanEnabled;
set => edgePanEnabled = value;
}
/// <summary>Snaps the pivot to <paramref name="worldPos"/>. Y is ignored. Used by the
/// minimap for click-to-jump.</summary>
public void JumpTo(Vector3 worldPos)
{
Vector3 newPivot = new Vector3(worldPos.x, GridCoordinates.BUILDABLE_PLANE_Y, worldPos.z);
TryMovePivotTo(newPivot);
ApplyTransform();
}
/// <summary>Begins external drag mode (e.g., from the minimap). Subsequent
/// <see cref="UpdateDrag"/> calls drive the pivot directly, bypassing keyboard/edge
/// input until <see cref="EndDrag"/>.</summary>
public void BeginDrag() => isExternalDragActive = true;
/// <summary>Updates the pivot to the dragged world position. Same effect as
/// <see cref="JumpTo"/> but intended to be called every frame during a drag.</summary>
public void UpdateDrag(Vector3 worldPos) => JumpTo(worldPos);
/// <summary>Ends external drag mode. Normal input handling resumes.</summary>
public void EndDrag() => isExternalDragActive = false;
// ----- Lifecycle --------------------------------------------------
private void Start()
{
if (cameraChild == null) cameraChild = GetComponentInChildren<Camera>();
if (cameraChild == null)
{
Debug.LogError("[CameraController] No child Camera found. Place a Camera as " +
"a child of this GameObject or assign it in the inspector.");
enabled = false;
return;
}
currentDolly = Mathf.Clamp(startDollyDistance, minDollyDistance, maxDollyDistance);
currentPitch = Mathf.Clamp(startPitchDegrees, minPitchDegrees, maxPitchDegrees);
// Compute initial pivot position. Falls back to current transform if loader or
// local-player data isn't ready (e.g., editor preview before Start Host).
Vector3 startPivot = ComputeStartPivot(transform.position);
transform.position = startPivot;
ApplyTransform();
}
private void Update()
{
if (isExternalDragActive)
{
// Minimap or other external system is driving — don't fight it.
return;
}
HandleZoomAndPitch();
HandlePan();
}
// ----- Input handlers ---------------------------------------------
private void HandlePan()
{
Vector2 dir = Vector2.zero;
// Keyboard: WASD + arrow keys
var kb = Keyboard.current;
if (kb != null)
{
if (kb.aKey.isPressed || kb.leftArrowKey.isPressed) dir.x -= 1f;
if (kb.dKey.isPressed || kb.rightArrowKey.isPressed) dir.x += 1f;
if (kb.sKey.isPressed || kb.downArrowKey.isPressed) dir.y -= 1f;
if (kb.wKey.isPressed || kb.upArrowKey.isPressed) dir.y += 1f;
}
// Edge-pan: mouse near screen edge adds to keyboard direction.
if (edgePanEnabled && Mouse.current != null)
{
Vector2 mousePos = Mouse.current.position.ReadValue();
if (mousePos.x <= edgePanMarginPixels) dir.x -= 1f;
if (mousePos.x >= Screen.width - edgePanMarginPixels) dir.x += 1f;
if (mousePos.y <= edgePanMarginPixels) dir.y -= 1f;
if (mousePos.y >= Screen.height - edgePanMarginPixels) dir.y += 1f;
}
if (dir.sqrMagnitude < 0.0001f) return;
// Normalize to prevent diagonal speed boost when both axes active.
if (dir.sqrMagnitude > 1f) dir.Normalize();
// Scale speed by current zoom: at min zoom multiplier=1, at max zoom multiplier
// is the ratio of max-to-min dolly distance.
float speedMultiplier = currentDolly / minDollyDistance;
float distance = basePanSpeed * speedMultiplier * Time.deltaTime;
// dir.x maps to world X, dir.y maps to world Z (since pivot is on XZ plane).
Vector3 desired = transform.position + new Vector3(dir.x, 0f, dir.y) * distance;
TryMovePivotTo(desired);
ApplyTransform();
}
private void HandleZoomAndPitch()
{
var mouse = Mouse.current;
var kb = Keyboard.current;
if (mouse == null) return;
// If the cursor is over an interactive HUD element (e.g., the minimap, which has
// its own scroll-wheel zoom), don't also drive the world camera. UI Toolkit's
// WheelEvent.StopPropagation only blocks UI-side bubbling — it has no effect on
// raw Input System polling, so we have to gate here explicitly.
if (HUDController.IsPointerOverInteractiveHud(mouse.position.ReadValue())) return;
// Scroll wheel values are inconsistent across platforms and devices:
// - Windows click-wheel mice: ±120 per click (legacy Win32 convention)
// - Free-spin wheels and trackpads: small fractional values per tick
// - macOS / Linux mice: typically ±1 per click
// We use the raw value with no normalization. Speed defaults are tuned for
// ±1-per-click hardware (Steam Deck, macOS, many Linux/Windows mice). On
// ±120-per-click hardware, scroll naturally feels faster — which usually
// matches the "discrete tick" feel users expect from those mice anyway.
float scrollDelta = mouse.scroll.ReadValue().y;
if (Mathf.Abs(scrollDelta) < 0.0001f) return;
bool altHeld = kb != null &&
(kb.leftAltKey.isPressed || kb.rightAltKey.isPressed);
if (altHeld)
{
// Alt + scroll: adjust pitch.
currentPitch = Mathf.Clamp(currentPitch - scrollDelta * pitchSpeed,
minPitchDegrees, maxPitchDegrees);
ApplyTransform();
}
else
{
// Plain scroll: dolly zoom. Negative scroll = zoom out.
ApplyZoom(scrollDelta * zoomSpeed, mouse.position.ReadValue());
}
}
// ----- Zoom with cursor anchor ------------------------------------
private void ApplyZoom(float zoomDelta, Vector2 cursorScreen)
{
if (!cursorAnchoredZoom)
{
currentDolly = Mathf.Clamp(currentDolly - zoomDelta,
minDollyDistance, maxDollyDistance);
ApplyTransform();
return;
}
// Cursor-anchored zoom:
// 1. Find the world point under the cursor BEFORE the zoom.
// 2. Apply zoom (changes dolly).
// 3. Find the world point under the cursor AFTER the zoom.
// 4. Translate the pivot by (before - after) so the cursor's world point stays put.
if (!TryGroundPlanePoint(cursorScreen, out Vector3 before))
{
// Cursor not over the ground plane — fall back to non-anchored zoom.
currentDolly = Mathf.Clamp(currentDolly - zoomDelta,
minDollyDistance, maxDollyDistance);
ApplyTransform();
return;
}
currentDolly = Mathf.Clamp(currentDolly - zoomDelta,
minDollyDistance, maxDollyDistance);
ApplyTransform();
if (!TryGroundPlanePoint(cursorScreen, out Vector3 after))
{
return;
}
Vector3 desiredPivot = transform.position + (before - after);
TryMovePivotTo(desiredPivot);
ApplyTransform();
}
// ----- Pivot movement with bounds clamping ------------------------
/// <summary>
/// Attempts to move the pivot to <paramref name="desired"/>. If the new position would
/// leave the map, tries each axis independently and applies whichever still lands in
/// the map (wall-sliding). If neither axis works, the pivot doesn't move.
/// </summary>
private void TryMovePivotTo(Vector3 desired)
{
// If LevelLoader isn't loaded, accept the move as-is (editor preview, early frames).
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded)
{
transform.position = new Vector3(desired.x, GridCoordinates.BUILDABLE_PLANE_Y, desired.z);
return;
}
Vector3 current = transform.position;
if (IsPositionInMap(loader, desired))
{
transform.position = new Vector3(desired.x, GridCoordinates.BUILDABLE_PLANE_Y, desired.z);
return;
}
// Try X-only move (slide along the Z wall).
Vector3 xOnly = new Vector3(desired.x, current.y, current.z);
if (IsPositionInMap(loader, xOnly))
{
transform.position = new Vector3(xOnly.x, GridCoordinates.BUILDABLE_PLANE_Y, xOnly.z);
return;
}
// Try Z-only move (slide along the X wall).
Vector3 zOnly = new Vector3(current.x, current.y, desired.z);
if (IsPositionInMap(loader, zOnly))
{
transform.position = new Vector3(zOnly.x, GridCoordinates.BUILDABLE_PLANE_Y, zOnly.z);
return;
}
// Both axes blocked — don't move.
}
private static bool IsPositionInMap(LevelLoader loader, Vector3 worldPos)
{
Vector2Int tile = GridCoordinates.WorldToGrid(worldPos);
return loader.IsInMap(tile);
}
// ----- Transform composition --------------------------------------
/// <summary>
/// Applies <see cref="currentPitch"/> to the pivot rotation and
/// <see cref="currentDolly"/> to the camera's local Z offset. Yaw stays at 0.
/// </summary>
private void ApplyTransform()
{
transform.rotation = Quaternion.Euler(currentPitch, 0f, 0f);
cameraChild.transform.localPosition = new Vector3(0f, 0f, -currentDolly);
cameraChild.transform.localRotation = Quaternion.identity;
}
// ----- Ground-plane raycast helper --------------------------------
/// <summary>
/// Casts a ray from the camera through <paramref name="screenPos"/> against the
/// buildable plane (Y = <see cref="GridCoordinates.BUILDABLE_PLANE_Y"/>). Returns
/// false if the ray is parallel to the plane or the cursor is above the horizon.
/// </summary>
private bool TryGroundPlanePoint(Vector2 screenPos, out Vector3 worldPos)
{
worldPos = default;
Ray ray = cameraChild.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f));
Plane ground = new Plane(Vector3.up, new Vector3(0f, GridCoordinates.BUILDABLE_PLANE_Y, 0f));
if (!ground.Raycast(ray, out float enter)) return false;
worldPos = ray.GetPoint(enter);
return true;
}
// ----- Initial position -------------------------------------------
/// <summary>
/// Computes the initial pivot position as the centroid of the local player's owned
/// tiles. Falls back to the map center, and finally to <paramref name="fallback"/>
/// (the rig's authoring-time position) if neither is available yet.
/// </summary>
private Vector3 ComputeStartPivot(Vector3 fallback)
{
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded) return fallback;
PlayerSlot localSlot = GetLocalPlayerSlot();
if (localSlot != PlayerSlot.None)
{
if (TryComputeZoneCentroid(loader, localSlot, out Vector3 centroid))
return centroid;
}
// Fall back to map center.
return ComputeMapCenter(loader.LevelData);
}
private static bool TryComputeZoneCentroid(LevelLoader loader, PlayerSlot slot,
out Vector3 centroid)
{
centroid = default;
var levelData = loader.LevelData;
if (levelData == null || levelData.OwnerGrid == null) return false;
// Sum the world positions of every tile owned by this slot.
// Using Vector2 sums to avoid Y precision drift.
Vector2 sum = Vector2.zero;
int count = 0;
for (int y = 0; y < levelData.GridSize.y; y++)
{
for (int x = 0; x < levelData.GridSize.x; x++)
{
int idx = y * levelData.GridSize.x + x;
if (levelData.OwnerGrid[idx] != slot) continue;
Vector2Int tile = new Vector2Int(
levelData.GridOriginTile.x + x,
levelData.GridOriginTile.y + y);
Vector3 world = GridCoordinates.GridToWorld(tile);
sum.x += world.x;
sum.y += world.z;
count++;
}
}
if (count == 0) return false;
centroid = new Vector3(sum.x / count, GridCoordinates.BUILDABLE_PLANE_Y, sum.y / count);
return true;
}
private static Vector3 ComputeMapCenter(LevelData levelData)
{
if (levelData == null) return Vector3.zero;
float cx = (levelData.GridOriginTile.x + (levelData.GridSize.x - 1) * 0.5f)
* GridCoordinates.TILE_SIZE;
float cz = (levelData.GridOriginTile.y + (levelData.GridSize.y - 1) * 0.5f)
* GridCoordinates.TILE_SIZE;
return new Vector3(cx, GridCoordinates.BUILDABLE_PLANE_Y, cz);
}
// ----- Player slot ------------------------------------------------
/// <summary>
/// Returns the local player's PlayerSlot.
/// STUB: same trivial mapping used elsewhere; replaced when MatchState lands.
/// </summary>
private static PlayerSlot GetLocalPlayerSlot()
{
var nm = Unity.Netcode.NetworkManager.Singleton;
if (nm == null || !nm.IsClient) return PlayerSlot.None;
ulong clientId = nm.LocalClientId;
byte slotByte = (byte)(clientId + 1);
if (slotByte < 1 || slotByte > 9) return PlayerSlot.None;
return (PlayerSlot)slotByte;
}
}
}