Decals, ghost textures, placement functionality, builder stub ins, a new camera system, and more.
459 lines
No EOL
20 KiB
C#
459 lines
No EOL
20 KiB
C#
// Assets/_Project/Scripts/Gameplay/CameraController.cs
|
|
using UnityEngine;
|
|
using UnityEngine.InputSystem;
|
|
using TD.Core;
|
|
using TD.Levels;
|
|
|
|
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;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
} |