// Assets/_Project/Scripts/Gameplay/CameraController.cs using UnityEngine; using UnityEngine.InputSystem; using TD.Core; using TD.Levels; using TD.UI; namespace TD.Gameplay { /// /// Per-client RTS-style camera controller. Lives on a "camera rig" GameObject (the pivot) /// with a child 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. /// /// /// Rig math. The pivot's world rotation is set to (pitch, 0, 0). The /// camera child sits at local position (0, 0, -dolly) 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). /// /// Inputs. /// /// WASD or Arrow keys — pan the pivot across the buildable plane. /// Mouse near screen edge — edge-pan, when enabled. /// Scroll wheel — dolly zoom (cursor-anchored). /// Alt + Scroll wheel — adjust pitch. /// /// /// Bounds clamping. The pivot's tile is required to satisfy /// . 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. /// /// Speed scaling. Pan speed scales linearly with the current dolly distance, so /// zoomed-out panning covers more ground per second and zoomed-in panning is precise. /// /// Public API for minimap. , , /// , exist so the minimap (when implemented) /// can drive the camera without this controller knowing about it. /// 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 ------------------------------------------------- /// Whether edge-panning is currently active. Wire to settings UI. public bool EdgePanEnabled { get => edgePanEnabled; set => edgePanEnabled = value; } /// Snaps the pivot to . Y is ignored. Used by the /// minimap for click-to-jump. public void JumpTo(Vector3 worldPos) { Vector3 newPivot = new Vector3(worldPos.x, GridCoordinates.BUILDABLE_PLANE_Y, worldPos.z); TryMovePivotTo(newPivot); ApplyTransform(); } /// Begins external drag mode (e.g., from the minimap). Subsequent /// calls drive the pivot directly, bypassing keyboard/edge /// input until . public void BeginDrag() => isExternalDragActive = true; /// Updates the pivot to the dragged world position. Same effect as /// but intended to be called every frame during a drag. public void UpdateDrag(Vector3 worldPos) => JumpTo(worldPos); /// Ends external drag mode. Normal input handling resumes. public void EndDrag() => isExternalDragActive = false; /// /// Computes where the four screen corners project onto the buildable plane and /// writes them into in the order BL, BR, TR, TL. /// Returns true when every ray hits the plane in front of the camera. When the /// camera angles above the horizon for one or more corners (rare in our pitch /// range), those corner(s) fall back to a far point along the ray and the method /// returns false — the caller may still use the result; the off-plane corners /// just sit far outside the map. Used by the minimap to draw the viewport /// trapezoid (the "what the player sees" rectangle). /// public bool TryGetViewportWorldCorners(Vector3[] cornersOut) { if (cornersOut == null || cornersOut.Length < 4) return false; if (cameraChild == null) return false; // Buildable plane is Y = BUILDABLE_PLANE_Y, normal = up. var plane = new Plane(Vector3.up, new Vector3(0f, GridCoordinates.BUILDABLE_PLANE_Y, 0f)); // Screen-space order: BL, BR, TR, TL (origin at bottom-left, Y up). // Resulting world points form a trapezoid on the buildable plane (camera // is angled, so the far edge — top of screen — projects wider than the // near edge — bottom of screen). float w = Screen.width; float h = Screen.height; cornersOut[0] = ProjectScreenToPlane(new Vector2(0f, 0f), plane, out bool a); cornersOut[1] = ProjectScreenToPlane(new Vector2(w, 0f), plane, out bool b); cornersOut[2] = ProjectScreenToPlane(new Vector2(w, h ), plane, out bool c); cornersOut[3] = ProjectScreenToPlane(new Vector2(0f, h ), plane, out bool d); return a && b && c && d; } // Helper: ray from screen point, intersect plane. If it doesn't hit in front of // the camera (rare — only at horizon-or-above pitches), use a far fallback so // the caller still gets a usable value. private Vector3 ProjectScreenToPlane(Vector2 screenPoint, Plane plane, out bool hit) { Ray ray = cameraChild.ScreenPointToRay(new Vector3(screenPoint.x, screenPoint.y, 0f)); if (plane.Raycast(ray, out float dist) && dist > 0f) { hit = true; return ray.GetPoint(dist); } hit = false; // Far point along the ray as a graceful fallback. Distance picked large enough // that the resulting UI coord lands well outside the minimap bounds and gets // clipped by overflow:hidden. return ray.GetPoint(1000f); } // ----- Lifecycle -------------------------------------------------- private void Start() { if (cameraChild == null) cameraChild = GetComponentInChildren(); 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. Suppressed entirely while the player // is typing — pressing 'a' or 'w' into chat should not pan the camera. // (Edge-pan below stays active since it's mouse-driven.) var kb = HUDController.IsTextInputActive ? null : 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 ------------------------ /// /// Attempts to move the pivot to . 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. /// 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 -------------------------------------- /// /// Applies to the pivot rotation and /// to the camera's local Z offset. Yaw stays at 0. /// 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 -------------------------------- /// /// Casts a ray from the camera through against the /// buildable plane (Y = ). Returns /// false if the ray is parallel to the plane or the cursor is above the horizon. /// 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 ------------------------------------------- /// /// Computes the initial pivot position as the centroid of the local player's owned /// tiles. Falls back to the map center, and finally to /// (the rig's authoring-time position) if neither is available yet. /// 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 ------------------------------------------------ private static PlayerSlot GetLocalPlayerSlot() => PlayerMatchState.Local?.Slot ?? PlayerSlot.None; } }